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:
Breadway 2026-08-15 22:20:29 +08:00
parent 3ab97c1634
commit a3ead6607a
16 changed files with 687 additions and 154 deletions

View file

@ -1,19 +1,21 @@
name: Build and release ISO name: Build and release ISO
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container), # Builds the BOS ISO on the hestia self-hosted runner (native Arch container).
# downloads all bakery ecosystem binaries from their GitHub releases, compiles # Stages bakery desktop apps from the *minisign-verified* stable index at
# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release. # https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs
# A matching GitHub release is created that points to Forgejo for the download # 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). # (GitHub releases cannot host files larger than 2 GB).
# #
# Required secrets: # Required secrets:
# RELEASE_TOKEN — Forgejo API token with write:repository scope # 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" # GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing"
# identity (releases@breadway.dev); public half is committed # identity (releases@breadway.dev); public half is committed
# at KEYS.asc for verification. No passphrase (CI-only key, # at KEYS.asc for verifying ISO SHA256SUMS only. That key
# access controlled via the Forgejo secret store, not a # does not sign the [breadway] pacman repo. No passphrase
# passphrase nobody could type non-interactively anyway). # (CI-only key, access controlled via the Forgejo secret
# store).
on: on:
push: push:
@ -28,6 +30,8 @@ jobs:
release-iso: release-iso:
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
container: container:
# Floating tag: this environment cannot pin a reproducible digest of
# archlinux:latest. Do not invent one.
image: archlinux:latest image: archlinux:latest
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod # --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
# --network=host: gives localhost:3002 access to Forgejo (avoids the # --network=host: gives localhost:3002 access to Forgejo (avoids the
@ -37,7 +41,7 @@ jobs:
steps: steps:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
pacman -Syu --noconfirm archiso curl python git rust pacman -Syu --noconfirm archiso curl python git minisign
- name: Determine tag and version - name: Determine tag and version
id: vars id: vars
@ -55,85 +59,17 @@ jobs:
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \ git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
- name: Download bakery ecosystem binaries - name: Stage bakery ecosystem from signed stable index
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p /build-home/.local/bin \ cd /bos
/build-home/.local/state/bakery \ LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py
/build-home/.cache/bakery
# Fetch the canonical bakery index - name: Verify staged bakery bake inputs
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
run: | run: |
set -euo pipefail set -euo pipefail
# bread-theme is not in the bakery index; build it at the ref pinned cd /bos
# in bos-settings' Cargo.toml so the CLI matches the library version LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh
# 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"
- name: Build ISO - name: Build ISO
run: | run: |
@ -247,9 +183,8 @@ jobs:
gh release create "${TAG}" \ gh release create "${TAG}" \
--repo "Breadway/bos" \ --repo "Breadway/bos" \
--title "BOS ${TAG}" \ --title "BOS ${TAG}" \
\
--notes-file /tmp/gh-release-notes.md \ --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 # `stable` is a marker branch only — CI fast-forwards it to whatever
# commit the latest real (non-RC) release tag points at. Never merged # commit the latest real (non-RC) release tag points at. Never merged

5
.gitignore vendored
View file

@ -44,3 +44,8 @@ logs/
/Bread Background.png /Bread Background.png
# Local hygiene notes (not for commit) # Local hygiene notes (not for commit)
CLAUDE.md
# Python
__pycache__/
*.pyc

View file

