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
# 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

5
.gitignore vendored
View file

@ -44,3 +44,8 @@ logs/
/Bread Background.png
# 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. |
| 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. |
---

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.
- **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-<date>-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 |

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.
#
# 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"

View file

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

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

View file

@ -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 "<section>.db" from Server.

View file

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

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
# 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",
]

View file

@ -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 "<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,
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

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