From 96a2f685a2e6222af4af77492e0345e840770b2a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:53:01 +0800 Subject: [PATCH] 1.0 polish: os-release, snapper pre, lockfile pins, listen, docs Point os-release at the bos repo and issues; drop Arch privacy terms. Take a best-effort snapper pre snapshot before pacman and bakery. Pin current stable bakery versions so CI fetches the same bits per commit. Autostart breadpaper/breadshot listen behind command -v. Document signed-repo setup and Mesa/NVIDIA/grub-btrfs recovery. --- README.md | 41 +++-- docs/hardware.md | 21 +++ docs/signed-repo.md | 85 +++++++++ iso/airootfs/etc/os-release | 6 +- .../etc/skel/.config/hypr/autostart.json | 4 +- .../etc/skel/.config/hypr/hyprland.lua | 15 +- .../.config/hypr/scripts/system/autostart.lua | 4 + iso/airootfs/usr/local/bin/bos-update | 18 ++ iso/bread-lockfile.toml | 27 ++- scripts/ci-stage-bakery.py | 162 +++++++++++++++--- 10 files changed, 336 insertions(+), 47 deletions(-) create mode 100644 docs/hardware.md create mode 100644 docs/signed-repo.md diff --git a/README.md b/README.md index 6ad797b..ccaf576 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,12 @@ wiring up dotfiles, no per-tool bakery installs. - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing with avahi mDNS discovery, TLP power management, fwupd firmware updates. Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is - unsupported out of the box (see Known limitations). + unsupported out of the box (see [docs/hardware.md](docs/hardware.md)). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot, - not `snapper rollback` (GRUB pins `rootflags=subvol=@`). + not `snapper rollback` (GRUB pins `rootflags=subvol=@`). See + [docs/hardware.md](docs/hardware.md). - **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via `sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the @@ -62,8 +63,10 @@ wiring up dotfiles, no per-tool bakery installs. | **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) | | **Not shipped** | `breadcast`, `breadarr` | -The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml). -`build-local.sh` fails if any **required** binary is missing on the builder. +The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml) +(plus optional `[versions]` / `[[pin]]` so CI fetches +`https://dl.breadway.dev///...`). `build-local.sh` fails if any +**required** binary is missing on the builder. ## Repo layout @@ -74,7 +77,7 @@ repos and arrive via bakery. ``` bos/ ├── iso/ # archiso profile -│ ├── bread-lockfile.toml # bakery bins (required + optional) +│ ├── bread-lockfile.toml # bakery bins + optional version pins │ ├── profiledef.sh │ ├── packages.x86_64 # live + installed pacman set │ └── airootfs/ # files overlaid onto the image @@ -91,6 +94,9 @@ bos/ │ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME │ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso │ └── smoke-test.sh +├── docs/ +│ ├── hardware.md # Mesa only, NVIDIA, grub-btrfs recovery +│ └── signed-repo.md # future dl.breadway.dev/arch signing ├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release ├── build-local.sh # native ISO build for this machine ├── README.md @@ -133,8 +139,10 @@ non-zero** if any **required** lockfile binary (or breadhelp content) is missing. Optional bins are skipped with a warning. CI stages the builder from the **minisign-verified** stable bakery index -(`index.json` + `index.json.minisig`); local builds still snapshot the -builder. The lockfile is names only. +(`index.json` + `index.json.minisig`) and prefers lockfile `[versions]` +URLs (`https://dl.breadway.dev///...`) when set, so two bakes +of the same commit fetch the same bits. Local builds still snapshot the +builder. ### Why some packages are in-house @@ -158,7 +166,8 @@ dedicated release-signing key (not reused from anything else): The public half is committed at [`KEYS.asc`](KEYS.asc). That key signs **ISO checksums only** — it does not sign the `[breadway]` pacman repo (Forgejo's Arch registry has no pacman-compatible db signatures; that -section stays `SigLevel = Never`). To verify a download: +section stays `SigLevel = Never` until a signed repo exists — see +[docs/signed-repo.md](docs/signed-repo.md)). To verify a download: ```sh gpg --import KEYS.asc @@ -303,9 +312,13 @@ cheatsheet in-session; first boot shows a short welcome (once). ## Known limitations +See [docs/hardware.md](docs/hardware.md) (GPUs, NVIDIA, recovery) and +[docs/signed-repo.md](docs/signed-repo.md) (`[breadway]` stays unsigned +until `dl.breadway.dev/arch` exists). + - **GPUs**: ships the generic Mesa stack — AMD and Intel work out of the box. - The **NVIDIA proprietary driver is not included**; NVIDIA users must install - `nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install. + NVIDIA is **unsupported** (no proprietary driver, no NVIDIA firmware). See + [docs/hardware.md](docs/hardware.md). - **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use `virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is noticeably laggy. @@ -324,7 +337,10 @@ cheatsheet in-session; first boot shows a short welcome (once). BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id). - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default - btrfs subvolume layout the installer creates. + btrfs subvolume layout the installer creates. Recovery is the GRUB + snapshots submenu, not `snapper rollback` — [docs/hardware.md](docs/hardware.md). +- **`[breadway]` signatures**: `SigLevel = Never` until a signed repo is + stood up at `dl.breadway.dev/arch`. See [docs/signed-repo.md](docs/signed-repo.md). ## Recovery @@ -335,7 +351,8 @@ Snapshots and pick a snapshot to boot, **or** choose one from the **GRUB Do **not** run `snapper rollback` as the default recovery step. BOS GRUB pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not what the installed grub.cfg will boot next. Use the grub-btrfs entry so the -kernel command line matches the snapshot you want. +kernel command line matches the snapshot you want. Details: +[docs/hardware.md](docs/hardware.md). A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is not shipped. diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..4bc2d40 --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,21 @@ +# Hardware and recovery + +## GPUs + +BOS ships the generic **Mesa** stack. AMD and Intel work out of the box. + +**NVIDIA is unsupported.** The proprietary driver is not included, NVIDIA +firmware is not on the image, and there is no Hyprland NVIDIA env wiring. +Installing `nvidia` / `nvidia-utils` after the fact is not a product path. + +## Recovery + +An update that breaks the system is recovered from the **GRUB "snapshots" +submenu** (grub-btrfs), not `snapper rollback`. + +BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default +subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs +entry so the kernel command line matches the snapshot you want. + +A/B root swapping is not implemented. See the README Recovery section for +the "system will not boot" GRUB/EFI repair path. diff --git a/docs/signed-repo.md b/docs/signed-repo.md new file mode 100644 index 0000000..112e254 --- /dev/null +++ b/docs/signed-repo.md @@ -0,0 +1,85 @@ +# Signed `[breadway]` repo + +Today the ISO's `[Breadway.os.git.breadway.dev]` section is +`SigLevel = Never`. That is TLS-only integrity: packages come from Forgejo's +Arch registry, which does **not** serve pacman-compatible database +signatures. `KEYS.asc` signs **ISO `SHA256SUMS` only** — it is not imported +as a pacman repo key. Do not flip `SigLevel` to `Required` on that section +until a signed repo exists and has been verified; Required without +signatures breaks the ISO and every installed system. + +The signed repo belongs at `https://dl.breadway.dev/arch`, not on Forgejo's +registry. + +## Stand up `dl.breadway.dev/arch` + +Use the same release-signing key already in CI: + +- Public half: [`KEYS.asc`](../KEYS.asc) + (`5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2`, + `releases@breadway.dev`) +- Private half: the `GPG_PRIVATE_KEY` Forgejo secret (armoured secret key, + no passphrase). Same secret `release-iso.yml` uses to sign `SHA256SUMS`. + +Layout (example for `x86_64`): + +``` +https://dl.breadway.dev/arch/x86_64/ + breadlock--1-x86_64.pkg.tar.zst + breadlock--1-x86_64.pkg.tar.zst.sig + breadway.db + breadway.db.sig + breadway.files + breadway.files.sig +``` + +Build the database **and sign it** with `repo-add -s`: + +```sh +export GNUPGHOME=/tmp/gnupg-breadway-repo +mkdir -m 700 -p "$GNUPGHOME" +printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import + +cd /srv/dl.breadway.dev/arch/x86_64 +repo-add -s -k releases@breadway.dev breadway.db.tar.gz *.pkg.tar.zst +``` + +`repo-add -s` writes `breadway.db.tar.gz.sig` (and the `.files` pair). +Pacman fetches `
.db` + `
.db.sig` from `Server`. + +Package signatures are separate from the database signature. Detach-sign +each `.pkg.tar.zst` as a **binary** sidecar (pacman wants `.sig`, not +armoured `.asc`): + +```sh +gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign breadlock--1-x86_64.pkg.tar.zst +# → breadlock--1-x86_64.pkg.tar.zst.sig +``` + +## breadlock `package.yml` sidecar + +[`breadlock` `package.yml`](https://git.breadway.dev/Breadway/breadlock/src/branch/main/.forgejo/workflows/package.yml) +already `makepkg`s and PUTs the archive at Forgejo's registry. When the +signed repo exists, that job can also emit the sidecar and publish both +files to `dl.breadway.dev/arch`: + +```sh +PKG=$(find packaging/arch -name '*.pkg.tar.zst' | head -1) +printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import +gpg --batch --yes --local-user releases@breadway.dev --detach-sign "$PKG" +# upload "$PKG" and "${PKG}.sig" to dl.breadway.dev/arch/x86_64/ +# then repo-add -s as above +``` + +Keep publishing to Forgejo until installs have been switched. The ISO +section stays `SigLevel = Never` until the signed tree is live. + +## After the signed repo exists + +1. Import `KEYS.asc` into the ISO keyring (`pacman-key --add` + `--lsign-key`). +2. Point `[breadway]` `Server` at `https://dl.breadway.dev/arch/$arch`. +3. Only then flip that section to `SigLevel = Required`. + +Do not do those three steps against Forgejo's registry. See +`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. diff --git a/iso/airootfs/etc/os-release b/iso/airootfs/etc/os-release index 4f28e07..f6b380c 100644 --- a/iso/airootfs/etc/os-release +++ b/iso/airootfs/etc/os-release @@ -5,7 +5,7 @@ ID_LIKE=arch BUILD_ID=rolling ANSI_COLOR="38;2;23;147;209" HOME_URL="https://breadway.dev" -DOCUMENTATION_URL="https://wiki.archlinux.org/" -SUPPORT_URL="https://bbs.archlinux.org/" +DOCUMENTATION_URL="https://git.breadway.dev/Breadway/bos" +SUPPORT_URL="https://git.breadway.dev/Breadway/bos/issues" BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" -PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" +PRIVACY_POLICY_URL="https://breadway.dev" diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json index e18222b..a8032d3 100644 --- a/iso/airootfs/etc/skel/.config/hypr/autostart.json +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -3,6 +3,8 @@ { "command": "breadbar", "label": "Bar (breadbar)", "enabled": true }, { "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true }, { "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true }, - { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true } + { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, + { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, + { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true } ] } diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 5262654..ac760f4 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -106,8 +106,10 @@ hl.gesture({ -- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's -- timing/order-sensitive infrastructure, not something a settings UI should -- expose for a user to disable or reorder. The extra, genuinely toggleable --- apps (breadbar, hypridle, bos-netcheck, breadhelp) come from --- autostart.json via scripts/system/autostart.lua, appended after. +-- apps (breadbar, hypridle, bos-netcheck, breadhelp, breadpaper/breadshot +-- listen) come from autostart.json via scripts/system/autostart.lua, +-- appended after. listen is wrapped with `command -v` so a missing +-- binary does not brick login (Hyprland exec is already fire-and-forget). -- (bos-live-setup appends the live-installer launch below this on the ISO.) -- --------------------------------------------------------------------------- hl.on("hyprland.start", function() @@ -168,7 +170,14 @@ hl.on("hyprland.start", function() -- autostart.json/its loader broke — fall back to the same apps BOS -- has always started, so a bad JSON edit degrades to "normal -- desktop" rather than "no bar, no idle lock, no onboarding". - extra = { "breadbar", "hypridle", "bos-netcheck", "breadhelp --autostart" } + extra = { + "breadbar", + "hypridle", + "bos-netcheck", + "breadhelp --autostart", + "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", + "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", + } end for _, cmd in ipairs(extra) do hl.dispatch(hl.dsp.exec_cmd(cmd)) diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua index 7fb119d..f51bdda 100644 --- a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -12,11 +12,15 @@ -- because the valid result happens to be empty. local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") +-- breadpaper/breadshot `listen` is wrapped so a missing binary (stable +-- does not ship the command-bus verb yet) cannot take down the session. local DEFAULT_EXTRA = { { command = "breadbar", enabled = true }, { command = "hypridle", enabled = true }, { command = "bos-netcheck", enabled = true }, { command = "breadhelp --autostart", enabled = true }, + { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, + { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, } return function() diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index aeb6fbd..65f27c0 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -19,6 +19,24 @@ set -uo pipefail bold() { printf '\033[1m%s\033[0m\n' "$1"; } +# Timed snapper pre snapshot before either channel. snap-pac already +# snapshots root around pacman; bakery writes ~/.local/bin ($HOME / @home), +# which is outside that root snapshot. This extra snapshot is still +# best-effort and covers bakery $HOME updates as well as possible — a +# home config if the installer created one, otherwise the root timeline +# around the whole update. Never fail the update if snapper is missing +# or the create errors. +if command -v snapper >/dev/null; then + if snapper -c home list >/dev/null 2>&1; then + snapper -c home create -t pre -c number \ + -d "bos-update (pre bakery)" \ + || echo "WARN: snapper home pre snapshot failed" + fi + snapper -c root create -t pre -c number \ + -d "bos-update (pre bakery)" \ + || echo "WARN: snapper pre snapshot failed" +fi + bold "==> System packages (pacman -Syu)" if command -v pacman >/dev/null; then sudo pacman -Syu || echo "WARN: pacman update failed" diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index d704c33..0b84ee7 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -9,8 +9,12 @@ # A flat `bins` list is still accepted and treated as required_bins. # # CI populates the builder from the minisign-verified stable bakery index -# (https://dl.breadway.dev/index.json). Local builds still snapshot whatever -# is installed on the builder; this file only names what must / may be present. +# (https://dl.breadway.dev/index.json). Optional [versions] (or [[pin]] +# tables with package + version) pin bakery package versions so two ISO +# bakes of the same git commit fetch the same bits: +# https://dl.breadway.dev///... +# Bump pins after new bakery stables land. Local builds still snapshot +# whatever is installed on the builder. # # Not shipped (even if they appear in the index): breadcast, breadarr. # breadlock is pacman (see packages.x86_64), not bakery. @@ -42,3 +46,22 @@ optional_bins = [ "bread-emit", "bread-module-host", ] + +# Package name → version, matching today's stable index. CI prefers the +# pinned version URL when a key is set. [[pin]] { package, version } is +# accepted as well and merged (conflict = bake error). +[versions] +bakery = "0.7.1" +bread = "0.7.0" +bread-theme = "0.7.1" +breadbar = "0.3.0" +breadbox = "0.3.0" +breadcrumbs = "2.1.6" +breadpad = "0.5.0" +breadpaper = "0.1.11" +breadmon = "0.1.2" +breadsearch = "0.3.0" +breadclip = "0.1.1" +breadshot = "0.1.1" +bos-settings = "0.7.1" +breadhelp = "0.2.3" diff --git a/scripts/ci-stage-bakery.py b/scripts/ci-stage-bakery.py index 3545f6f..20dbfb1 100755 --- a/scripts/ci-stage-bakery.py +++ b/scripts/ci-stage-bakery.py @@ -24,6 +24,7 @@ from pathlib import Path from urllib.parse import urljoin, urlparse INDEX_URL = "https://dl.breadway.dev/index.json" +DL_ORIGIN = "https://dl.breadway.dev" # Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs. MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8" BLOCKED = frozenset({"breadcast", "breadarr"}) @@ -46,7 +47,41 @@ 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]]: +def load_versions(data: dict, path: Path) -> dict[str, str]: + """Optional [versions] map and/or [[pin]] tables → package → version.""" + versions: dict[str, str] = {} + + raw_map = data.get("versions") + if raw_map is not None: + if not isinstance(raw_map, dict): + die(f"{path}: [versions] must be a table of package = \"version\"") + for pkg, ver in raw_map.items(): + if not isinstance(pkg, str) or not valid_name(pkg): + die(f"{path}: invalid [versions] package {pkg!r}") + if not isinstance(ver, str) or not valid_name(ver): + die(f"{path}: invalid [versions] version for {pkg}: {ver!r}") + versions[pkg] = ver + + pins = data.get("pin") + if pins is not None: + if not isinstance(pins, list): + die(f"{path}: [[pin]] must be an array of tables") + for i, entry in enumerate(pins): + if not isinstance(entry, dict): + die(f"{path}: [[pin]] #{i} must be a table") + pkg = entry.get("package", entry.get("pkg")) + ver = entry.get("version") + if not isinstance(pkg, str) or not valid_name(pkg): + die(f"{path}: [[pin]] #{i}: missing valid package") + if not isinstance(ver, str) or not valid_name(ver): + die(f"{path}: [[pin]] #{i}: missing valid version") + if pkg in versions and versions[pkg] != ver: + die(f"{path}: conflicting pin for {pkg}: {versions[pkg]} vs {ver}") + versions[pkg] = ver + return versions + + +def load_lockfile(path: Path) -> tuple[list[str], list[str], dict[str, str]]: with path.open("rb") as f: data = tomllib.load(f) required = data.get("required_bins") @@ -66,15 +101,36 @@ def load_lockfile(path: Path) -> tuple[list[str], list[str]]: overlap = set(required) & set(optional) if overlap: die(f"{path}: bins in both required and optional: {sorted(overlap)}") - return list(required), list(optional) + return list(required), list(optional), load_versions(data, path) -def fetch(url: str, dest: Path) -> None: +def pinned_artifact_url(pkg: str, version: str, filename: str) -> str: + if not valid_name(pkg) or not valid_name(version) or not valid_name(filename): + die(f"refusing pinned URL with unsafe path {pkg}/{version}/{filename}") + return f"{DL_ORIGIN}/{pkg}/{version}/{filename}" + + +def package_base_url(pkg_name: str, versions: dict[str, str], first_url: str) -> str: + pin = versions.get(pkg_name) + if pin: + if not valid_name(pkg_name) or not valid_name(pin): + die(f"refusing pinned version dir {pkg_name}/{pin}") + return f"{DL_ORIGIN}/{pkg_name}/{pin}/" + return version_dir(first_url) + + +def fetch(url: str, dest: Path, *, required: bool = True) -> bool: dest.parent.mkdir(parents=True, exist_ok=True) try: urllib.request.urlretrieve(url, dest) + return True except (urllib.error.URLError, OSError) as e: - die(f"download failed: {url}: {e}") + if required: + die(f"download failed: {url}: {e}") + print(f"WARN: download failed: {url}: {e}", file=sys.stderr) + if dest.exists(): + dest.unlink() + return False def sha256_file(path: Path) -> str: @@ -183,11 +239,22 @@ def assert_safe_archive(path: Path) -> None: 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) +def stage_file( + url: str, + dest: Path, + sha256: str | None, + what: str, + mode: int | None = None, + *, + required: bool = True, +) -> bool: + if not fetch(url, dest, required=required): + return False + if sha256 is not None: + verify_sha256(dest, sha256, what) if mode is not None: dest.chmod(mode) + return True def main() -> int: @@ -216,8 +283,11 @@ def main() -> int: 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") + required, optional, versions = load_lockfile(lockfile) + print( + f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional" + + (f", {len(versions)} pinned" if versions else "") + ) bin_dir = home / ".local" / "bin" state_dir = home / ".local" / "state" / "bakery" @@ -245,6 +315,24 @@ def main() -> int: selected: dict[str, dict] = {} installed_bins: dict[str, list[str]] = {} installed_sha: dict[str, dict[str, str]] = {} + fetched_url: dict[str, str] = {} + + pin_warned: set[str] = set() + + def pin_digest(pkg_name: str, pkg: dict, value: object, what: str) -> str | None: + """Index sha256 is only valid when it describes the pinned version.""" + digest = require_sha256(value, what) + pin = versions.get(pkg_name) + if pin and str(pkg.get("version")) != pin: + if pkg_name not in pin_warned: + print( + f"WARN: {pkg_name} pin {pin} != index {pkg.get('version')}; " + f"fetching pinned URL without index sha256", + file=sys.stderr, + ) + pin_warned.add(pkg_name) + return None + return digest def take_bin(name: str, *, required_bin: bool) -> bool: hit = published.get(name) @@ -256,16 +344,30 @@ def main() -> int: 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") + raw = binary.get("name") + index_url = binary.get("dl_url") + pin = versions.get(pkg_name) + if pin: + if not isinstance(raw, str) or not valid_name(raw): + die(f"{name}: missing binary filename for pinned URL") + url = pinned_artifact_url(pkg_name, pin, raw) + else: + url = index_url if not isinstance(url, str) or not url: die(f"{name}: missing dl_url") - digest = require_sha256(binary.get("sha256"), f"binary {name}") + digest = pin_digest(pkg_name, pkg, binary.get("sha256"), f"binary {name}") dest = bin_dir / name - print(f" {name} <- {url}") - stage_file(url, dest, digest, f"binary {name}", mode=0o755) + note = f" (pin {pkg_name}={pin})" if pin else "" + print(f" {name} <- {url}{note}") + if not stage_file( + url, dest, digest, f"binary {name}", mode=0o755, required=required_bin + ): + return False selected[pkg_name] = pkg installed_bins.setdefault(pkg_name, []).append(name) - installed_sha.setdefault(pkg_name, {})[name] = digest + if digest is not None: + installed_sha.setdefault(pkg_name, {})[name] = digest + fetched_url.setdefault(pkg_name, url) return True for name in required: @@ -281,14 +383,15 @@ def main() -> int: 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 + first_url = fetched_url.get(pkg_name) + if not first_url: + 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) + base = package_base_url(pkg_name, versions, first_url) service_names: list[str] = [] for svc in pkg.get("services") or []: @@ -297,12 +400,13 @@ def main() -> int: 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}") + digest = pin_digest(pkg_name, pkg, 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}") + if digest is not None: + 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"): @@ -322,7 +426,9 @@ def main() -> int: 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}") + digest = pin_digest( + pkg_name, pkg, 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) @@ -349,7 +455,9 @@ def main() -> int: 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}") + digest = pin_digest( + pkg_name, pkg, 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}") @@ -357,13 +465,15 @@ def main() -> int: 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}") + digest = pin_digest( + pkg_name, pkg, 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"), + "version": versions.get(pkg_name, pkg.get("version")), "binaries": installed_bins.get(pkg_name, []), "services": service_names, "installed_at": now,