@ -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. | | 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. | | 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. | | `[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. |
--- ---

View file

@ -16,15 +16,16 @@ wiring up dotfiles, no per-tool bakery installs.
keybinds, snappy animations, blur, and pywal-driven colours on a black base. keybinds, snappy animations, blur, and pywal-driven colours on a black base.
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries - **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
(no network needed at install time): the `bread`/`breadd` automation daemon (no network needed at install time): the `bread`/`breadd` automation daemon
plus `bread-emit` / `bread-module-host`, `breadbar` (status bar + (`bread-emit` / `bread-module-host` when the stable bread release publishes
notifications), `breadbox` (launcher), `breadclip` (clipboard history), them), `breadbar` (status bar + notifications), `breadbox` (launcher),
`breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman` (notes), `breadpaper` `breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles),
(wallpaper + theme), `breadsearch` (system search), `breadmon` (monitor `breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme),
layout TUI), `breadshot` (screenshots), `bread-theme` (the shared palette `breadsearch` (system search), `breadmon` (monitor layout TUI),
engine), `breadhelp` (onboarding + cheatsheet), `bos-settings` (control `breadshot` (screenshots), `bread-theme` (the shared palette engine),
panel), and the `bakery` package manager. Most of those apps are `breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel),
zero-config on first boot; breadcrumbs networks are user-filled after and the `bakery` package manager. Most of those apps are zero-config on
install. See [below](#the-bread-ecosystem). 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 - **breadlock** (lock screen + greeter) is the one bread\* app that ships as
**pacman**, not bakery — it needs a root-owned PAM service. **pacman**, not bakery — it needs a root-owned PAM service.
- **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery - **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]`. `yay` ships for AUR access beyond bakery + `[breadway]`.
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
with avahi mDNS discovery, TLP power management, fwupd firmware updates. 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 - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
- **Security**: full-disk encryption (LUKS, via Calamares' built-in support — A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot,
cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted not `snapper rollback` (GRUB pins `rootflags=subvol=@`).
install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled - **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock
automatically at install time when the firmware is in Setup Mode). 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 ## What ships vs what does not
| Channel | What | | 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, …) | | **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) |
| **Not shipped** | `breadcast`, `breadarr` | | **Not shipped** | `breadcast`, `breadarr` |
The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml). 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 ## Repo layout
@ -68,7 +74,7 @@ repos and arrive via bakery.
``` ```
bos/ bos/
├── iso/ # archiso profile ├── iso/ # archiso profile
│ ├── bread-lockfile.toml # bakery bins that MUST be baked │ ├── bread-lockfile.toml # bakery bins (required + optional)
│ ├── profiledef.sh │ ├── profiledef.sh
│ ├── packages.x86_64 # live + installed pacman set │ ├── packages.x86_64 # live + installed pacman set
│ └── airootfs/ # files overlaid onto the image │ └── airootfs/ # files overlaid onto the image
@ -81,7 +87,10 @@ bos/
│ ├── powerlevel10k/ │ ├── powerlevel10k/
│ └── yay-bin/ │ └── yay-bin/
├── dotfiles/ # STALE — not the live skel; see its README ├── 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 ├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release
├── build-local.sh # native ISO build for this machine ├── build-local.sh # native ISO build for this machine
├── README.md ├── README.md
@ -120,10 +129,12 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs)
The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins
`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL `SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL
to the Tailscale-reachable Forgejo registry for the build, and **exits 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 CI stages the builder from the **minisign-verified** stable bakery index
builds still snapshot the builder. The lockfile is names only. (`index.json` + `index.json.minisig`); local builds still snapshot the
builder. The lockfile is names only.
### Why some packages are in-house ### 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 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 ```sh
gpg --import KEYS.asc gpg --import KEYS.asc
@ -221,7 +235,7 @@ directly.
| Tool | Role | Launch | | 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 | | `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` | | `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 | | `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 |

View file

