Watch
1
0
Fork
You've already forked SouveraineOS
0

distribution: establish the OS release contract

This commit is contained in:
Fimeg 2026-08-10 16:53:18 -04:00
commit d1312cbb06
5 changed files with 291 additions and 0 deletions

View 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
View 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
View 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"

View file

@ -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), §1011 (one authority).

View 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())