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 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) —
|
**Why.** [`SESSION-AUTHORITY-DOCTRINE.md`](SESSION-AUTHORITY-DOCTRINE.md) —
|
||||||
§13 first (who owns the device), then §4 (never hold state the protocol owns),
|
§13 first (who owns the device), then §4 (never hold state the protocol owns),
|
||||||
§9 (sensors are evidence), §10–11 (one authority).
|
§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