@ -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. # 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. # CI should prefer the stable bakery index when populating the builder home.
# Local builds still snapshot the builder. The lockfile is the name list; # Local builds still snapshot the builder. required_bins fail the bake if
# missing bins fail the bake (a hollow ISO is worse than a failed build). # 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" LOCKFILE="$REPO/iso/bread-lockfile.toml"
if [[ ! -f "$LOCKFILE" ]]; then if [[ ! -f "$LOCKFILE" ]]; then
echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2 echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2
exit 1 exit 1
fi fi
mapfile -t BREAD_BINS < <(python3 - "$LOCKFILE" <<'PY' eval "$(python3 - "$LOCKFILE" <<'PY'
import sys, tomllib import sys, tomllib
path = sys.argv[1] path = sys.argv[1]
with open(path, "rb") as f: with open(path, "rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
bins = data.get("bins") or data.get("binaries") required = data.get("required_bins")
if not isinstance(bins, list) or not bins: optional = data.get("optional_bins") or []
sys.exit(f"{path}: missing non-empty bins list") if required is None:
for b in bins: required = data.get("bins") or data.get("binaries")
if not isinstance(b, str) or not b or "/" in b or b in (".", ".."): if not isinstance(required, list) or not required:
sys.exit(f"{path}: invalid bin name {b!r}") sys.exit(f"{path}: missing non-empty required_bins (or bins) list")
print(b) 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 PY
) )"
if [[ ${#BREAD_BINS[@]} -eq 0 ]]; then if [[ ${#REQUIRED_BINS[@]} -eq 0 ]]; then
echo "ERROR: $LOCKFILE produced an empty bins list" >&2 echo "ERROR: $LOCKFILE produced an empty required bins list" >&2
exit 1 exit 1
fi fi
@ -83,10 +99,10 @@ BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery"
BAKERY_SHARE="$LAPTOP_HOME/.local/share" BAKERY_SHARE="$LAPTOP_HOME/.local/share"
SKEL="$STAGE/airootfs/etc/skel" SKEL="$STAGE/airootfs/etc/skel"
echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ==="
echo "lockfile: $LOCKFILE (${#BREAD_BINS[@]} bins)" echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)"
missing=() missing=()
for b in "${BREAD_BINS[@]}"; do for b in "${REQUIRED_BINS[@]}"; do
if [[ ! -x "$BAKERY_BIN/$b" ]]; then if [[ ! -x "$BAKERY_BIN/$b" ]]; then
missing+=("$BAKERY_BIN/$b") missing+=("$BAKERY_BIN/$b")
fi fi
@ -99,6 +115,15 @@ if [[ ${#missing[@]} -gt 0 ]]; then
exit 1 exit 1
fi 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" install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
for b in "${BREAD_BINS[@]}"; do for b in "${BREAD_BINS[@]}"; do
install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b" install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b"

View file

@ -1,8 +1,12 @@
# `dotfiles/` is not the live skel # `dotfiles/` is not the live skel
These files are a leftover from an earlier design (Hyprland `.conf` binds, These files are a leftover from an earlier design (Hyprland `.conf` binds).
including grimblast). They are **not** copied into the ISO or the installed They are **not** copied into the ISO or the installed system.
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) Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel)
(`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot, (`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot,

View file

@ -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 $mod = SUPER
# App launchers # App launchers

View file

@ -41,8 +41,11 @@ passwd -l root || true
# over to the target (unpackfs may skip it / perms differ), leaving the installed # 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 # 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 # 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; # fresh install can update out of the box. archlinux-keyring is already present
# [breadway] is SigLevel=Never so it needs no key. # 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 if command -v pacman-key &>/dev/null; then
pacman-key --init || echo "WARN: pacman-key --init failed" pacman-key --init || echo "WARN: pacman-key --init failed"

View file

@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist
# Packages are published to the Forgejo Arch registry (group "os") by the # Packages are published to the Forgejo Arch registry (group "os") by the
# .forgejo/workflows/*.yml workflows in this repo (and breadlock's). # .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 # Forgejo's Arch package registry does not serve pacman-compatible db
# fails. SigLevel = Never skips verification (acceptable for this private # signatures. SigLevel = Never is TLS-only integrity: the connection is
# repo over TLS). Future improvement: import Forgejo's signing key and # HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM)
# switch to SigLevel = Required for full package verification. # 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 # The section name must match Forgejo's served db filename
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server. # ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.

View file

@ -2,13 +2,17 @@
# bos-update — update all of BOS in one go. # bos-update — update all of BOS in one go.
# #
# BOS packages come from two channels, so a full update touches both: # BOS packages come from two channels, so a full update touches both:
# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.). # 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR
# Every transaction is snapshotted by snap-pac, so you can roll # republishes: calamares, zen-browser-bin, bibata, yay-bin,
# back from the GRUB "snapshots" submenu or BOS Settings. # 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` # 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
# reports as installed — bread, breadbar, breadbox, breadcrumbs, # reports as installed — bakery, bread, breadbar, breadbox,
# breadpad, breadman, bread-theme, breadpaper, breadmon, # breadcrumbs, breadpad, breadman, bread-theme, breadpaper,
# breadsearch, breadclip, breadshot, ...). # breadmon, breadsearch, breadclip, breadshot, bos-settings,
# breadhelp, ...).
# #
# Best-effort: a failure in one channel doesn't abort the other. # Best-effort: a failure in one channel doesn't abort the other.
set -uo pipefail set -uo pipefail

View file

@ -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 # build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing
# second hardcoded array. A missing binary fails the bake: a hollow ISO is # *required* binary fails the bake: a hollow ISO is worse than a failed build.
# 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 # (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. # breadlock is pacman (see packages.x86_64), not bakery.
bins = [ required_bins = [
"bakery", "bakery",
"bread", "bread",
"breadd", "breadd",
"bread-emit",
"bread-module-host",
"breadman", "breadman",
"breadbar", "breadbar",
"breadbox", "breadbox",
@ -34,3 +36,9 @@ bins = [
"bos-settings", "bos-settings",
"breadhelp", "breadhelp",
] ]
# Bake if the verified index publishes them; do not fail the ISO if absent.
optional_bins = [
"bread-emit",
"bread-module-host",
]

View file

@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist
# Packages are published to the Forgejo Arch registry (group "os") by the # Packages are published to the Forgejo Arch registry (group "os") by the
# .forgejo/workflows/*.yml workflows in this repo (and breadlock's). # .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 # Forgejo's Arch package registry does not serve pacman-compatible db
# fails. SigLevel = Never skips verification (acceptable for this private # signatures. SigLevel = Never is TLS-only integrity: the connection is
# repo over TLS). Future improvement: import Forgejo's signing key and # HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM)
# switch to SigLevel = Required for full package verification. # 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 # The section name must match Forgejo's served db filename
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server. # ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.

View file

@ -10,8 +10,9 @@ one publishes on a push to `packaging/<name>/**`.
Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs, Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs,
breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot, breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot,
bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see
`iso/bread-lockfile.toml`, which `build-local.sh` uses as the name list `iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which
when baking this machine's bakery install into the ISO's `/etc/skel`. `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 `breadlock` is the sole deliberate exception (it needs a root-owned
`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no `/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no
privileged-install path for) and stays on pacman only; see privileged-install path for) and stays on pacman only; see

387
scripts/ci-stage-bakery.py Executable file
View 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())

128
scripts/ci-verify-bake.sh Executable file
View file

@ -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 ]]

View file

@ -46,9 +46,16 @@ done
check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]"
echo "== bread ecosystem on PATH ==" 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" check "$bin found" "command -v $bin"
done 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 ==" echo "== bos-settings =="
check "bos-settings installed" "command -v bos-settings" check "bos-settings installed" "command -v bos-settings"