distribution: establish the OS release contract
This commit is contained in:
parent
287acdffd9
commit
d1312cbb06
5 changed files with 291 additions and 0 deletions
21
.gitea/workflows/distribution.yml
Normal file
21
.gitea/workflows/distribution.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: distribution-contract
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- distribution/**
|
||||
- tools/validate-distribution.py
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- distribution/**
|
||||
- tools/validate-distribution.py
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Validate the release and installer graph
|
||||
run: python3 tools/validate-distribution.py distribution/manifest.toml
|
||||
45
distribution/README.md
Normal file
45
distribution/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# SouveraineOS distribution contract
|
||||
|
||||
`SouveraineOS` is the operating-system repository. This directory is its
|
||||
release and installation authority.
|
||||
|
||||
`../souveraine` is one component: the agent substrate, shell, and the package
|
||||
that delivers it. `../Pixel3Arch` is another: device packages and the blueline
|
||||
kernel. Neither decides what a SouveraineOS body is, what it installs, or where
|
||||
its release lands. Those decisions live here, once.
|
||||
|
||||
## What this owns
|
||||
|
||||
- `manifest.toml` is the canonical body, package, producer, and installer
|
||||
profile graph.
|
||||
- The signed `edge` archive is one multi-producer archive. Its implementation
|
||||
currently lives in `souveraine/packaging/arch/publish-edge.sh`; this manifest
|
||||
names it so consumers do not grow their own publisher.
|
||||
- `../tools/validate-distribution.py` checks graph integrity without requiring
|
||||
a runner, a device, or a secret.
|
||||
- `.gitea/workflows/distribution.yml` makes that check a gate in the OS repo.
|
||||
|
||||
The manifest is deliberately honest about maturity. A profile marked `blocked`
|
||||
is a desired composition with named missing work, not an installer claim. Only
|
||||
a `ready` profile is eligible for a public installer or release channel.
|
||||
|
||||
## The install shape
|
||||
|
||||
An eventual graphical installer can be Calamares-shaped, but it must not own a
|
||||
second device matrix. It selects one target ID from this manifest, obtains that
|
||||
profile's signed packages, applies only its declared boot and device packages,
|
||||
then runs the target's commissioning step. The UI is downstream of this file.
|
||||
|
||||
The first implementation remains a non-destructive provisioner: resolve a
|
||||
target, verify that its profile is `ready`, and emit the exact package and boot
|
||||
plan. Partitioning, credential enrollment, and device-local secrets require
|
||||
their own explicit installer work.
|
||||
|
||||
## Producer rule
|
||||
|
||||
Component workflows build code; they do not independently choose the release
|
||||
set. A producer may publish only package names and architectures declared here.
|
||||
The next pipeline change is to make each producer fetch this manifest at the
|
||||
commit carried by its build and reject an undeclared package before
|
||||
`publish-edge.sh` runs. That is how a new body becomes one profile and package
|
||||
change here, rather than another private chain of scripts.
|
||||
115
distribution/manifest.toml
Normal file
115
distribution/manifest.toml
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# SouveraineOS distribution graph. Component repositories own source and
|
||||
# package recipes; this file owns release composition and installer selection.
|
||||
schema = 1
|
||||
|
||||
[archive]
|
||||
repository = "Fimeg/souveraine"
|
||||
channel = "edge"
|
||||
architectures = ["x86_64", "aarch64"]
|
||||
publisher_repository = "Fimeg/souveraine"
|
||||
publisher_path = "packaging/arch/publish-edge.sh"
|
||||
publisher_rule = "additive"
|
||||
|
||||
[producers.souveraine]
|
||||
repository = "Fimeg/souveraine"
|
||||
workflow = ".gitea/workflows/ci.yml"
|
||||
architectures = ["x86_64", "aarch64"]
|
||||
|
||||
[producers.souveraine-updater]
|
||||
repository = "Fimeg/souveraine-updater"
|
||||
workflow = ".gitea/workflows/ci.yml"
|
||||
architectures = ["x86_64", "aarch64"]
|
||||
|
||||
[producers.pixel3arch]
|
||||
repository = "Fimeg/Pixel3Arch"
|
||||
workflow = ".gitea/workflows/packages.yml"
|
||||
architectures = ["aarch64"]
|
||||
|
||||
# Current packages. `managed` means a producer and shared archive path exist;
|
||||
# it does not assert that the newest run is green. `blocked` and `planned` are
|
||||
# intentionally visible to the installer gate.
|
||||
[packages]
|
||||
souveraine = { producer = "souveraine", architectures = ["x86_64", "aarch64"], state = "managed" }
|
||||
upower-souveraine = { producer = "souveraine", architectures = ["x86_64", "aarch64"], state = "managed" }
|
||||
souveraine-updater = { producer = "souveraine-updater", architectures = ["x86_64", "aarch64"], state = "managed" }
|
||||
souveraine-viewtop = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
linux-blueline = { producer = "pixel3arch", architectures = ["aarch64"], state = "blocked", blocker = "kernel.yml does not publish into the shared archive" }
|
||||
souveraine-callaudio = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
souveraine-callaudiod = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
souveraine-ucm-blueline = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
souveraine-q6voiced = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
hexagonrpc-blueline = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
tqftpserv-blueline = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
blueline-edge-sense = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
souveraine-stevia = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
souveraine-squeekboard = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
souveraine-stt = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
blueline-camera = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
blueline-usb-gadget = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
hyprgrass = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
smoo = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
usb-signaller = { producer = "pixel3arch", architectures = ["aarch64"], state = "managed" }
|
||||
linux-d10 = { producer = "d10", architectures = ["aarch64"], state = "planned", blocker = "TASK-64: kernel source home and CI do not exist" }
|
||||
souveraine-d10-loader = { producer = "d10", architectures = ["x86_64"], state = "planned", blocker = "TASK-64: m1n1 and patched iBSS chain are not packaged" }
|
||||
souveraine-d10-device = { producer = "d10", architectures = ["aarch64"], state = "planned", blocker = "TASK-64: DTB and device quirks are not a package" }
|
||||
|
||||
[profiles.generic-aarch64]
|
||||
status = "blocked"
|
||||
architectures = ["aarch64"]
|
||||
packages = ["souveraine", "upower-souveraine", "souveraine-updater"]
|
||||
blockers = ["generic ARM provisioning has no completed installer"]
|
||||
|
||||
[profiles.blueline]
|
||||
status = "blocked"
|
||||
architectures = ["aarch64"]
|
||||
packages = ["souveraine", "upower-souveraine", "souveraine-updater", "souveraine-viewtop", "linux-blueline", "souveraine-callaudio", "souveraine-callaudiod", "souveraine-ucm-blueline", "souveraine-q6voiced", "hexagonrpc-blueline", "tqftpserv-blueline", "blueline-edge-sense", "souveraine-stevia", "souveraine-squeekboard", "souveraine-stt", "blueline-camera", "blueline-usb-gadget", "hyprgrass", "smoo", "usb-signaller"]
|
||||
blockers = ["linux-blueline is not published to the shared archive", "rootfs overlay content still needs package ownership"]
|
||||
|
||||
[profiles.x86-laptop]
|
||||
status = "blocked"
|
||||
architectures = ["x86_64"]
|
||||
packages = ["souveraine", "upower-souveraine", "souveraine-updater"]
|
||||
blockers = ["laptop installer and hardware profile are not implemented"]
|
||||
|
||||
[profiles.d10]
|
||||
status = "blocked"
|
||||
architectures = ["aarch64"]
|
||||
packages = ["souveraine", "upower-souveraine", "souveraine-updater", "linux-d10", "souveraine-d10-device"]
|
||||
host_tools = ["souveraine-d10-loader"]
|
||||
blockers = ["TASK-64 has no packaged kernel, loader, or device identity yet", "ADP display has no pixel proof"]
|
||||
|
||||
[profiles.m1]
|
||||
status = "planned"
|
||||
architectures = ["aarch64"]
|
||||
packages = ["souveraine", "upower-souveraine", "souveraine-updater"]
|
||||
blockers = ["no M1 device profile or installer exists"]
|
||||
|
||||
[targets.blueline]
|
||||
label = "Google Pixel 3 (blueline)"
|
||||
architecture = "aarch64"
|
||||
profile = "blueline"
|
||||
state = "supported"
|
||||
|
||||
[targets.generic-aarch64]
|
||||
label = "generic ARM64 device"
|
||||
architecture = "aarch64"
|
||||
profile = "generic-aarch64"
|
||||
state = "planned"
|
||||
|
||||
[targets.d10]
|
||||
label = "iPhone 7 (D10AP / T8010)"
|
||||
architecture = "aarch64"
|
||||
profile = "d10"
|
||||
state = "bringup"
|
||||
|
||||
[targets.x86-laptop]
|
||||
label = "generic x86_64 laptop"
|
||||
architecture = "x86_64"
|
||||
profile = "x86-laptop"
|
||||
state = "planned"
|
||||
|
||||
[targets.m1]
|
||||
label = "Apple Silicon laptop"
|
||||
architecture = "aarch64"
|
||||
profile = "m1"
|
||||
state = "planned"
|
||||
|
|
@ -10,6 +10,10 @@ Two subjects live here, and the split is real:
|
|||
|
||||
## The device — read in this order
|
||||
|
||||
**The distribution.** [`../distribution/`](../distribution/) — the canonical
|
||||
body, package, installer-profile, and release graph. Component repositories
|
||||
build the declared pieces; they do not define the OS.
|
||||
|
||||
**Why.** [`SESSION-AUTHORITY-DOCTRINE.md`](SESSION-AUTHORITY-DOCTRINE.md) —
|
||||
§13 first (who owns the device), then §4 (never hold state the protocol owns),
|
||||
§9 (sensors are evidence), §10–11 (one authority).
|
||||
|
|
|
|||
106
tools/validate-distribution.py
Normal file
106
tools/validate-distribution.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate the SouveraineOS distribution contract without network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PACKAGE_STATES = {"managed", "blocked", "planned"}
|
||||
PROFILE_STATES = {"ready", "blocked", "planned"}
|
||||
TARGET_STATES = {"supported", "bringup", "planned"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--ready", metavar="PROFILE", help="require one profile to be installer-ready")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with args.manifest.open("rb") as source:
|
||||
data = tomllib.load(source)
|
||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"invalid manifest: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
errors: list[str] = []
|
||||
def error(message: str) -> None:
|
||||
errors.append(message)
|
||||
|
||||
if data.get("schema") != 1:
|
||||
error("schema must be 1")
|
||||
if data.get("archive", {}).get("publisher_rule") != "additive":
|
||||
error("archive publisher_rule must be additive")
|
||||
|
||||
producers = data.get("producers", {})
|
||||
packages = data.get("packages", {})
|
||||
for name, package in packages.items():
|
||||
if package.get("state") not in PACKAGE_STATES:
|
||||
error(f"package {name}: invalid state {package.get('state')!r}")
|
||||
if package.get("producer") not in producers and package.get("state") != "planned":
|
||||
error(f"package {name}: unknown producer {package.get('producer')!r}")
|
||||
if not package.get("architectures"):
|
||||
error(f"package {name}: no architecture declared")
|
||||
|
||||
profiles = data.get("profiles", {})
|
||||
for profile_name, profile in profiles.items():
|
||||
status = profile.get("status")
|
||||
architectures = set(profile.get("architectures", []))
|
||||
if status not in PROFILE_STATES:
|
||||
error(f"profile {profile_name}: invalid status {status!r}")
|
||||
if not architectures:
|
||||
error(f"profile {profile_name}: no architecture declared")
|
||||
for name in profile.get("packages", []):
|
||||
package = packages.get(name)
|
||||
if package is None:
|
||||
error(f"profile {profile_name}: unknown package {name}")
|
||||
continue
|
||||
if not architectures.intersection(package.get("architectures", [])):
|
||||
error(f"profile {profile_name}: {name} has no matching architecture")
|
||||
if status == "ready" and package.get("state") != "managed":
|
||||
error(f"profile {profile_name}: ready profile includes {name} ({package.get('state')})")
|
||||
for name in profile.get("host_tools", []):
|
||||
package = packages.get(name)
|
||||
if package is None:
|
||||
error(f"profile {profile_name}: unknown host tool {name}")
|
||||
elif status == "ready" and package.get("state") != "managed":
|
||||
error(f"profile {profile_name}: ready profile includes host tool {name} ({package.get('state')})")
|
||||
|
||||
targets = data.get("targets", {})
|
||||
for target_name, target in targets.items():
|
||||
profile = profiles.get(target.get("profile"))
|
||||
if target.get("state") not in TARGET_STATES:
|
||||
error(f"target {target_name}: invalid state {target.get('state')!r}")
|
||||
if profile is None:
|
||||
error(f"target {target_name}: unknown profile {target.get('profile')!r}")
|
||||
continue
|
||||
if target.get("architecture") not in profile.get("architectures", []):
|
||||
error(f"target {target_name}: architecture does not match its profile")
|
||||
if target.get("state") == "supported" and profile.get("status") == "planned":
|
||||
error(f"target {target_name}: supported target cannot use a planned profile")
|
||||
|
||||
if errors:
|
||||
print("distribution manifest is invalid:", file=sys.stderr)
|
||||
print("\n".join(f" - {item}" for item in errors), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
blocked = [name for name, profile in profiles.items() if profile.get("status") != "ready"]
|
||||
print(f"distribution manifest valid: {len(targets)} targets, {len(packages)} packages, {len(profiles)} profiles")
|
||||
if blocked:
|
||||
print("profiles not ready: " + ", ".join(blocked))
|
||||
if args.ready:
|
||||
profile = profiles.get(args.ready)
|
||||
if profile is None:
|
||||
print(f"unknown profile: {args.ready}", file=sys.stderr)
|
||||
return 2
|
||||
if profile.get("status") != "ready":
|
||||
print(f"profile {args.ready} is not ready for a public installer", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue