diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index 241197d..7191c22 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -1,19 +1,21 @@ name: Build and release ISO -# Builds the BOS ISO on the hestia self-hosted runner (native Arch container), -# downloads all bakery ecosystem binaries from their GitHub releases, compiles -# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release. -# A matching GitHub release is created that points to Forgejo for the download +# Builds the BOS ISO on the hestia self-hosted runner (native Arch container). +# Stages bakery desktop apps from the *minisign-verified* stable index at +# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs +# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub +# release is created best-effort and points at Forgejo for the download # (GitHub releases cannot host files larger than 2 GB). # # Required secrets: # RELEASE_TOKEN — Forgejo API token with write:repository scope -# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# MIRROR_TOKEN — GitHub personal access token with repo scope # GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" # identity (releases@breadway.dev); public half is committed -# at KEYS.asc for verification. No passphrase (CI-only key, -# access controlled via the Forgejo secret store, not a -# passphrase nobody could type non-interactively anyway). +# at KEYS.asc for verifying ISO SHA256SUMS only. That key +# does not sign the [breadway] pacman repo. No passphrase +# (CI-only key, access controlled via the Forgejo secret +# store). on: push: @@ -28,6 +30,8 @@ jobs: release-iso: runs-on: [self-hosted, hestia] container: + # Floating tag: this environment cannot pin a reproducible digest of + # archlinux:latest. Do not invent one. image: archlinux:latest # --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod # --network=host: gives localhost:3002 access to Forgejo (avoids the @@ -37,7 +41,7 @@ jobs: steps: - name: Install build dependencies run: | - pacman -Syu --noconfirm archiso curl python git rust + pacman -Syu --noconfirm archiso curl python git minisign - name: Determine tag and version id: vars @@ -55,85 +59,17 @@ jobs: git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos - - name: Download bakery ecosystem binaries + - name: Stage bakery ecosystem from signed stable index run: | set -euo pipefail - mkdir -p /build-home/.local/bin \ - /build-home/.local/state/bakery \ - /build-home/.cache/bakery + cd /bos + LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py - # Fetch the canonical bakery index - curl -fsSL "https://dl.breadway.dev/index.json" \ - -o /build-home/.cache/bakery/index.json - - # Download each binary from dl.breadway.dev (canonical source; github_url - # is not always published for dev/patch releases) and generate the - # installed.json that bakery expects in ~/.local/state. - python3 << 'PYEOF' - import json, urllib.request, os - - with open('/build-home/.cache/bakery/index.json') as f: - idx = json.load(f) - - BIN_DIR = '/build-home/.local/bin' - installed = {} - - for pkg_name, pkg in idx['packages'].items(): - bins = [] - for b in pkg['binaries']: - dest_name = b['name'].removesuffix('-x86_64') - dest = os.path.join(BIN_DIR, dest_name) - url = b['dl_url'] - print(f' {dest_name} <- {url}', flush=True) - urllib.request.urlretrieve(url, dest) - os.chmod(dest, 0o755) - bins.append(dest_name) - - # installed.json services field is a flat list of unit-name strings - services = [ - (s['unit'] if isinstance(s, dict) else s) - for s in pkg.get('services', []) - ] - installed[pkg_name] = { - 'name': pkg_name, - 'version': pkg['version'], - 'binaries': bins, - 'services': services, - 'installed_at': '2024-01-01T00:00:00+00:00', - } - - with open('/build-home/.local/state/bakery/installed.json', 'w') as f: - json.dump({'packages': installed}, f, indent=2) - print('installed.json written', flush=True) - PYEOF - - - name: Build bread-theme from source + - name: Verify staged bakery bake inputs run: | set -euo pipefail - # bread-theme is not in the bakery index; build it at the ref pinned - # in bos-settings' Cargo.toml so the CLI matches the library version - # the bos-settings package (and breadbar/breadbox/breadpad) were - # built against. bos-settings used to live at bos-settings/Cargo.toml - # inside this repo; it's since been split into its own repo - # (git.breadway.dev/Breadway/bos-settings) and migrated to Tauri, - # which moved the Rust manifest to bos-settings/src/Cargo.toml, so - # fetch it from there. Uses bos-settings' default branch (dev) — the - # branch its own CI actually publishes the `bos-settings` pacman - # package from. - REPO_OWNER="${GITHUB_REPOSITORY%%/*}" - curl -fsSL "https://git.breadway.dev/${REPO_OWNER}/bos-settings/raw/branch/dev/src/Cargo.toml" \ - -o /tmp/bos-settings-Cargo.toml - # bread-theme is pinned by tag once a release ships the functions - # bos-settings needs, or by branch in the meantime — handle either. - THEME_REF=$(grep '^bread-theme' /tmp/bos-settings-Cargo.toml \ - | grep -oP '(tag|branch)\s*=\s*"\K[^"]+') - echo "Building bread-theme @ $THEME_REF" - git clone --branch "$THEME_REF" --depth 1 \ - https://github.com/Breadway/bread-ecosystem /bread-ecosystem - cd /bread-ecosystem - cargo build --release -p bread-theme - install -m 755 target/release/bread-theme /build-home/.local/bin/bread-theme - echo "bread-theme built OK" + cd /bos + LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh - name: Build ISO run: | @@ -247,9 +183,8 @@ jobs: gh release create "${TAG}" \ --repo "Breadway/bos" \ --title "BOS ${TAG}" \ - \ --notes-file /tmp/gh-release-notes.md \ - 2>/dev/null || echo "GitHub release already exists — skipping" + || echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)" # `stable` is a marker branch only — CI fast-forwards it to whatever # commit the latest real (non-RC) release tag points at. Never merged diff --git a/.gitignore b/.gitignore index 669daaa..6e38d88 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ logs/ /Bread Background.png # Local hygiene notes (not for commit) +CLAUDE.md + +# Python +__pycache__/ +*.pyc diff --git a/DESIGN.md b/DESIGN.md index 5828e33..31b22f8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -15,6 +15,8 @@ taken as current: | A/B root swapping | **Future.** Today: btrfs + snapper + **grub-btrfs**. GRUB pins `rootflags=subvol=@`, so `snapper rollback` is not the user-facing recovery path. | | Work on `dev`; origin = GitHub | Single-trunk `main`; `stable` is a CI marker. `origin` = Forgejo, `github` = GitHub. | | `[breadway]` provides bakery/breadbar/bos-settings | `[breadway]` is breadlock + AUR republishes. Desktop apps are bakery. **Not shipped:** breadcast, breadarr. | +| NVIDIA / A/B / Secure Boot / LUKS2 | NVIDIA proprietary is **unsupported**. A/B root swapping is **not implemented**. Secure Boot is **Setup Mode only** (self-signed `sbctl`). Disk encryption is **LUKS1** because GRUB cannot unlock LUKS2+Argon2id. | +| `SigLevel = Required` on `[breadway]` | **No.** Forgejo's Arch registry has no pacman-compatible db signatures. `SigLevel = Never` is TLS only; flipping Required without a signed db breaks installs. `KEYS.asc` signs ISO SHA256SUMS, not the pacman repo. | --- diff --git a/README.md b/README.md index e7ff533..6ad797b 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,16 @@ wiring up dotfiles, no per-tool bakery installs. keybinds, snappy animations, blur, and pywal-driven colours on a black base. - **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries (no network needed at install time): the `bread`/`breadd` automation daemon - plus `bread-emit` / `bread-module-host`, `breadbar` (status bar + - notifications), `breadbox` (launcher), `breadclip` (clipboard history), - `breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman` (notes), `breadpaper` - (wallpaper + theme), `breadsearch` (system search), `breadmon` (monitor - layout TUI), `breadshot` (screenshots), `bread-theme` (the shared palette - engine), `breadhelp` (onboarding + cheatsheet), `bos-settings` (control - panel), and the `bakery` package manager. Most of those apps are - zero-config on first boot; breadcrumbs networks are user-filled after - install. See [below](#the-bread-ecosystem). + (`bread-emit` / `bread-module-host` when the stable bread release publishes + them), `breadbar` (status bar + notifications), `breadbox` (launcher), + `breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles), + `breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme), + `breadsearch` (system search), `breadmon` (monitor layout TUI), + `breadshot` (screenshots), `bread-theme` (the shared palette engine), + `breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel), + and the `bakery` package manager. Most of those apps are zero-config on + first boot; breadcrumbs networks are user-filled after install. See + [below](#the-bread-ecosystem). - **breadlock** (lock screen + greeter) is the one bread\* app that ships as **pacman**, not bakery — it needs a root-owned PAM service. - **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery @@ -41,23 +42,28 @@ wiring up dotfiles, no per-tool bakery installs. `yay` ships for AUR access beyond bakery + `[breadway]`. - **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). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). -- **Security**: full-disk encryption (LUKS, via Calamares' built-in support — - cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted - install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled - automatically at install time when the firmware is in Setup Mode). + A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot, + not `snapper rollback` (GRUB pins `rootflags=subvol=@`). +- **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 + firmware is already in Setup Mode. ## What ships vs what does not | Channel | What | |---------|------| -| **Bakery, baked into skel** | `bakery`, `bread` / `breadd` / `bread-emit` / `bread-module-host`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) | +| **Bakery, required** | `bakery`, `bread` / `breadd`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) | +| **Bakery, optional** | `bread-emit`, `bread-module-host` — baked when the verified stable index publishes them; skipped (not a failed bake) until bread ships them | | **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 listed binary is missing on the builder. +`build-local.sh` fails if any **required** binary is missing on the builder. ## Repo layout @@ -68,7 +74,7 @@ repos and arrive via bakery. ``` bos/ ├── iso/ # archiso profile -│ ├── bread-lockfile.toml # bakery bins that MUST be baked +│ ├── bread-lockfile.toml # bakery bins (required + optional) │ ├── profiledef.sh │ ├── packages.x86_64 # live + installed pacman set │ └── airootfs/ # files overlaid onto the image @@ -81,7 +87,10 @@ bos/ │ ├── powerlevel10k/ │ └── yay-bin/ ├── dotfiles/ # STALE — not the live skel; see its README -├── scripts/smoke-test.sh +├── scripts/ +│ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME +│ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso +│ └── smoke-test.sh ├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release ├── build-local.sh # native ISO build for this machine ├── README.md @@ -120,10 +129,12 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs) The ISO lands in `out/bos--x86_64.iso`. The script pins `SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL to the Tailscale-reachable Forgejo registry for the build, and **exits -non-zero** if any lockfile binary (or breadhelp content) is missing. +non-zero** if any **required** lockfile binary (or breadhelp content) is +missing. Optional bins are skipped with a warning. -CI should populate the builder from the **stable** bakery index; local -builds still snapshot the builder. The lockfile is names only. +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. ### Why some packages are in-house @@ -144,7 +155,10 @@ dedicated release-signing key (not reused from anything else): 5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2 ``` -The public half is committed at [`KEYS.asc`](KEYS.asc). To verify a download: +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: ```sh gpg --import KEYS.asc @@ -221,7 +235,7 @@ directly. | Tool | Role | Launch | |------|------|--------| -| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. | runs at login (`breadd.service`) | +| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. Both extra bins are **optional** on the ISO until a stable bread release publishes them. | runs at login (`breadd.service`) | | `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login | | `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` | | `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock. **pacman**, not bakery. | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot | diff --git a/build-local.sh b/build-local.sh index 902c470..b06f58d 100755 --- a/build-local.sh +++ b/build-local.sh @@ -50,29 +50,45 @@ grep airootfs_image_tool_options "$STAGE/profiledef.sh" # offline. Copied at build time so the binaries never bloat the git repo. # # CI should prefer the stable bakery index when populating the builder home. -# Local builds still snapshot the builder. The lockfile is the name list; -# missing bins fail the bake (a hollow ISO is worse than a failed build). +# Local builds still snapshot the builder. required_bins fail the bake if +# missing; optional_bins are skipped with a warning (a hollow ISO is worse +# than a failed build). A flat `bins` list is treated as all-required. LOCKFILE="$REPO/iso/bread-lockfile.toml" if [[ ! -f "$LOCKFILE" ]]; then echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2 exit 1 fi -mapfile -t BREAD_BINS < <(python3 - "$LOCKFILE" <<'PY' +eval "$(python3 - "$LOCKFILE" <<'PY' import sys, tomllib path = sys.argv[1] with open(path, "rb") as f: data = tomllib.load(f) -bins = data.get("bins") or data.get("binaries") -if not isinstance(bins, list) or not bins: - sys.exit(f"{path}: missing non-empty bins list") -for b in bins: - if not isinstance(b, str) or not b or "/" in b or b in (".", ".."): - sys.exit(f"{path}: invalid bin name {b!r}") - print(b) +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: + sys.exit(f"{path}: missing non-empty required_bins (or bins) list") +if not isinstance(optional, list): + sys.exit(f"{path}: optional_bins must be a list") +blocked = {"breadcast", "breadarr"} +for label, names in (("required_bins", required), ("optional_bins", optional)): + for b in names: + if not isinstance(b, str) or not b or "/" in b or b in (".", ".."): + sys.exit(f"{path}: invalid {label} name {b!r}") + if b in blocked: + sys.exit(f"{path}: {b} is not shipped on the ISO") +def emit(name, values): + print(f"{name}=(") + for v in values: + print(f" {v!r}") + print(")") +emit("REQUIRED_BINS", required) +emit("OPTIONAL_BINS", optional) PY -) -if [[ ${#BREAD_BINS[@]} -eq 0 ]]; then - echo "ERROR: $LOCKFILE produced an empty bins list" >&2 +)" +if [[ ${#REQUIRED_BINS[@]} -eq 0 ]]; then + echo "ERROR: $LOCKFILE produced an empty required bins list" >&2 exit 1 fi @@ -83,10 +99,10 @@ BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery" BAKERY_SHARE="$LAPTOP_HOME/.local/share" SKEL="$STAGE/airootfs/etc/skel" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" -echo "lockfile: $LOCKFILE (${#BREAD_BINS[@]} bins)" +echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)" missing=() -for b in "${BREAD_BINS[@]}"; do +for b in "${REQUIRED_BINS[@]}"; do if [[ ! -x "$BAKERY_BIN/$b" ]]; then missing+=("$BAKERY_BIN/$b") fi @@ -99,6 +115,15 @@ if [[ ${#missing[@]} -gt 0 ]]; then exit 1 fi +BREAD_BINS=("${REQUIRED_BINS[@]}") +for b in "${OPTIONAL_BINS[@]}"; do + if [[ -x "$BAKERY_BIN/$b" ]]; then + BREAD_BINS+=("$b") + else + echo "WARN: optional lockfile bin missing, skipping: $BAKERY_BIN/$b" >&2 + fi +done + install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" for b in "${BREAD_BINS[@]}"; do install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b" diff --git a/dotfiles/README.md b/dotfiles/README.md index 8ad5099..8e61cc6 100644 --- a/dotfiles/README.md +++ b/dotfiles/README.md @@ -1,8 +1,12 @@ # `dotfiles/` is not the live skel -These files are a leftover from an earlier design (Hyprland `.conf` binds, -including grimblast). They are **not** copied into the ISO or the installed -system. +These files are a leftover from an earlier design (Hyprland `.conf` binds). +They are **not** copied into the ISO or the installed system. + +`dotfiles/hypr/keybinds.conf` still mentions `grimblast`. That is not the +screenshot tool BOS ships — live binds use **breadshot** +(`iso/airootfs/etc/skel/.config/hypr/binds.json`). Do not treat grimblast +as current. Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel) (`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot, diff --git a/dotfiles/hypr/keybinds.conf b/dotfiles/hypr/keybinds.conf index 7cf8cdd..a857962 100644 --- a/dotfiles/hypr/keybinds.conf +++ b/dotfiles/hypr/keybinds.conf @@ -1,3 +1,7 @@ +# STALE — not the live Hyprland binds. Not copied into the ISO. +# Screenshots are breadshot (see iso/airootfs/etc/skel/.config/hypr/binds.json), +# not grimblast. Do not copy from this file. + $mod = SUPER # App launchers diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 4d61cfe..66ba35b 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -41,8 +41,11 @@ passwd -l root || true # over to the target (unpackfs may skip it / perms differ), leaving the installed # system unable to verify package signatures — the first `pacman -Syu` then dies # with "keyring is not writable / required key missing". Initialise it here so a -# fresh install can update out of the box. archlinux-keyring is already present; -# [breadway] is SigLevel=Never so it needs no key. +# fresh install can update out of the box. archlinux-keyring is already present +# and is the only keyring populated — it verifies official Arch packages. +# [breadway] stays SigLevel=Never (Forgejo does not serve pacman-compatible +# db signatures). Do not import KEYS.asc here: that key signs ISO SHA256SUMS, +# not the pacman repo; treating it as a repo key would be a lie. # --------------------------------------------------------------------------- if command -v pacman-key &>/dev/null; then pacman-key --init || echo "WARN: pacman-key --init failed" diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 59992fb..7e15c7a 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). # -# Forgejo signs the repo db with a key pacman can't look up, so TrustAll -# fails. SigLevel = Never skips verification (acceptable for this private -# repo over TLS). Future improvement: import Forgejo's signing key and -# switch to SigLevel = Required for full package verification. +# Forgejo's Arch package registry does not serve pacman-compatible db +# signatures. SigLevel = Never is TLS-only integrity: the connection is +# HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM) +# rides this repo. Do NOT flip to SigLevel = Required unless a signed db +# has been verified to work — Required without signatures breaks the ISO +# and every install that uses [breadway]. KEYS.asc is the ISO SHA256SUMS +# signing key, not a pacman repo key. # ----------------------------------------------------------------------- # The section name must match Forgejo's served db filename # ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 53ac3c1..aeb6fbd 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -2,13 +2,17 @@ # bos-update — update all of BOS in one go. # # BOS packages come from two channels, so a full update touches both: -# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.). -# Every transaction is snapshotted by snap-pac, so you can roll -# back from the GRUB "snapshots" submenu or BOS Settings. +# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR +# republishes: calamares, zen-browser-bin, bibata, yay-bin, +# powerlevel10k). [breadway] does NOT provide bos-settings +# or other bakery desktop apps. Every transaction is +# snapshotted by snap-pac; recover via the GRUB "snapshots" +# submenu (grub-btrfs), not `snapper rollback`. # 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list` -# reports as installed — bread, breadbar, breadbox, breadcrumbs, -# breadpad, breadman, bread-theme, breadpaper, breadmon, -# breadsearch, breadclip, breadshot, ...). +# reports as installed — bakery, bread, breadbar, breadbox, +# breadcrumbs, breadpad, breadman, bread-theme, breadpaper, +# breadmon, breadsearch, breadclip, breadshot, bos-settings, +# breadhelp, ...). # # Best-effort: a failure in one channel doesn't abort the other. set -uo pipefail diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index 2ea64a2..d704c33 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -1,22 +1,24 @@ -# Bakery binaries that MUST be baked into the live/installed skel. +# Bakery binaries baked into the live/installed skel. # -# build-local.sh derives BREAD_BINS from `bins` — this is the name list, not a -# second hardcoded array. A missing binary fails the bake: a hollow ISO is -# worse than a failed build. +# build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing +# *required* binary fails the bake: a hollow ISO is worse than a failed build. +# optional_bins are baked when the verified stable index publishes them, and +# skipped with a warning when it does not (today: bread 0.7.0 has no +# bread-emit / bread-module-host). # -# CI should populate the builder from the stable bakery index +# 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 be present. +# is installed on the builder; this file only names what must / may be present. # -# Not shipped (even if present on the builder): breadcast, breadarr. +# Not shipped (even if they appear in the index): breadcast, breadarr. # breadlock is pacman (see packages.x86_64), not bakery. -bins = [ +required_bins = [ "bakery", "bread", "breadd", - "bread-emit", - "bread-module-host", "breadman", "breadbar", "breadbox", @@ -34,3 +36,9 @@ bins = [ "bos-settings", "breadhelp", ] + +# Bake if the verified index publishes them; do not fail the ISO if absent. +optional_bins = [ + "bread-emit", + "bread-module-host", +] diff --git a/iso/pacman.conf b/iso/pacman.conf index 59992fb..7e15c7a 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). # -# Forgejo signs the repo db with a key pacman can't look up, so TrustAll -# fails. SigLevel = Never skips verification (acceptable for this private -# repo over TLS). Future improvement: import Forgejo's signing key and -# switch to SigLevel = Required for full package verification. +# Forgejo's Arch package registry does not serve pacman-compatible db +# signatures. SigLevel = Never is TLS-only integrity: the connection is +# HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM) +# rides this repo. Do NOT flip to SigLevel = Required unless a signed db +# has been verified to work — Required without signatures breaks the ISO +# and every install that uses [breadway]. KEYS.asc is the ISO SHA256SUMS +# signing key, not a pacman repo key. # ----------------------------------------------------------------------- # The section name must match Forgejo's served db filename # ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. diff --git a/packaging/arch/README.md b/packaging/arch/README.md index b6ef676..ce80f7e 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -10,8 +10,9 @@ one publishes on a push to `packaging//**`. Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot, bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see -`iso/bread-lockfile.toml`, which `build-local.sh` uses as the name list -when baking this machine's bakery install into the ISO's `/etc/skel`. +`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which +`build-local.sh` uses as the name list when baking this machine's bakery +install into the ISO's `/etc/skel`. `breadlock` is the sole deliberate exception (it needs a root-owned `/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no privileged-install path for) and stays on pacman only; see diff --git a/scripts/ci-stage-bakery.py b/scripts/ci-stage-bakery.py new file mode 100755 index 0000000..3545f6f --- /dev/null +++ b/scripts/ci-stage-bakery.py @@ -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()) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh new file mode 100755 index 0000000..1b01cab --- /dev/null +++ b/scripts/ci-verify-bake.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Read-only checks that a builder home (and optionally a staged skel) has +# everything build-local.sh needs before mkarchiso. Exit non-zero on failure. +# +# LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh +# SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +LOCKFILE="${LOCKFILE:-$REPO/iso/bread-lockfile.toml}" +LAPTOP_HOME="${LAPTOP_HOME:-/build-home}" +SKEL="${SKEL:-}" + +pass=0 +fail=0 +ok() { printf ' PASS %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf ' FAIL %s\n' "$1" >&2; fail=$((fail + 1)); } + +if [[ ! -f "$LOCKFILE" ]]; then + echo "ERROR: lockfile missing: $LOCKFILE" >&2 + exit 1 +fi + +eval "$(python3 - "$LOCKFILE" <<'PY' +import sys, tomllib +path = sys.argv[1] +with open(path, "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") or [] +def emit(name, values): + print(f"{name}=(") + for v in values: + print(f" {v!r}") + print(")") +emit("REQUIRED_BINS", required) +emit("OPTIONAL_BINS", optional) +PY +)" + +echo "== lockfile $LOCKFILE ==" +echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +echo "== builder home $LAPTOP_HOME ==" + +check_exec() { + local path="$1" label="$2" + if [[ -x "$path" && -f "$path" ]]; then + ok "$label executable: $path" + else + bad "$label missing or not executable: $path" + fi +} + +check_dir() { + local path="$1" label="$2" + if [[ -d "$path" ]]; then + ok "$label: $path" + else + bad "$label missing: $path" + fi +} + +check_file() { + local path="$1" label="$2" + if [[ -f "$path" ]]; then + ok "$label: $path" + else + bad "$label missing: $path" + fi +} + +for b in "${REQUIRED_BINS[@]}"; do + check_exec "$LAPTOP_HOME/.local/bin/$b" "required bin $b" +done +for b in "${OPTIONAL_BINS[@]}"; do + if [[ -x "$LAPTOP_HOME/.local/bin/$b" ]]; then + ok "optional bin $b present" + else + printf ' ---- optional bin %s not staged (ok until bread ships it)\n' "$b" + fi +done + +check_dir "$LAPTOP_HOME/.local/share/breadhelp/content" "breadhelp content" +check_file "$LAPTOP_HOME/.cache/bakery/index.json" "bakery index cache" +check_file "$LAPTOP_HOME/.local/state/bakery/installed.json" "bakery installed.json" + +mapfile -t UNITS < <(python3 - "$LAPTOP_HOME/.local/state/bakery/installed.json" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + data = json.load(f) +pkgs = data.get("packages", data) +for pkg in pkgs.values(): + for s in pkg.get("services", []): + print(s["unit"] if isinstance(s, dict) else s) +PY +) +if [[ ${#UNITS[@]} -eq 0 ]]; then + bad "installed.json lists no service units" +else + for unit in "${UNITS[@]}"; do + [[ -n "$unit" ]] || continue + check_file "$LAPTOP_HOME/.config/systemd/user/$unit" "unit $unit" + done +fi + +if [[ -n "$SKEL" ]]; then + echo "== staged skel $SKEL ==" + for b in "${REQUIRED_BINS[@]}"; do + check_exec "$SKEL/.local/bin/$b" "skel required bin $b" + done + check_dir "$SKEL/.local/share/breadhelp/content" "skel breadhelp content" + check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache" + for unit in "${UNITS[@]}"; do + [[ -n "$unit" ]] || continue + if [[ -f "$SKEL/.config/systemd/user/$unit" ]]; then + ok "skel unit $unit" + else + bad "skel unit missing: $SKEL/.config/systemd/user/$unit" + fi + done +fi + +echo +printf 'Result: %d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 2d5264e..ab70559 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -46,9 +46,16 @@ done check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" echo "== bread ecosystem on PATH ==" -for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do +for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do check "$bin found" "command -v $bin" done +for bin in bread-emit bread-module-host; do + if command -v "$bin" >/dev/null 2>&1; then + ok "$bin found" + else + note "$bin not on PATH (optional until stable bread ships it)" + fi +done echo "== bos-settings ==" check "bos-settings installed" "command -v bos-settings"