CI: stage bakery from signed stable index, drop bread-theme cargo build
The tagged ISO workflow fetched bos-settings/src/Cargo.toml from the dev branch (404 after the Tauri split) and cargo-built bread-theme. bread-theme 0.7.1 is already on the stable index. Stage required bins, units, breadhelp content, and desktop/license files from the minisign-verified index instead; optional bread-emit/module-host skip until bread publishes them. Fail the bake if a required bin is missing.
This commit is contained in:
parent
3ab97c1634
commit
a3ead6607a
16 changed files with 687 additions and 154 deletions
387
scripts/ci-stage-bakery.py
Executable file
387
scripts/ci-stage-bakery.py
Executable file
|
|
@ -0,0 +1,387 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Stage bakery artifacts from the verified stable index into $LAPTOP_HOME.
|
||||
|
||||
Used by .forgejo/workflows/release-iso.yml so the ISO bake does not invent
|
||||
binaries, fake installed.json, or cargo-build bread-theme. Never downloads
|
||||
breadcast or breadarr.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
INDEX_URL = "https://dl.breadway.dev/index.json"
|
||||
# Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs.
|
||||
MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
|
||||
BLOCKED = frozenset({"breadcast", "breadarr"})
|
||||
ARCH_SUFFIXES = ("-x86_64", "-aarch64", "-arm64", "-armv7")
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def dest_name(name: str) -> str:
|
||||
for suf in ARCH_SUFFIXES:
|
||||
if name.endswith(suf):
|
||||
return name[: -len(suf)]
|
||||
return name
|
||||
|
||||
|
||||
def valid_name(name: str) -> bool:
|
||||
return bool(name) and "/" not in name and name not in (".", "..")
|
||||
|
||||
|
||||
def load_lockfile(path: Path) -> tuple[list[str], list[str]]:
|
||||
with path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
required = data.get("required_bins")
|
||||
optional = data.get("optional_bins") or []
|
||||
if required is None:
|
||||
required = data.get("bins") or data.get("binaries")
|
||||
if not isinstance(required, list) or not required:
|
||||
die(f"{path}: missing non-empty required_bins (or bins) list")
|
||||
if not isinstance(optional, list):
|
||||
die(f"{path}: optional_bins must be a list")
|
||||
for label, names in (("required_bins", required), ("optional_bins", optional)):
|
||||
for b in names:
|
||||
if not isinstance(b, str) or not valid_name(b):
|
||||
die(f"{path}: invalid {label} name {b!r}")
|
||||
if b in BLOCKED:
|
||||
die(f"{path}: {b} is not shipped on the ISO")
|
||||
overlap = set(required) & set(optional)
|
||||
if overlap:
|
||||
die(f"{path}: bins in both required and optional: {sorted(overlap)}")
|
||||
return list(required), list(optional)
|
||||
|
||||
|
||||
def fetch(url: str, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
die(f"download failed: {url}: {e}")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def require_sha256(value: object, what: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
die(f"{what}: index sha256 is required and must be non-empty")
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def verify_sha256(path: Path, expected: str, what: str) -> None:
|
||||
actual = sha256_file(path)
|
||||
if actual != expected:
|
||||
die(f"{what}: sha256 mismatch (expected {expected}, got {actual})")
|
||||
|
||||
|
||||
def version_dir(first_dl_url: str) -> str:
|
||||
parsed = urlparse(first_dl_url)
|
||||
parent = parsed.path.rsplit("/", 1)[0]
|
||||
return f"{parsed.scheme}://{parsed.netloc}{parent}/"
|
||||
|
||||
|
||||
def verify_index(index_path: Path, sig_path: Path) -> None:
|
||||
if shutil.which("minisign") is None:
|
||||
die("minisign is not installed — refuse to trust an unsigned index")
|
||||
cmd = [
|
||||
"minisign",
|
||||
"-V",
|
||||
"-q",
|
||||
"-m",
|
||||
str(index_path),
|
||||
"-x",
|
||||
str(sig_path),
|
||||
"-P",
|
||||
MINISIGN_PUBKEY,
|
||||
]
|
||||
result = subprocess.run(cmd, check=False)
|
||||
if result.returncode != 0:
|
||||
die("index.json minisign verification FAILED — refusing to proceed")
|
||||
print("index.json minisign OK")
|
||||
|
||||
|
||||
def bin_index(packages: dict) -> dict[str, tuple[str, dict, dict]]:
|
||||
out: dict[str, tuple[str, dict, dict]] = {}
|
||||
for pkg_name, pkg in packages.items():
|
||||
if pkg_name in BLOCKED:
|
||||
continue
|
||||
for b in pkg.get("binaries") or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
raw = b.get("name")
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
dest = dest_name(raw)
|
||||
if dest in BLOCKED or pkg_name in BLOCKED:
|
||||
continue
|
||||
if dest in out and out[dest][0] != pkg_name:
|
||||
die(f"index publishes {dest} from both {out[dest][0]} and {pkg_name}")
|
||||
out[dest] = (pkg_name, pkg, b)
|
||||
return out
|
||||
|
||||
|
||||
def patch_exec_start(text: str, bin_dir: Path) -> str:
|
||||
lines = []
|
||||
for line in text.splitlines():
|
||||
if line.lstrip().startswith("ExecStart="):
|
||||
rest = line.split("=", 1)[1]
|
||||
argv = rest.split()
|
||||
if argv:
|
||||
name = os.path.basename(argv[0])
|
||||
new_path = bin_dir / name
|
||||
if len(argv) == 1:
|
||||
line = f"ExecStart={new_path}"
|
||||
else:
|
||||
line = f"ExecStart={new_path} {' '.join(argv[1:])}"
|
||||
lines.append(line)
|
||||
out = "\n".join(lines)
|
||||
if text.endswith("\n"):
|
||||
out += "\n"
|
||||
return out
|
||||
|
||||
|
||||
def wanted_by(text: str) -> list[str]:
|
||||
targets: list[str] = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("WantedBy="):
|
||||
targets.extend(line.split("=", 1)[1].split())
|
||||
return targets or ["default.target"]
|
||||
|
||||
|
||||
def assert_safe_archive(path: Path) -> None:
|
||||
with tarfile.open(path, "r:gz") as tf:
|
||||
for info in tf.getmembers():
|
||||
name = info.name
|
||||
if info.issym() or info.islnk():
|
||||
die(f"refusing archive with symlink entry {name!r}")
|
||||
if name.startswith("/") or any(p in ("..", "") for p in Path(name).parts if p == ".."):
|
||||
die(f"refusing archive with unsafe path {name!r}")
|
||||
if Path(name).is_absolute() or ".." in Path(name).parts:
|
||||
die(f"refusing archive with unsafe path {name!r}")
|
||||
|
||||
|
||||
def stage_file(url: str, dest: Path, sha256: str, what: str, mode: int | None = None) -> None:
|
||||
fetch(url, dest)
|
||||
verify_sha256(dest, sha256, what)
|
||||
if mode is not None:
|
||||
dest.chmod(mode)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# CI logs mix stdout/stderr; keep them in source order.
|
||||
try:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--home",
|
||||
default=os.environ.get("LAPTOP_HOME", "/build-home"),
|
||||
help="builder home to populate (default: $LAPTOP_HOME or /build-home)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lockfile",
|
||||
default=str(repo / "iso" / "bread-lockfile.toml"),
|
||||
)
|
||||
parser.add_argument("--index-url", default=INDEX_URL)
|
||||
args = parser.parse_args()
|
||||
|
||||
home = Path(args.home)
|
||||
lockfile = Path(args.lockfile)
|
||||
if not lockfile.is_file():
|
||||
die(f"lockfile missing: {lockfile}")
|
||||
|
||||
required, optional = load_lockfile(lockfile)
|
||||
print(f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional")
|
||||
|
||||
bin_dir = home / ".local" / "bin"
|
||||
state_dir = home / ".local" / "state" / "bakery"
|
||||
cache_dir = home / ".cache" / "bakery"
|
||||
share_dir = home / ".local" / "share"
|
||||
unit_dir = home / ".config" / "systemd" / "user"
|
||||
for d in (bin_dir, state_dir, cache_dir, share_dir, unit_dir):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
index_path = cache_dir / "index.json"
|
||||
sig_path = cache_dir / "index.json.minisig"
|
||||
print(f"fetch {args.index_url}")
|
||||
fetch(args.index_url, index_path)
|
||||
print(f"fetch {args.index_url}.minisig")
|
||||
fetch(args.index_url + ".minisig", sig_path)
|
||||
verify_index(index_path, sig_path)
|
||||
|
||||
with index_path.open() as f:
|
||||
idx = json.load(f)
|
||||
packages = idx.get("packages")
|
||||
if not isinstance(packages, dict):
|
||||
die("index.json: missing packages object")
|
||||
|
||||
published = bin_index(packages)
|
||||
selected: dict[str, dict] = {}
|
||||
installed_bins: dict[str, list[str]] = {}
|
||||
installed_sha: dict[str, dict[str, str]] = {}
|
||||
|
||||
def take_bin(name: str, *, required_bin: bool) -> bool:
|
||||
hit = published.get(name)
|
||||
if hit is None:
|
||||
if required_bin:
|
||||
die(f"required bin {name!r} is not in the verified stable index")
|
||||
print(f"WARN: optional bin {name} not in index — skipping", file=sys.stderr)
|
||||
return False
|
||||
pkg_name, pkg, binary = hit
|
||||
if pkg_name in BLOCKED or name in BLOCKED:
|
||||
die(f"refusing blocked package/bin {pkg_name}/{name}")
|
||||
url = binary.get("dl_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
die(f"{name}: missing dl_url")
|
||||
digest = require_sha256(binary.get("sha256"), f"binary {name}")
|
||||
dest = bin_dir / name
|
||||
print(f" {name} <- {url}")
|
||||
stage_file(url, dest, digest, f"binary {name}", mode=0o755)
|
||||
selected[pkg_name] = pkg
|
||||
installed_bins.setdefault(pkg_name, []).append(name)
|
||||
installed_sha.setdefault(pkg_name, {})[name] = digest
|
||||
return True
|
||||
|
||||
for name in required:
|
||||
take_bin(name, required_bin=True)
|
||||
for name in optional:
|
||||
take_bin(name, required_bin=False)
|
||||
|
||||
if not selected:
|
||||
die("no packages selected from lockfile ∩ index")
|
||||
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
installed: dict[str, dict] = {}
|
||||
|
||||
for pkg_name, pkg in sorted(selected.items()):
|
||||
bins = pkg.get("binaries") or []
|
||||
first_url = None
|
||||
for b in bins:
|
||||
if isinstance(b, dict) and b.get("dl_url"):
|
||||
first_url = b["dl_url"]
|
||||
break
|
||||
if not first_url:
|
||||
die(f"{pkg_name}: no binary dl_url to derive version dir")
|
||||
base = version_dir(first_url)
|
||||
service_names: list[str] = []
|
||||
|
||||
for svc in pkg.get("services") or []:
|
||||
if not isinstance(svc, dict):
|
||||
die(f"{pkg_name}: service entry must be an object with unit + sha256")
|
||||
unit = svc.get("unit")
|
||||
if not isinstance(unit, str) or not valid_name(unit):
|
||||
die(f"{pkg_name}: invalid service unit {unit!r}")
|
||||
digest = require_sha256(svc.get("sha256"), f"{pkg_name} {unit}")
|
||||
dest = unit_dir / unit
|
||||
url = urljoin(base, unit)
|
||||
print(f" {unit} <- {url}")
|
||||
fetch(url, dest)
|
||||
verify_sha256(dest, digest, f"unit {unit}")
|
||||
dest.write_text(patch_exec_start(dest.read_text(), bin_dir))
|
||||
dest.chmod(0o644)
|
||||
if svc.get("enable"):
|
||||
for target in wanted_by(dest.read_text()):
|
||||
if not valid_name(target):
|
||||
die(f"{unit}: invalid WantedBy {target!r}")
|
||||
wants = unit_dir / f"{target}.wants"
|
||||
wants.mkdir(parents=True, exist_ok=True)
|
||||
link = wants / unit
|
||||
if link.exists() or link.is_symlink():
|
||||
link.unlink()
|
||||
link.symlink_to(Path("..") / unit)
|
||||
print(f" enabled {target}.wants/{unit}")
|
||||
service_names.append(unit)
|
||||
|
||||
archive = pkg.get("data_archive")
|
||||
if archive:
|
||||
if not isinstance(archive, str) or not valid_name(archive):
|
||||
die(f"{pkg_name}: invalid data_archive {archive!r}")
|
||||
digest = require_sha256(pkg.get("data_archive_sha256"), f"{pkg_name} {archive}")
|
||||
url = urljoin(base, archive)
|
||||
data_dir = share_dir / pkg_name
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=f"bos-{pkg_name}-") as tmp:
|
||||
tmp_path = Path(tmp) / archive
|
||||
print(f" {archive} <- {url}")
|
||||
stage_file(url, tmp_path, digest, f"{pkg_name} {archive}")
|
||||
assert_safe_archive(tmp_path)
|
||||
subprocess.run(
|
||||
[
|
||||
"tar",
|
||||
"xzf",
|
||||
str(tmp_path),
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
"-C",
|
||||
str(data_dir),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
print(f" extracted to {data_dir}")
|
||||
|
||||
desktop = pkg.get("desktop_file")
|
||||
if desktop:
|
||||
if not isinstance(desktop, str) or not valid_name(desktop):
|
||||
die(f"{pkg_name}: invalid desktop_file {desktop!r}")
|
||||
digest = require_sha256(pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}")
|
||||
dest = share_dir / "applications" / f"{pkg_name}.desktop"
|
||||
stage_file(urljoin(base, desktop), dest, digest, f"{pkg_name} {desktop}")
|
||||
|
||||
license_file = pkg.get("license_file")
|
||||
if license_file:
|
||||
if not isinstance(license_file, str) or not valid_name(license_file):
|
||||
die(f"{pkg_name}: invalid license_file {license_file!r}")
|
||||
digest = require_sha256(pkg.get("license_file_sha256"), f"{pkg_name} {license_file}")
|
||||
dest = share_dir / "licenses" / pkg_name / "LICENSE"
|
||||
stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}")
|
||||
|
||||
installed[pkg_name] = {
|
||||
"name": pkg_name,
|
||||
"version": pkg.get("version"),
|
||||
"binaries": installed_bins.get(pkg_name, []),
|
||||
"services": service_names,
|
||||
"installed_at": now,
|
||||
"track": "stable",
|
||||
"binary_sha256": installed_sha.get(pkg_name, {}),
|
||||
}
|
||||
|
||||
if "breadhelp" in installed:
|
||||
content = share_dir / "breadhelp" / "content"
|
||||
if not content.is_dir():
|
||||
die(f"breadhelp data_archive did not produce {content}")
|
||||
|
||||
state_path = state_dir / "installed.json"
|
||||
state_path.write_text(json.dumps({"track": "stable", "packages": installed}, indent=2) + "\n")
|
||||
print(f"installed.json written ({len(installed)} packages): {', '.join(sorted(installed))}")
|
||||
print(f"staged bins: {', '.join(sorted(p.name for p in bin_dir.iterdir() if p.is_file()))}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue