From 8a934fa6b643874264e52ef3eb8b59d7578ecdbb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 21:37:26 +0800 Subject: [PATCH 01/30] ci: fix release-iso bread-theme step for bos-settings Tauri migration bos-settings moved its Rust manifest to src/Cargo.toml and switched the bread-theme dependency from a tag pin to branch = "main" (no release with the needed functions yet). The workflow was still fetching the old root Cargo.toml path and grepping for a tag field, so it 404'd and broke every v0.5.2 release-iso run. --- .forgejo/workflows/release-iso.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index 862368e..e01cb7c 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -110,22 +110,25 @@ jobs: - name: Build bread-theme from source run: | set -euo pipefail - # bread-theme is not in the bakery index; build it at the tag pinned + # 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), so fetch its Cargo.toml - # from there instead of a path that no longer exists in this - # checkout. Uses bos-settings' default branch (dev) — the branch its - # own CI actually publishes the `bos-settings` pacman package from. + # (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/Cargo.toml" \ + curl -fsSL "https://git.breadway.dev/${REPO_OWNER}/bos-settings/raw/branch/dev/src/Cargo.toml" \ -o /tmp/bos-settings-Cargo.toml - THEME_TAG=$(grep 'bread-theme.*tag' /tmp/bos-settings-Cargo.toml \ - | grep -oP '"v[^"]+"' | tr -d '"') - echo "Building bread-theme @ $THEME_TAG" - git clone --branch "$THEME_TAG" --depth 1 \ + # 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 From 881ac41cbb5d399350569b9d6c10b2d17fd4b55c Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:14:12 +0800 Subject: [PATCH 02/30] CI: fast-forward a stable branch to the latest release tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bot-moved-only stable marker branch, never merged into by hand — fixes the same "long-lived branch silently rots" failure mode found in the app repos' old dev/beta/main model, applied here since bos shares the single-main-branch principle even though its ISO release cadence stays manual/deliberate rather than continuous. --- .forgejo/workflows/release-iso.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index e01cb7c..241197d 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -250,3 +250,19 @@ jobs: \ --notes-file /tmp/gh-release-notes.md \ 2>/dev/null || echo "GitHub release already exists — skipping" + + # `stable` is a marker branch only — CI fast-forwards it to whatever + # commit the latest real (non-RC) release tag points at. Never merged + # into by hand, so unlike the old dev/beta/main model it can't rot: + # nobody has to remember to move it, a bot always does. Lets you + # `git diff stable..main` before a build to see what's new since the + # last release, without a human-maintained promotion step. + - name: Fast-forward stable branch to this tag + if: ${{ !contains(steps.vars.outputs.tag, '-rc.') }} + env: + FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -euo pipefail + cd /bos + git push "https://oauth2:${FORGEJO_TOKEN}@git.breadway.dev/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/stable" --force From a21e81476f8bfa4e23c901f3a4c0f7c120f4c48b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:40:55 +0800 Subject: [PATCH 03/30] Align ISO bake and docs with bakery/Tauri product story Derive BREAD_BINS from iso/bread-lockfile.toml and fail the bake when a listed binary or breadhelp content is missing. Bake bakery share files and drop breadcast from the copied installed.json. Add WebKitGTK 4.1 for Tauri bos-settings, remove rustup, and rewrite README/DESIGN to match the ISO+skel tree. --- DESIGN.md | 163 ++++++++--------- README.md | 168 ++++++++++------- build-local.sh | 198 ++++++++++++++++++--- dotfiles/README.md | 9 + iso/airootfs/etc/calamares/post-install.sh | 5 +- iso/airootfs/etc/pacman.conf | 9 +- iso/bread-lockfile.toml | 36 ++++ iso/packages.x86_64 | 33 ++-- iso/pacman.conf | 9 +- packaging/arch/README.md | 4 +- scripts/smoke-test.sh | 5 +- 11 files changed, 427 insertions(+), 212 deletions(-) create mode 100644 dotfiles/README.md create mode 100644 iso/bread-lockfile.toml diff --git a/DESIGN.md b/DESIGN.md index d6d6585..5828e33 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,4 +1,24 @@ -# BOS — Bread Operating System Plan +# BOS — historical design plan + +## Current architecture + +**Read [README.md](README.md) for how this repo actually ships.** This file +is the original plan. Several sections below are historical and must not be +taken as current: + +| Plan said | What the tree does now | +|-----------|------------------------| +| Cargo workspace with a `bos-settings/` member | This repo is ISO + Calamares + skel only. No Cargo workspace. | +| `bos-settings` as an in-tree GTK4 app | Standalone bakery product, **Tauri 2 + Svelte**. | +| bakery install in Calamares post-install | bakery binaries + breadhelp content are **baked into `/etc/skel` at ISO build time** from `iso/bread-lockfile.toml`. Missing bins fail the bake. | +| `dotfiles/` is the live skel | Live defaults are `iso/airootfs/etc/skel`. `dotfiles/` is stale. | +| 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. | + +--- + +# Original plan (kept for history) ## Context @@ -7,51 +27,25 @@ The bread ecosystem (bread, breadbar, breadbox, breadcrumbs, breadpad/breadman, Goals: - **Install and be done**: Calamares GUI installer → reboot → working Hyprland + full bread stack - **Rollback safety**: Btrfs subvolumes + snapper + snap-pac; every pacman transaction is snapshotted -- **Unified config**: `bos-settings` GTK4 app surfaces all app configs + snapshot management + bakery updates +- **Unified config**: `bos-settings` surfaces all app configs + snapshot management + bakery updates - **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model) --- ## Repo Structure -Single new repo: `Breadway/bos` — a Cargo workspace. +Single new repo: `Breadway/bos` — *planned as* a Cargo workspace. **That is +not what landed**; see Current architecture. ``` bos/ -├── Cargo.toml # Workspace (members: [bos-settings]) -├── bos-settings/ # GTK4 unified settings app -│ ├── Cargo.toml -│ └── src/ -│ ├── main.rs -│ ├── state.rs -│ ├── theme.rs -│ ├── ui/ -│ │ ├── window.rs # Sidebar + content shell (port breadman pattern) -│ │ ├── sidebar.rs -│ │ └── views/ -│ │ ├── bread.rs -│ │ ├── breadbar.rs -│ │ ├── breadbox.rs -│ │ ├── breadcrumbs.rs -│ │ ├── breadpad.rs -│ │ ├── snapshots.rs -│ │ ├── packages.rs -│ │ └── hyprland.rs -│ └── config/ -│ └── mod.rs # Per-app config loaders -├── iso/ # archiso profile +├── Cargo.toml # Workspace (members: [bos-settings]) — NOT in tree +├── bos-settings/ # planned GTK4 app — now its own bakery repo +├── iso/ # archiso profile (this is the repo) │ ├── profiledef.sh -│ ├── packages.x86_64 # Live ISO + installed system package list -│ ├── airootfs/ # Files overlaid onto live ISO root -│ │ └── etc/ -│ │ ├── calamares/ # Calamares YAML configuration -│ │ └── skel/ # Default user dotfiles -└── dotfiles/ # Default configs deployed at install time - ├── hyprland/ # hyprland.conf, keybinds, autostart - ├── bread/ # breadd.toml, init.lua, devices.lua - ├── breadbar/ # (no config needed; zero-config by default) - ├── breadbox/ # config.toml with default context priorities - └── breadcrumbs/ # breadcrumbs.toml with default home profile +│ ├── packages.x86_64 +│ └── airootfs/ +└── dotfiles/ # planned install-time configs — NOT the live skel ``` --- @@ -70,7 +64,11 @@ bos/ Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes. -**A/B compatibility note:** The `@` subvolume is self-contained and can be swapped atomically — this is the design property needed for a future A/B upgrade path. The layout does not need to change to adopt it. +**A/B compatibility note (future):** The `@` subvolume is self-contained and +could be swapped atomically. This is a design property for a later upgrade +path. It is **not** implemented. Recovery today is reboot into a grub-btrfs +snapshot; GRUB's `rootflags=subvol=@` means a raw `snapper rollback` is the +wrong instruction to give users. ### Snapshot tooling (installed + configured during post-install) @@ -96,100 +94,79 @@ No user-facing CLI needed for this component — `bos-settings` is the interface ### archiso profile (`iso/`) - Derives from `/usr/share/archiso/configs/releng/` (the standard baseline) -- `packages.x86_64` includes: base, linux, grub, btrfs-progs, snapper, snap-pac, grub-btrfs, hyprland, pipewire, wireplumber, networkmanager, gtk4, gtk4-layer-shell, iw, librsvg, libpulse, bluez, bluez-utils, calamares, calamares-qt6 -- `airootfs/etc/skel/` contains the default dotfiles (symlinked from `dotfiles/`) +- `packages.x86_64` is the live + installed pacman set (Hyprland, Calamares, + breadlock, WebKitGTK 4.1 for Tauri bos-settings, …). bakery apps are not + listed here. +- `airootfs/etc/skel/` contains the default user configs (this is the live + skel — not `dotfiles/`). - Live session autologs into a `liveuser` and launches Calamares automatically ### Calamares modules (in order) +The historical list below included a post-install `bakery install` and +Calamares `bootloader`/`grubcfg` installing GRUB. What shipped instead: +binaries are already in skel; `post-install.sh` runs `grub-install` + +`grub-mkconfig` (Calamares' bootloader modules leave the ESP empty here). + 1. **welcome** — system checks (RAM ≥ 2GB, internet, disk space) 2. **locale** — timezone + locale selection 3. **keyboard** — layout selection 4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above 5. **users** — create main user, set password 6. **packages** — install package list (reuses `packages.x86_64`) -7. **bootloader** — install GRUB to EFI, `grub-mkconfig` with grub-btrfs hook -8. **shellprocess (post-install)** — runs `iso/post-install.sh`: - - Configures snapper root config - - Enables services: `NetworkManager`, `bluetooth`, `breadd` (user), `breadbox-sync` (user) - - Runs `bakery install bread breadbar breadbox breadcrumbs breadpad` (or `bakery install --all`) - - Copies `dotfiles/` into `/home/$USER/.config/` (skips any file that already exists) +7. **bootloader** — *planned*; actual GRUB install is in `post-install.sh` +8. **shellprocess (post-install)** — snapper, services, copy skel; does **not** run bakery 9. **finished** — reboot prompt --- -## Component 3: `bos-settings` GTK4 App +## Component 3: `bos-settings` (planned as GTK4) + +### Tech choices (original) -### Tech choices - **gtk4-rs** (v0.11, v4_12 feature), no relm4 — plain GTK4 following breadman's pattern -- **bread-theme** for palette + CSS (git dep: `github.com/Breadway/bread-ecosystem`) -- Reads/writes each tool's own config file directly (no unified intermediate config) -- Window: 960×640, sidebar 190px, `gtk4::Stack` for view switching — identical structure to breadman + +**What shipped:** Tauri 2 + Svelte in its own repo +(`git.breadway.dev/Breadway/bos-settings`), distributed by bakery. This +repo does not build it. ### Sidebar sections + views -| Section | View | What it does | -|---------|------|--------------| -| **Apps** | bread | Edit `~/.config/bread/breadd.toml` | -| | breadbar | Edit `~/.config/breadbar/` (style.css override, no TOML needed) | -| | breadbox | Edit `~/.config/breadbox/config.toml` (context priority lists) | -| | breadcrumbs | Edit `~/.config/breadcrumbs/breadcrumbs.toml` (profiles, networks) | -| | breadpad | Edit `~/.config/breadpad/breadpad.toml` (model, reminders, calendar) | -| **System** | Snapshots | `snapper list` output; rollback button calls `snapper rollback N` | -| | Packages | `bakery list --installed`; update buttons call `bakery update ` | -| | Hyprland | "Open config in editor" + monitor list from `bread.state.monitors()` | - -### Config loading pattern - -Each view has a dedicated `load_config(path) -> Result` and `save_config(path, T) -> Result<()>` using `toml` crate. Config structs mirror each app's existing types (no duplication — import the `*-shared` crate where it exists, e.g. `breadpad-shared`). For apps without a shared crate (breadbox, breadcrumbs), define minimal local structs. - -### Snapshots view specifics - -- On open: runs `snapper list --output-cols number,date,description,pre-post` via `std::process::Command`, parses into table rows -- Rollback: confirmation dialog → `snapper rollback ` → notify user to reboot -- Delete: `snapper delete ` -- No write access to `/` needed for list/rollback since snapper is configured with `ALLOW_USERS` for the main user - -### Packages view specifics - -- On open: reads `~/.local/state/bakery/installed.json` directly (no network) -- "Check for updates": runs `bakery list` (triggers index refresh), compares versions -- "Update all": runs `bakery update --all` in a subprocess, streams stdout to a log TextView +The panel list is still roughly accurate; see README. Snapshots recovery +should send users through **grub-btrfs reboot**, not `snapper rollback N`. ### Distribution -`bos-settings` gets a `bakery.toml` and is added to the `bread-ecosystem` registry — installable standalone on any Arch/Hyprland system via `bakery install bos-settings`, not only as part of a BOS install. +`bos-settings` has its own `bakery.toml` and is installable via +`bakery install bos-settings` on any Arch/Hyprland system, not only as part +of a BOS install. --- ## Component 4: Default Dotfiles -Minimal but functional defaults deployed at install time. These are opinionated starting points, not locked configs — users edit freely after install. +Minimal but functional defaults. These live in `iso/airootfs/etc/skel` +(`hyprland.lua` + JSON binds, not `dotfiles/hyprland/*.conf`). -| File | Key content | -|------|-------------| -| `dotfiles/hyprland/hyprland.conf` | Monitor auto-detect, default keybinds, `exec-once` for breadd/breadbar/breadbox-sync | -| `dotfiles/hyprland/keybinds.conf` | `$mod+Space` → breadbox, `$mod+N` → breadpad, `$mod+M` → breadman, `$mod+S` → bos-settings | -| `dotfiles/bread/breadd.toml` | All adapters enabled, log_level=info | -| `dotfiles/bread/init.lua` | Minimal: activates "default" profile on startup | -| `dotfiles/breadbox/config.toml` | Single default context with common apps | -| `dotfiles/breadcrumbs/breadcrumbs.toml` | Placeholder home profile (user fills in SSIDs) | +Zero-config bakery apps survive with no extra skel files. breadcrumbs +networks are user-filled after install — do not invent a full +`breadcrumbs.toml` in-tree. --- ## Build Order -1. **Dotfiles** — write default configs; these unblock installer testing immediately -2. **Btrfs + snapper config** — write `post-install.sh`; test in a VM with `archiso` livecdbase -3. **ISO profile** — archiso profiledef + package list + Calamares YAML; iterate in a VM -4. **bos-settings** — start with Snapshots and Packages views (highest value, no app-specific config parsing needed), then add per-app views one at a time +Historical. The ISO profile + skel + Calamares path is what this repo +iterates on. bos-settings is developed in its own repo. --- ## Verification -- **ISO**: Build with `mkarchiso -v -w /tmp/bos-work -o /tmp/bos-out iso/`; boot in QEMU (`qemu-system-x86_64 -cdrom bos.iso -m 4G -enable-kvm`); complete install; reboot into installed system; confirm all services running and bakery packages present +- **ISO**: `sudo ./build-local.sh` (not a raw `mkarchiso iso/` — the bake + step is required). Boot in QEMU; complete install; confirm bakery bins and + `~/.local/share/breadhelp/content`. - **btrfs layout**: `btrfs subvolume list /` after install; confirm `@`, `@home`, `@snapshots`, `@log`, `@cache` exist - **snapper**: `snapper list`; run `pacman -Syu` and confirm two new snapshots appear - **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB -- **bos-settings**: `cargo build --release`; launch; confirm each view loads its config file; edit a value, save, re-open and confirm persistence; test rollback button in Snapshots view +- **bos-settings**: built and tested in the bos-settings repo, not here diff --git a/README.md b/README.md index 7eb401b..e7ff533 100644 --- a/README.md +++ b/README.md @@ -5,36 +5,40 @@ ecosystem](https://git.breadway.dev/Breadway) preconfigured. One Calamares insta produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no wiring up dotfiles, no per-tool bakery installs. -> Design rationale and the btrfs/A-B roadmap live in [DESIGN.md](DESIGN.md). -> This file is the practical overview: what's in the image, how to build it, -> and how to test it. +> This file is the product as the tree ships it. [DESIGN.md](DESIGN.md) is the +> original plan, kept as history — several of its sections (in-tree GTK +> bos-settings, bakery-at-post-install, A/B as if it were current) are not +> how the ISO works today. ## What you get - **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated 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, - `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), and the `bakery` package manager. `breadlock` - (lock screen + greeter) ships as its own pacman package alongside - `bos-settings`, not through bakery. See [below](#the-bread-ecosystem) for - what each one actually does. -- **bos-settings**: a GTK4 control panel that configures every bread\* app's - config from a GUI (non-destructively), plus snapshot rollback and bakery - updates. See below. -- **Login**: greetd + breadgreet (bread-ecosystem's own greeter, under `cage`) - → Hyprland session. + (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). +- **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 + product, not a member of this repo). Configures every bread\* app + non-destructively, plus snapshots, bakery/pacman updates, and day-to-day + machine administration. +- **Login**: greetd + breadgreet (under `cage`) → Hyprland session. - **Boot splash**: Plymouth `bos` theme (logo + spinner, black background). - **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita (`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark icons; Bibata cursor. - **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor, gnome-calculator, file-roller, with file associations wired in `mimeapps.list`. - `yay` ships for AUR access beyond bakery's bread ecosystem + `[breadway]`. + `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. - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every @@ -44,35 +48,69 @@ wiring up dotfiles, no per-tool bakery installs. install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled automatically at install time when the firmware is 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/`) | +| **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. + ## Repo layout +This is an **ISO + Calamares + skel** repo. There is no Cargo workspace and +no `bos-settings/` member — bos-settings and breadhelp live in their own +repos and arrive via bakery. + ``` bos/ -├── Cargo.toml # workspace (members: bos-settings) -├── bos-settings/ # GTK4 unified settings app (Rust) -│ └── src/ -│ ├── config/mod.rs # non-destructive toml_edit config layer -│ └── ui/{widgets,window,sidebar}.rs, ui/views/*.rs ├── iso/ # archiso profile +│ ├── bread-lockfile.toml # bakery bins that MUST be baked │ ├── profiledef.sh -│ ├── packages.x86_64 # live + installed package set +│ ├── packages.x86_64 # live + installed pacman set │ └── airootfs/ # files overlaid onto the image │ └── etc/ -│ ├── skel/ # default user dotfiles (hypr, kitty, gtk, …) +│ ├── skel/ # live user defaults (hypr, kitty, gtk, …) │ └── calamares/ # installer config + post-install.sh ├── packaging/ # in-house PKGBUILDs for AUR-only deps -│ ├── arch/ # bos-settings │ ├── calamares/ -│ └── bibata/ -├── .forgejo/workflows/ # CI: build + publish packages to [breadway] +│ ├── bibata/ +│ ├── powerlevel10k/ +│ └── yay-bin/ +├── dotfiles/ # STALE — not the live skel; see its README +├── scripts/smoke-test.sh +├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release ├── build-local.sh # native ISO build for this machine -└── DESIGN.md +├── README.md +└── DESIGN.md # historical plan ``` +Live binds are `iso/airootfs/etc/skel/.config/hypr/binds.json` (`Super+L` → +`loginctl lock-session`, breadshot on `Super+Shift+S/C/P`, `Super+U` +breadpad). Do not treat `dotfiles/hypr/keybinds.conf` as current. + +## Branches and remotes + +Single-trunk: work on **`main`** via short-lived `feature/*` / `fix/*` +branches. **`stable`** is a marker branch CI fast-forwards to the latest +non-RC release tag — do not land work there by hand. + +Dual remotes: + +- **`origin`** — Forgejo (`ssh://git@100.66.238.26:2222/Breadway/bos.git`), + authoritative +- **`github`** — GitHub (`https://github.com/Breadway/bos.git`) mirror + +Push `origin` (and `github` when mirroring). Do not treat origin as GitHub. + ## Building the ISO `build-local.sh` builds the image natively (no container) and bakes this -machine's bakery-installed bread binaries into `/etc/skel`: +machine's bakery-installed bread binaries + breadhelp content into +`/etc/skel`: ```sh sudo ./build-local.sh # release-quality (xz squashfs) @@ -80,16 +118,20 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs) ``` The ISO lands in `out/bos--x86_64.iso`. The script pins -`SOURCE_DATE_EPOCH` (reproducible UUIDs) and rewrites the `[breadway]` repo URL -to the Tailscale-reachable Forgejo registry for the build. +`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. + +CI should populate the builder from the **stable** bakery index; local +builds still snapshot the builder. The lockfile is names only. ### Why some packages are in-house `calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are -AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes the -built package to the `[breadway]` repo via a Forgejo Actions workflow (built -on the hestia self-hosted runner, published with a scoped registry token). -`bos-settings` itself publishes the same way on a `v*` tag. +AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes +the built package to the `[breadway]` repo via a Forgejo Actions workflow +(built on the hestia self-hosted runner, published with a scoped registry +token). `[breadway]` is **not** where bakery/breadbar/bos-settings live. ### Verifying a release @@ -124,19 +166,21 @@ It uses KVM + `-cpu host`, 8 GiB / 8 vCPU, and `virtio-vga-gl` with Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to avoid memory pressure. +Post-install, `scripts/smoke-test.sh` (run as the installed user) checks +subvolumes, services, bakery bins, and breadhelp content under +`~/.local/share/breadhelp/content`. + ## bos-settings -A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing -config files, but live system state and control, so day-to-day machine -administration doesn't require a terminal. +Standalone bakery product: **Tauri 2 + Svelte**, not GTK4, and not built +from this repo. Install/update with `bakery`; the ISO just bakes whatever +binary the builder has. -Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit` -parses the file, changes only the keys a view exposes, and writes it back — -preserving comments and any keys the UI doesn't model (calendar passwords, -saved-network passwords, model paths). Panels with a daemon behind them -(bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live -systemd status + Start/Stop/Restart/Logs via a shared `service_control` -widget, not just the config file. +It aims for GNOME-Settings-style parity: live system state and control, so +day-to-day administration doesn't require a terminal. Bread-ecosystem +configs are edited **non-destructively** (comments and unmodeled keys stay). +Panels with a daemon (bread, breadbox, breadcrumbs, breadsearch, breadclip) +also get live systemd status + Start/Stop/Restart/Logs. | Panel | What it does | |-------|--------------| @@ -161,12 +205,8 @@ widget, not just the config file. | Firmware | `fwupd` device list + updates | | Snapshots | `snapper` list / boot-into (grub-btrfs) / delete | -Build standalone: - -```sh -cargo build --release -p bos-settings -cargo test -p bos-settings # includes config round-trip tests -``` +Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) +repo, not here. ## The bread ecosystem @@ -174,17 +214,17 @@ Everything below is a separate bakery-distributed project with its own repo and release cadence, baked into `/etc/skel` at ISO build time so a fresh install has them all with no network round-trip. Some ship more than one binary from a single package — that's noted where it applies. Most have a -corresponding **bos-settings** panel for configuration; this table is about -*using* the app directly. +corresponding **bos-settings** panel; this table is about *using* the app +directly. **Desktop shell** | Tool | Role | Launch | |------|------|--------| -| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). Everything else in the ecosystem can subscribe to its events. | 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. | 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 | `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 | | `bread-theme` | The shared palette engine every bread app renders through: fixed dark base colors, with only the accent slots following the current wallpaper's pywal palette. `bread-theme generate` regenerates the stylesheet; hyprland.lua calls it automatically on wallpaper change. | invoked automatically, rarely run by hand | **Productivity** @@ -195,6 +235,7 @@ corresponding **bos-settings** panel for configuration; this table is about | `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` | | `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` | | `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search | +| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `~/.local/share/breadhelp/content` (bakery `content.tar.gz`, baked into skel). | `SUPER+/` | **System** @@ -210,7 +251,7 @@ corresponding **bos-settings** panel for configuration; this table is about | Tool | Role | Launch | |------|------|--------| | `bakery` | CLI package manager for the whole ecosystem — install/update/list, tracks installed binaries + versions independently of pacman | `bakery` | -| `bos-settings` | Unified GTK4 control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` | +| `bos-settings` | Unified Tauri 2 + Svelte control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` | ## Keyboard shortcuts @@ -274,9 +315,16 @@ cheatsheet in-session; first boot shows a short welcome (once). ## Recovery **An update broke something (system still boots):** open BOS Settings → -Snapshots and roll back, or pick a pre-update snapshot from the **GRUB -“snapshots” submenu** at boot, then run `snapper rollback` from the booted -snapshot. +Snapshots and pick a snapshot to boot, **or** choose one from the **GRUB +“snapshots” submenu** (grub-btrfs) at boot, then reboot into it. + +Do **not** run `snapper rollback` as the default recovery step. BOS GRUB +pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not +what the installed grub.cfg will boot next. Use the grub-btrfs entry so the +kernel command line matches the snapshot you want. + +A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is +not shipped. **The system won't boot (broken GRUB / lost EFI entry):** diff --git a/build-local.sh b/build-local.sh index ebaac0a..902c470 100755 --- a/build-local.sh +++ b/build-local.sh @@ -41,66 +41,210 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then fi grep airootfs_image_tool_options "$STAGE/profiledef.sh" -# --- Bake this laptop's bakery-installed bread ecosystem into /etc/skel ------- -# The bread apps are managed by bakery (which fetches release binaries from -# GitHub), not pacman. bakery needs DNS at install time, which the live/installed -# image doesn't have — so instead of running bakery on the target, we copy the -# exact binaries + bakery manifest this laptop already has into skel. Every user -# created from skel (the live user and the installed user) then gets the same -# versions `bakery list` reports here, fully offline. Copied at build time so the -# binaries never bloat the git repo and always track the current bakery state. -BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot bos-settings breadhelp) +# --- Bake this machine's bakery-installed bread ecosystem into /etc/skel ------ +# The bread desktop apps are bakery-managed (release binaries from +# dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time, +# which the live/installed image doesn't have — so instead of running bakery +# on the target, we copy the binaries + bakery manifest this builder already +# has into skel. Every user created from skel then gets those versions fully +# 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). +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' +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) +PY +) +if [[ ${#BREAD_BINS[@]} -eq 0 ]]; then + echo "ERROR: $LOCKFILE produced an empty bins list" >&2 + exit 1 +fi + LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}" BAKERY_BIN="$LAPTOP_HOME/.local/bin" BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery" 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)" + +missing=() +for b in "${BREAD_BINS[@]}"; do + if [[ ! -x "$BAKERY_BIN/$b" ]]; then + missing+=("$BAKERY_BIN/$b") + fi +done +if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: bakery lockfile requires binaries that are missing on the builder:" >&2 + printf ' %s\n' "${missing[@]}" >&2 + echo "Install them with bakery (or stage them under $BAKERY_BIN) before baking." >&2 + echo "A hollow ISO is worse than a failed build." >&2 + exit 1 +fi + 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" done -install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json" + +# Drop packages that are not in the lockfile (breadcast/breadarr must not +# appear installed when their binaries were deliberately left out). +python3 - "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json" "${BREAD_BINS[@]}" <<'PY' +import json, sys +src, dest, *bins = sys.argv[1:] +wanted = set(bins) +with open(src) as f: + data = json.load(f) +pkgs = data.get("packages", data) +if not isinstance(pkgs, dict): + sys.exit(f"{src}: expected packages object") +kept = {} +for name, pkg in pkgs.items(): + pbins = pkg.get("binaries") or [] + if name in wanted or any(b in wanted for b in pbins): + kept[name] = pkg +out = {"packages": kept} +if "track" in data: + out["track"] = data["track"] +with open(dest, "w") as f: + json.dump(out, f, indent=2) + f.write("\n") +print("installed.json packages:", ", ".join(sorted(kept)) or "(none)") +PY + # bakery fetches its package index from dl.breadway.dev (then a GitHub fallback), # but falls back to a cached index when both are unreachable. With no network/DNS # in the live/installed image, even `bakery list` errors unless that cache exists, # so bake it in too — then bakery works fully offline (list/info from cache; # install/update still need network, as expected). +if [[ ! -f "$BAKERY_CACHE/index.json" ]]; then + echo "ERROR: bakery index cache missing: $BAKERY_CACHE/index.json" >&2 + exit 1 +fi install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json" -echo "baked: $(ls "$SKEL/.local/bin")" +echo "baked bins: $(ls "$SKEL/.local/bin")" + +# --- Bake bakery data dirs the apps need offline ------------------------------ +# bakery extracts data_archive (breadhelp's content.tar.gz) to +# ~/.local/share// and writes desktop entries + licenses next to it. +# Copy those — never laptop-local state (clipboard history, WebKit cache, +# bread sync-repo, models). +echo "=== baking bakery share/data into skel ===" +BREADHELP_CONTENT="$BAKERY_SHARE/breadhelp/content" +if [[ ! -d "$BREADHELP_CONTENT" ]]; then + echo "ERROR: breadhelp content missing: $BREADHELP_CONTENT" >&2 + echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content" >&2 + echo "A breadhelp binary without content is a hollow ISO." >&2 + exit 1 +fi +install -d -m 0755 "$SKEL/.local/share" +cp -a "$BAKERY_SHARE/breadhelp" "$SKEL/.local/share/breadhelp" +echo " baked $SKEL/.local/share/breadhelp/content" + +python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$SKEL/.local/share" "${BREAD_BINS[@]}" <<'PY' +import json, os, shutil, sys +index_path, src_share, dest_share, *bins = sys.argv[1:] +wanted = set(bins) +try: + with open(index_path) as f: + idx = json.load(f) + packages = idx.get("packages", {}) +except (OSError, json.JSONDecodeError): + packages = {} + +# Package names we ship: lockfile bin names plus index packages that +# publish at least one of those bins. +pkg_names = set(wanted) +for name, pkg in packages.items(): + pbins = [] + for b in pkg.get("binaries") or []: + n = b["name"] if isinstance(b, dict) else b + pbins.append(str(n).removesuffix("-x86_64")) + if name in wanted or any(b in wanted for b in pbins): + pkg_names.add(name) + +os.makedirs(os.path.join(dest_share, "applications"), exist_ok=True) +os.makedirs(os.path.join(dest_share, "licenses"), exist_ok=True) + +for name in sorted(pkg_names): + pkg = packages.get(name) or {} + if pkg.get("data_archive"): + src = os.path.join(src_share, name) + dest = os.path.join(dest_share, name) + if name == "breadhelp": + continue # already copied above, required + if os.path.isdir(src): + if os.path.exists(dest): + shutil.rmtree(dest) + shutil.copytree(src, dest, symlinks=True) + print(f" baked data dir {dest}") + else: + sys.exit(f"ERROR: bakery data_archive for {name} missing at {src}") + + desktop_src = os.path.join(src_share, "applications", f"{name}.desktop") + desktop_dest = os.path.join(dest_share, "applications", f"{name}.desktop") + if os.path.isfile(desktop_src) and not os.path.isfile(desktop_dest): + shutil.copy2(desktop_src, desktop_dest) + print(f" baked desktop {desktop_dest}") + + lic_src = os.path.join(src_share, "licenses", name) + lic_dest = os.path.join(dest_share, "licenses", name) + if os.path.isdir(lic_src) and not os.path.isdir(lic_dest): + shutil.copytree(lic_src, lic_dest, symlinks=True) + print(f" baked license {lic_dest}") +PY # --- Bake systemd user services for bakery-managed bread packages ----------- # Historically only breadd.service was hand-committed to skel; every other # bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was # silently left out, so those daemons never start on a fresh install/live # boot until the user re-runs `bakery install` (which needs network). -# Generalize from the same source of truth as the binary bake above: read -# the services this laptop's bakery actually installed, copy each unit file -# into skel with ExecStart rewritten from this laptop's literal home path to -# the portable `%h` specifier, and recreate whichever *.target.wants enable -# symlink bakery created locally. Units already committed by hand (breadd.service -# carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build -# notes) are left alone rather than overwritten. +# Source of truth is the *filtered* installed.json we just wrote: only +# lockfile packages. Copy each unit with ExecStart rewritten from this +# laptop's literal home path to the portable `%h` specifier, and recreate +# whichever *.target.wants enable symlink bakery created locally. Units +# already committed by hand (breadd.service carries a +# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are left alone. echo "=== baking bakery service units into skel ===" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user" -mapfile -t SERVICE_UNITS < <(python3 -c " -import json -with open('$BAKERY_STATE/installed.json') as f: +mapfile -t SERVICE_UNITS < <(python3 - "$SKEL/.local/state/bakery/installed.json" <<'PY' +import json, sys +with open(sys.argv[1]) as f: d = json.load(f) -for pkg in d.get('packages', d).values(): - for s in pkg.get('services', []): - print(s) -") +for pkg in d.get("packages", d).values(): + for s in pkg.get("services", []): + print(s["unit"] if isinstance(s, dict) else s) +PY +) for unit in "${SERVICE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then echo " $unit already committed in skel, leaving as-is" continue fi src="$SYSTEMD_USER_DIR/$unit" if [[ ! -f "$src" ]]; then - echo " warning: $unit not found at $src, skipping" - continue + echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 + echo "Refusing to bake a skel whose daemons will never start." >&2 + exit 1 fi install -d -m 0755 "$SKEL_SYSTEMD" sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit" diff --git a/dotfiles/README.md b/dotfiles/README.md new file mode 100644 index 0000000..8ad5099 --- /dev/null +++ b/dotfiles/README.md @@ -0,0 +1,9 @@ +# `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. + +Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel) +(`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot, +breadpad, …). Edit that tree. diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index c2e059f..4d61cfe 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -374,9 +374,8 @@ fi # The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs, # breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: the # binaries and bakery manifest live in /etc/skel/.local (baked in at ISO -# build time via build-local.sh's BREAD_BINS array) and are copied into the -# user's home below, so the install works fully offline with no DNS for -# bakery/GitHub. +# build time from iso/bread-lockfile.toml) and are copied into the user's +# home below, so the install works fully offline with no DNS for bakery. # --------------------------------------------------------------------------- # Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 20c5242..59992fb 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -26,12 +26,13 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist # ----------------------------------------------------------------------- -# Breadway custom repo — provides: bakery and the bread ecosystem packages -# (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings). -# (calamares comes from the official extra repo, not here.) +# Breadway custom repo — breadlock plus AUR republishes the ISO needs +# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, +# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp +# are NOT here; they are bakery-baked into /etc/skel at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the -# .forgejo/workflows/package.yml workflow in each repo, on tag push. +# .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 diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml new file mode 100644 index 0000000..2ea64a2 --- /dev/null +++ b/iso/bread-lockfile.toml @@ -0,0 +1,36 @@ +# Bakery binaries that MUST be 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. +# +# CI should populate the builder from the 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. +# +# Not shipped (even if present on the builder): breadcast, breadarr. +# breadlock is pacman (see packages.x86_64), not bakery. + +bins = [ + "bakery", + "bread", + "breadd", + "bread-emit", + "bread-module-host", + "breadman", + "breadbar", + "breadbox", + "breadbox-sync", + "breadcrumbs", + "breadpad", + "breadpaper", + "bread-theme", + "breadmon", + "breadsearch", + "breadmill", + "breadclip", + "breadclipd", + "breadshot", + "bos-settings", + "breadhelp", +] diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index c8cbb57..ad76c71 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -99,12 +99,17 @@ bluez-utils # blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only). blueman -# GTK4 runtime +# GTK4 runtime (breadbar, breadbox, breadclip, breadhelp, and other bakery apps) gtk4 gtk4-layer-shell librsvg libpulse hicolor-icon-theme +# Tauri 2 runtime for bakery-baked bos-settings. Arch's WebKitGTK 4.1 package +# is webkit2gtk-4.1 (libwebkit2gtk-4.1.so); libsoup3 and JavaScriptCore 4.1 +# are pulled in as its dependencies. xdg-desktop-portal comes from the +# Hyprland/GTK portal packages listed above. +webkit2gtk-4.1 # GTK3 dark theme (Adwaita-dark); without this package the gtk-theme-name in # skel settings.ini silently falls back to the light theme for GTK3 apps. gnome-themes-extra @@ -179,19 +184,16 @@ yay-bin # Bread ecosystem. # -# None of the bread apps (bakery, bread, breadbar, breadbox, breadcrumbs, -# breadpad, bos-settings, breadhelp, ...) are pacman packages here anymore — -# they are all bakery-managed binaries baked into /etc/skel/.local/bin at -# build time (see build-local.sh's BREAD_BINS array), so every user gets the -# exact versions from this laptop's bakery install with no network/DNS -# needed at install or runtime. bos-settings/breadhelp's desktop entries are -# hand-committed static files at -# iso/airootfs/etc/skel/.local/share/applications/, matching the pattern -# already used for breadclip/breadman/breadmon/breadsearch. Their runtime -# system deps are pulled in elsewhere in this list (gtk4, gtk4-layer-shell, -# iw, libpulse, librsvg, networkmanager, openssl, zlib, systemd-libs, -# hicolor-icon-theme) — keep those even though no bread package depends on -# them via pacman. +# breadlock is the only bread* pacman package here (it needs a root-owned +# /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/ +# bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, +# bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings, +# breadhelp — is bakery-managed and baked into /etc/skel/.local at ISO build +# time from iso/bread-lockfile.toml (see build-local.sh). breadcast and +# breadarr are not shipped. bos-settings/breadhelp desktop entries are +# committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime +# deps stay listed even though no bread package depends on them via pacman +# (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …). # Input / screen utilities brightnessctl @@ -324,6 +326,3 @@ qt6ct # hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland. qt5-wayland qt6-wayland - -# Dev tools (for bos-settings standalone install) -rustup diff --git a/iso/pacman.conf b/iso/pacman.conf index 20c5242..59992fb 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -26,12 +26,13 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist # ----------------------------------------------------------------------- -# Breadway custom repo — provides: bakery and the bread ecosystem packages -# (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings). -# (calamares comes from the official extra repo, not here.) +# Breadway custom repo — breadlock plus AUR republishes the ISO needs +# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, +# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp +# are NOT here; they are bakery-baked into /etc/skel at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the -# .forgejo/workflows/package.yml workflow in each repo, on tag push. +# .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 diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 4c3df28..b6ef676 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -10,8 +10,8 @@ one publishes on a push to `packaging//**`. Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot, bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see -`build-local.sh`'s `BREAD_BINS` array, which bakes this laptop's -bakery-installed binaries into the ISO's `/etc/skel` at build time. +`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`. `breadlock` is the sole deliberate exception (it needs a root-owned `/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no privileged-install path for) and stays on pacman only; see diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 6672447..2d5264e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -46,7 +46,7 @@ done check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" echo "== bread ecosystem on PATH ==" -for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do +for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do check "$bin found" "command -v $bin" done @@ -55,7 +55,8 @@ check "bos-settings installed" "command -v bos-settings" echo "== breadhelp ==" check "breadhelp installed" "command -v breadhelp" -check "breadhelp content installed" "[ -d /usr/share/breadhelp/content ]" +check "breadhelp content installed" \ + "[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]" check "bos-netcheck present" "command -v bos-netcheck" echo "== default dotfiles ==" From 3ab97c163487d68c942ce22da3aa2954cf149885 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 04/30] Track AGENTS.md (single-trunk ISO repo notes) --- .gitignore | 1 - AGENTS.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 5032061..669daaa 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,3 @@ logs/ /Bread Background.png # Local hygiene notes (not for commit) -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3a9f4bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. Product shape is [README.md](README.md). + +## What this repo is + +ISO + Calamares + skel. **No Cargo workspace. No `bos-settings/` member.** +bos-settings (Tauri 2 + Svelte) and breadhelp are standalone bakery +products. breadlock is the only bread\* pacman package. + +Live skel is `iso/airootfs/etc/skel`. `dotfiles/` is stale. + +## Branch model + +Single-trunk: + +- `main` — integration + release trunk. Land work via short-lived + `feature/*` / `fix/*` branches, then merge. +- `stable` — marker only. CI fast-forwards it to the latest non-RC release + tag. Never merge into it by hand. + +There is no `dev` integration branch. + +## Remotes + +- `origin` — Forgejo (`ssh://git@100.66.238.26:2222/Breadway/bos.git`) — authoritative. +- `github` — GitHub (`https://github.com/Breadway/bos.git`) mirror. Push + origin (and github when mirroring). + +`origin` is **not** GitHub. + +## CI + +- `.forgejo/workflows/*.yml` trigger on `push: tags: ['v*']` (or + path-scoped packaging triggers) — ordinary pushes to `main` run nothing + except those path filters. Tag a release to build the ISO. +- `stable` is moved by the release-iso workflow, not by humans. +- No build/lint/test CI runs on ordinary commits or PRs — test locally + before merging to `main`. + +## Cleanup + +- Delete feature/fix branches (local + remote) once merged. Check with + `git branch --merged main`. +- Don't let merged branches accumulate. + +## Don't + +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't leave the default branch pointed at a feature branch on + GitHub/Forgejo. +- Don't bake an ISO (`sudo ./build-local.sh`) unless asked — lockfile/docs + work does not require it. +- Don't tell users to `snapper rollback` blindly; GRUB pins + `rootflags=subvol=@`. Recovery is grub-btrfs reboot. From a3ead6607a3fb8d3949281b72b86d6b40a199dc4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:20:29 +0800 Subject: [PATCH 05/30] 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. --- .forgejo/workflows/release-iso.yml | 105 ++---- .gitignore | 5 + DESIGN.md | 2 + README.md | 58 +-- build-local.sh | 55 ++- dotfiles/README.md | 10 +- dotfiles/hypr/keybinds.conf | 4 + iso/airootfs/etc/calamares/post-install.sh | 7 +- iso/airootfs/etc/pacman.conf | 11 +- iso/airootfs/usr/local/bin/bos-update | 16 +- iso/bread-lockfile.toml | 28 +- iso/pacman.conf | 11 +- packaging/arch/README.md | 5 +- scripts/ci-stage-bakery.py | 387 +++++++++++++++++++++ scripts/ci-verify-bake.sh | 128 +++++++ scripts/smoke-test.sh | 9 +- 16 files changed, 687 insertions(+), 154 deletions(-) create mode 100755 scripts/ci-stage-bakery.py create mode 100755 scripts/ci-verify-bake.sh diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index 241197d..7191c22 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -1,19 +1,21 @@ name: Build and release ISO -# Builds the BOS ISO on the hestia self-hosted runner (native Arch container), -# downloads all bakery ecosystem binaries from their GitHub releases, compiles -# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release. -# A matching GitHub release is created that points to Forgejo for the download +# Builds the BOS ISO on the hestia self-hosted runner (native Arch container). +# Stages bakery desktop apps from the *minisign-verified* stable index at +# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs +# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub +# release is created best-effort and points at Forgejo for the download # (GitHub releases cannot host files larger than 2 GB). # # Required secrets: # RELEASE_TOKEN — Forgejo API token with write:repository scope -# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# MIRROR_TOKEN — GitHub personal access token with repo scope # GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" # identity (releases@breadway.dev); public half is committed -# at KEYS.asc for verification. No passphrase (CI-only key, -# access controlled via the Forgejo secret store, not a -# passphrase nobody could type non-interactively anyway). +# at KEYS.asc for verifying ISO SHA256SUMS only. That key +# does not sign the [breadway] pacman repo. No passphrase +# (CI-only key, access controlled via the Forgejo secret +# store). on: push: @@ -28,6 +30,8 @@ jobs: release-iso: runs-on: [self-hosted, hestia] container: + # Floating tag: this environment cannot pin a reproducible digest of + # archlinux:latest. Do not invent one. image: archlinux:latest # --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod # --network=host: gives localhost:3002 access to Forgejo (avoids the @@ -37,7 +41,7 @@ jobs: steps: - name: Install build dependencies run: | - pacman -Syu --noconfirm archiso curl python git rust + pacman -Syu --noconfirm archiso curl python git minisign - name: Determine tag and version id: vars @@ -55,85 +59,17 @@ jobs: git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos - - name: Download bakery ecosystem binaries + - name: Stage bakery ecosystem from signed stable index run: | set -euo pipefail - mkdir -p /build-home/.local/bin \ - /build-home/.local/state/bakery \ - /build-home/.cache/bakery + cd /bos + LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py - # Fetch the canonical bakery index - curl -fsSL "https://dl.breadway.dev/index.json" \ - -o /build-home/.cache/bakery/index.json - - # Download each binary from dl.breadway.dev (canonical source; github_url - # is not always published for dev/patch releases) and generate the - # installed.json that bakery expects in ~/.local/state. - python3 << 'PYEOF' - import json, urllib.request, os - - with open('/build-home/.cache/bakery/index.json') as f: - idx = json.load(f) - - BIN_DIR = '/build-home/.local/bin' - installed = {} - - for pkg_name, pkg in idx['packages'].items(): - bins = [] - for b in pkg['binaries']: - dest_name = b['name'].removesuffix('-x86_64') - dest = os.path.join(BIN_DIR, dest_name) - url = b['dl_url'] - print(f' {dest_name} <- {url}', flush=True) - urllib.request.urlretrieve(url, dest) - os.chmod(dest, 0o755) - bins.append(dest_name) - - # installed.json services field is a flat list of unit-name strings - services = [ - (s['unit'] if isinstance(s, dict) else s) - for s in pkg.get('services', []) - ] - installed[pkg_name] = { - 'name': pkg_name, - 'version': pkg['version'], - 'binaries': bins, - 'services': services, - 'installed_at': '2024-01-01T00:00:00+00:00', - } - - with open('/build-home/.local/state/bakery/installed.json', 'w') as f: - json.dump({'packages': installed}, f, indent=2) - print('installed.json written', flush=True) - PYEOF - - - name: Build bread-theme from source + - name: Verify staged bakery bake inputs run: | set -euo pipefail - # bread-theme is not in the bakery index; build it at the ref pinned - # in bos-settings' Cargo.toml so the CLI matches the library version - # the bos-settings package (and breadbar/breadbox/breadpad) were - # built against. bos-settings used to live at bos-settings/Cargo.toml - # inside this repo; it's since been split into its own repo - # (git.breadway.dev/Breadway/bos-settings) and migrated to Tauri, - # which moved the Rust manifest to bos-settings/src/Cargo.toml, so - # fetch it from there. Uses bos-settings' default branch (dev) — the - # branch its own CI actually publishes the `bos-settings` pacman - # package from. - REPO_OWNER="${GITHUB_REPOSITORY%%/*}" - curl -fsSL "https://git.breadway.dev/${REPO_OWNER}/bos-settings/raw/branch/dev/src/Cargo.toml" \ - -o /tmp/bos-settings-Cargo.toml - # bread-theme is pinned by tag once a release ships the functions - # bos-settings needs, or by branch in the meantime — handle either. - THEME_REF=$(grep '^bread-theme' /tmp/bos-settings-Cargo.toml \ - | grep -oP '(tag|branch)\s*=\s*"\K[^"]+') - echo "Building bread-theme @ $THEME_REF" - git clone --branch "$THEME_REF" --depth 1 \ - https://github.com/Breadway/bread-ecosystem /bread-ecosystem - cd /bread-ecosystem - cargo build --release -p bread-theme - install -m 755 target/release/bread-theme /build-home/.local/bin/bread-theme - echo "bread-theme built OK" + cd /bos + LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh - name: Build ISO run: | @@ -247,9 +183,8 @@ jobs: gh release create "${TAG}" \ --repo "Breadway/bos" \ --title "BOS ${TAG}" \ - \ --notes-file /tmp/gh-release-notes.md \ - 2>/dev/null || echo "GitHub release already exists — skipping" + || echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)" # `stable` is a marker branch only — CI fast-forwards it to whatever # commit the latest real (non-RC) release tag points at. Never merged diff --git a/.gitignore b/.gitignore index 669daaa..6e38d88 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ logs/ /Bread Background.png # Local hygiene notes (not for commit) +CLAUDE.md + +# Python +__pycache__/ +*.pyc diff --git a/DESIGN.md b/DESIGN.md index 5828e33..31b22f8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -15,6 +15,8 @@ taken as current: | A/B root swapping | **Future.** Today: btrfs + snapper + **grub-btrfs**. GRUB pins `rootflags=subvol=@`, so `snapper rollback` is not the user-facing recovery path. | | Work on `dev`; origin = GitHub | Single-trunk `main`; `stable` is a CI marker. `origin` = Forgejo, `github` = GitHub. | | `[breadway]` provides bakery/breadbar/bos-settings | `[breadway]` is breadlock + AUR republishes. Desktop apps are bakery. **Not shipped:** breadcast, breadarr. | +| NVIDIA / A/B / Secure Boot / LUKS2 | NVIDIA proprietary is **unsupported**. A/B root swapping is **not implemented**. Secure Boot is **Setup Mode only** (self-signed `sbctl`). Disk encryption is **LUKS1** because GRUB cannot unlock LUKS2+Argon2id. | +| `SigLevel = Required` on `[breadway]` | **No.** Forgejo's Arch registry has no pacman-compatible db signatures. `SigLevel = Never` is TLS only; flipping Required without a signed db breaks installs. `KEYS.asc` signs ISO SHA256SUMS, not the pacman repo. | --- diff --git a/README.md b/README.md index e7ff533..6ad797b 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,16 @@ wiring up dotfiles, no per-tool bakery installs. keybinds, snappy animations, blur, and pywal-driven colours on a black base. - **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries (no network needed at install time): the `bread`/`breadd` automation daemon - plus `bread-emit` / `bread-module-host`, `breadbar` (status bar + - notifications), `breadbox` (launcher), `breadclip` (clipboard history), - `breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman` (notes), `breadpaper` - (wallpaper + theme), `breadsearch` (system search), `breadmon` (monitor - layout TUI), `breadshot` (screenshots), `bread-theme` (the shared palette - engine), `breadhelp` (onboarding + cheatsheet), `bos-settings` (control - panel), and the `bakery` package manager. Most of those apps are - zero-config on first boot; breadcrumbs networks are user-filled after - install. See [below](#the-bread-ecosystem). + (`bread-emit` / `bread-module-host` when the stable bread release publishes + them), `breadbar` (status bar + notifications), `breadbox` (launcher), + `breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles), + `breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme), + `breadsearch` (system search), `breadmon` (monitor layout TUI), + `breadshot` (screenshots), `bread-theme` (the shared palette engine), + `breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel), + and the `bakery` package manager. Most of those apps are zero-config on + first boot; breadcrumbs networks are user-filled after install. See + [below](#the-bread-ecosystem). - **breadlock** (lock screen + greeter) is the one bread\* app that ships as **pacman**, not bakery — it needs a root-owned PAM service. - **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery @@ -41,23 +42,28 @@ wiring up dotfiles, no per-tool bakery installs. `yay` ships for AUR access beyond bakery + `[breadway]`. - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing with avahi mDNS discovery, TLP power management, fwupd firmware updates. + Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is + unsupported out of the box (see Known limitations). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). -- **Security**: full-disk encryption (LUKS, via Calamares' built-in support — - cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted - install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled - automatically at install time when the firmware is in Setup Mode). + A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot, + not `snapper rollback` (GRUB pins `rootflags=subvol=@`). +- **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock + LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via + `sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the + firmware is already in Setup Mode. ## What ships vs what does not | Channel | What | |---------|------| -| **Bakery, baked into skel** | `bakery`, `bread` / `breadd` / `bread-emit` / `bread-module-host`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) | +| **Bakery, required** | `bakery`, `bread` / `breadd`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) | +| **Bakery, optional** | `bread-emit`, `bread-module-host` — baked when the verified stable index publishes them; skipped (not a failed bake) until bread ships them | | **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) | | **Not shipped** | `breadcast`, `breadarr` | The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml). -`build-local.sh` fails if any listed binary is missing on the builder. +`build-local.sh` fails if any **required** binary is missing on the builder. ## Repo layout @@ -68,7 +74,7 @@ repos and arrive via bakery. ``` bos/ ├── iso/ # archiso profile -│ ├── bread-lockfile.toml # bakery bins that MUST be baked +│ ├── bread-lockfile.toml # bakery bins (required + optional) │ ├── profiledef.sh │ ├── packages.x86_64 # live + installed pacman set │ └── airootfs/ # files overlaid onto the image @@ -81,7 +87,10 @@ bos/ │ ├── powerlevel10k/ │ └── yay-bin/ ├── dotfiles/ # STALE — not the live skel; see its README -├── scripts/smoke-test.sh +├── scripts/ +│ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME +│ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso +│ └── smoke-test.sh ├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release ├── build-local.sh # native ISO build for this machine ├── README.md @@ -120,10 +129,12 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs) The ISO lands in `out/bos--x86_64.iso`. The script pins `SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL to the Tailscale-reachable Forgejo registry for the build, and **exits -non-zero** if any lockfile binary (or breadhelp content) is missing. +non-zero** if any **required** lockfile binary (or breadhelp content) is +missing. Optional bins are skipped with a warning. -CI should populate the builder from the **stable** bakery index; local -builds still snapshot the builder. The lockfile is names only. +CI stages the builder from the **minisign-verified** stable bakery index +(`index.json` + `index.json.minisig`); local builds still snapshot the +builder. The lockfile is names only. ### Why some packages are in-house @@ -144,7 +155,10 @@ dedicated release-signing key (not reused from anything else): 5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2 ``` -The public half is committed at [`KEYS.asc`](KEYS.asc). To verify a download: +The public half is committed at [`KEYS.asc`](KEYS.asc). That key signs +**ISO checksums only** — it does not sign the `[breadway]` pacman repo +(Forgejo's Arch registry has no pacman-compatible db signatures; that +section stays `SigLevel = Never`). To verify a download: ```sh gpg --import KEYS.asc @@ -221,7 +235,7 @@ directly. | Tool | Role | Launch | |------|------|--------| -| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. | runs at login (`breadd.service`) | +| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. Both extra bins are **optional** on the ISO until a stable bread release publishes them. | runs at login (`breadd.service`) | | `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login | | `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` | | `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock. **pacman**, not bakery. | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot | diff --git a/build-local.sh b/build-local.sh index 902c470..b06f58d 100755 --- a/build-local.sh +++ b/build-local.sh @@ -50,29 +50,45 @@ grep airootfs_image_tool_options "$STAGE/profiledef.sh" # offline. Copied at build time so the binaries never bloat the git repo. # # CI should prefer the stable bakery index when populating the builder home. -# Local builds still snapshot the builder. The lockfile is the name list; -# missing bins fail the bake (a hollow ISO is worse than a failed build). +# Local builds still snapshot the builder. required_bins fail the bake if +# missing; optional_bins are skipped with a warning (a hollow ISO is worse +# than a failed build). A flat `bins` list is treated as all-required. LOCKFILE="$REPO/iso/bread-lockfile.toml" if [[ ! -f "$LOCKFILE" ]]; then echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2 exit 1 fi -mapfile -t BREAD_BINS < <(python3 - "$LOCKFILE" <<'PY' +eval "$(python3 - "$LOCKFILE" <<'PY' import sys, tomllib path = sys.argv[1] with open(path, "rb") as f: data = tomllib.load(f) -bins = data.get("bins") or data.get("binaries") -if not isinstance(bins, list) or not bins: - sys.exit(f"{path}: missing non-empty bins list") -for b in bins: - if not isinstance(b, str) or not b or "/" in b or b in (".", ".."): - sys.exit(f"{path}: invalid bin name {b!r}") - print(b) +required = data.get("required_bins") +optional = data.get("optional_bins") or [] +if required is None: + required = data.get("bins") or data.get("binaries") +if not isinstance(required, list) or not required: + sys.exit(f"{path}: missing non-empty required_bins (or bins) list") +if not isinstance(optional, list): + sys.exit(f"{path}: optional_bins must be a list") +blocked = {"breadcast", "breadarr"} +for label, names in (("required_bins", required), ("optional_bins", optional)): + for b in names: + if not isinstance(b, str) or not b or "/" in b or b in (".", ".."): + sys.exit(f"{path}: invalid {label} name {b!r}") + if b in blocked: + sys.exit(f"{path}: {b} is not shipped on the ISO") +def emit(name, values): + print(f"{name}=(") + for v in values: + print(f" {v!r}") + print(")") +emit("REQUIRED_BINS", required) +emit("OPTIONAL_BINS", optional) PY -) -if [[ ${#BREAD_BINS[@]} -eq 0 ]]; then - echo "ERROR: $LOCKFILE produced an empty bins list" >&2 +)" +if [[ ${#REQUIRED_BINS[@]} -eq 0 ]]; then + echo "ERROR: $LOCKFILE produced an empty required bins list" >&2 exit 1 fi @@ -83,10 +99,10 @@ BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery" BAKERY_SHARE="$LAPTOP_HOME/.local/share" SKEL="$STAGE/airootfs/etc/skel" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" -echo "lockfile: $LOCKFILE (${#BREAD_BINS[@]} bins)" +echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)" missing=() -for b in "${BREAD_BINS[@]}"; do +for b in "${REQUIRED_BINS[@]}"; do if [[ ! -x "$BAKERY_BIN/$b" ]]; then missing+=("$BAKERY_BIN/$b") fi @@ -99,6 +115,15 @@ if [[ ${#missing[@]} -gt 0 ]]; then exit 1 fi +BREAD_BINS=("${REQUIRED_BINS[@]}") +for b in "${OPTIONAL_BINS[@]}"; do + if [[ -x "$BAKERY_BIN/$b" ]]; then + BREAD_BINS+=("$b") + else + echo "WARN: optional lockfile bin missing, skipping: $BAKERY_BIN/$b" >&2 + fi +done + install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" for b in "${BREAD_BINS[@]}"; do install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b" diff --git a/dotfiles/README.md b/dotfiles/README.md index 8ad5099..8e61cc6 100644 --- a/dotfiles/README.md +++ b/dotfiles/README.md @@ -1,8 +1,12 @@ # `dotfiles/` is not the live skel -These files are a leftover from an earlier design (Hyprland `.conf` binds, -including grimblast). They are **not** copied into the ISO or the installed -system. +These files are a leftover from an earlier design (Hyprland `.conf` binds). +They are **not** copied into the ISO or the installed system. + +`dotfiles/hypr/keybinds.conf` still mentions `grimblast`. That is not the +screenshot tool BOS ships — live binds use **breadshot** +(`iso/airootfs/etc/skel/.config/hypr/binds.json`). Do not treat grimblast +as current. Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel) (`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot, diff --git a/dotfiles/hypr/keybinds.conf b/dotfiles/hypr/keybinds.conf index 7cf8cdd..a857962 100644 --- a/dotfiles/hypr/keybinds.conf +++ b/dotfiles/hypr/keybinds.conf @@ -1,3 +1,7 @@ +# STALE — not the live Hyprland binds. Not copied into the ISO. +# Screenshots are breadshot (see iso/airootfs/etc/skel/.config/hypr/binds.json), +# not grimblast. Do not copy from this file. + $mod = SUPER # App launchers diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 4d61cfe..66ba35b 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -41,8 +41,11 @@ passwd -l root || true # over to the target (unpackfs may skip it / perms differ), leaving the installed # system unable to verify package signatures — the first `pacman -Syu` then dies # with "keyring is not writable / required key missing". Initialise it here so a -# fresh install can update out of the box. archlinux-keyring is already present; -# [breadway] is SigLevel=Never so it needs no key. +# fresh install can update out of the box. archlinux-keyring is already present +# and is the only keyring populated — it verifies official Arch packages. +# [breadway] stays SigLevel=Never (Forgejo does not serve pacman-compatible +# db signatures). Do not import KEYS.asc here: that key signs ISO SHA256SUMS, +# not the pacman repo; treating it as a repo key would be a lie. # --------------------------------------------------------------------------- if command -v pacman-key &>/dev/null; then pacman-key --init || echo "WARN: pacman-key --init failed" diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 59992fb..7e15c7a 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). # -# Forgejo signs the repo db with a key pacman can't look up, so TrustAll -# fails. SigLevel = Never skips verification (acceptable for this private -# repo over TLS). Future improvement: import Forgejo's signing key and -# switch to SigLevel = Required for full package verification. +# Forgejo's Arch package registry does not serve pacman-compatible db +# signatures. SigLevel = Never is TLS-only integrity: the connection is +# HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM) +# rides this repo. Do NOT flip to SigLevel = Required unless a signed db +# has been verified to work — Required without signatures breaks the ISO +# and every install that uses [breadway]. KEYS.asc is the ISO SHA256SUMS +# signing key, not a pacman repo key. # ----------------------------------------------------------------------- # The section name must match Forgejo's served db filename # ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 53ac3c1..aeb6fbd 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -2,13 +2,17 @@ # bos-update — update all of BOS in one go. # # BOS packages come from two channels, so a full update touches both: -# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.). -# Every transaction is snapshotted by snap-pac, so you can roll -# back from the GRUB "snapshots" submenu or BOS Settings. +# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR +# republishes: calamares, zen-browser-bin, bibata, yay-bin, +# powerlevel10k). [breadway] does NOT provide bos-settings +# or other bakery desktop apps. Every transaction is +# snapshotted by snap-pac; recover via the GRUB "snapshots" +# submenu (grub-btrfs), not `snapper rollback`. # 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list` -# reports as installed — bread, breadbar, breadbox, breadcrumbs, -# breadpad, breadman, bread-theme, breadpaper, breadmon, -# breadsearch, breadclip, breadshot, ...). +# reports as installed — bakery, bread, breadbar, breadbox, +# breadcrumbs, breadpad, breadman, bread-theme, breadpaper, +# breadmon, breadsearch, breadclip, breadshot, bos-settings, +# breadhelp, ...). # # Best-effort: a failure in one channel doesn't abort the other. set -uo pipefail diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index 2ea64a2..d704c33 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -1,22 +1,24 @@ -# Bakery binaries that MUST be baked into the live/installed skel. +# Bakery binaries baked into the live/installed skel. # -# build-local.sh derives BREAD_BINS from `bins` — this is the name list, not a -# second hardcoded array. A missing binary fails the bake: a hollow ISO is -# worse than a failed build. +# build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing +# *required* binary fails the bake: a hollow ISO is worse than a failed build. +# optional_bins are baked when the verified stable index publishes them, and +# skipped with a warning when it does not (today: bread 0.7.0 has no +# bread-emit / bread-module-host). # -# CI should populate the builder from the stable bakery index +# A flat `bins` list is still accepted and treated as required_bins. +# +# CI populates the builder from the minisign-verified stable bakery index # (https://dl.breadway.dev/index.json). Local builds still snapshot whatever -# is installed on the builder; this file only names what must be present. +# is installed on the builder; this file only names what must / may be present. # -# Not shipped (even if present on the builder): breadcast, breadarr. +# Not shipped (even if they appear in the index): breadcast, breadarr. # breadlock is pacman (see packages.x86_64), not bakery. -bins = [ +required_bins = [ "bakery", "bread", "breadd", - "bread-emit", - "bread-module-host", "breadman", "breadbar", "breadbox", @@ -34,3 +36,9 @@ bins = [ "bos-settings", "breadhelp", ] + +# Bake if the verified index publishes them; do not fail the ISO if absent. +optional_bins = [ + "bread-emit", + "bread-module-host", +] diff --git a/iso/pacman.conf b/iso/pacman.conf index 59992fb..7e15c7a 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). # -# Forgejo signs the repo db with a key pacman can't look up, so TrustAll -# fails. SigLevel = Never skips verification (acceptable for this private -# repo over TLS). Future improvement: import Forgejo's signing key and -# switch to SigLevel = Required for full package verification. +# Forgejo's Arch package registry does not serve pacman-compatible db +# signatures. SigLevel = Never is TLS-only integrity: the connection is +# HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM) +# rides this repo. Do NOT flip to SigLevel = Required unless a signed db +# has been verified to work — Required without signatures breaks the ISO +# and every install that uses [breadway]. KEYS.asc is the ISO SHA256SUMS +# signing key, not a pacman repo key. # ----------------------------------------------------------------------- # The section name must match Forgejo's served db filename # ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. diff --git a/packaging/arch/README.md b/packaging/arch/README.md index b6ef676..ce80f7e 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -10,8 +10,9 @@ one publishes on a push to `packaging//**`. Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot, bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see -`iso/bread-lockfile.toml`, which `build-local.sh` uses as the name list -when baking this machine's bakery install into the ISO's `/etc/skel`. +`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which +`build-local.sh` uses as the name list when baking this machine's bakery +install into the ISO's `/etc/skel`. `breadlock` is the sole deliberate exception (it needs a root-owned `/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no privileged-install path for) and stays on pacman only; see diff --git a/scripts/ci-stage-bakery.py b/scripts/ci-stage-bakery.py new file mode 100755 index 0000000..3545f6f --- /dev/null +++ b/scripts/ci-stage-bakery.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +"""Stage bakery artifacts from the verified stable index into $LAPTOP_HOME. + +Used by .forgejo/workflows/release-iso.yml so the ISO bake does not invent +binaries, fake installed.json, or cargo-build bread-theme. Never downloads +breadcast or breadarr. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urljoin, urlparse + +INDEX_URL = "https://dl.breadway.dev/index.json" +# Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs. +MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8" +BLOCKED = frozenset({"breadcast", "breadarr"}) +ARCH_SUFFIXES = ("-x86_64", "-aarch64", "-arm64", "-armv7") + + +def die(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def dest_name(name: str) -> str: + for suf in ARCH_SUFFIXES: + if name.endswith(suf): + return name[: -len(suf)] + return name + + +def valid_name(name: str) -> bool: + return bool(name) and "/" not in name and name not in (".", "..") + + +def load_lockfile(path: Path) -> tuple[list[str], list[str]]: + with path.open("rb") as f: + data = tomllib.load(f) + required = data.get("required_bins") + optional = data.get("optional_bins") or [] + if required is None: + required = data.get("bins") or data.get("binaries") + if not isinstance(required, list) or not required: + die(f"{path}: missing non-empty required_bins (or bins) list") + if not isinstance(optional, list): + die(f"{path}: optional_bins must be a list") + for label, names in (("required_bins", required), ("optional_bins", optional)): + for b in names: + if not isinstance(b, str) or not valid_name(b): + die(f"{path}: invalid {label} name {b!r}") + if b in BLOCKED: + die(f"{path}: {b} is not shipped on the ISO") + overlap = set(required) & set(optional) + if overlap: + die(f"{path}: bins in both required and optional: {sorted(overlap)}") + return list(required), list(optional) + + +def fetch(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + try: + urllib.request.urlretrieve(url, dest) + except (urllib.error.URLError, OSError) as e: + die(f"download failed: {url}: {e}") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def require_sha256(value: object, what: str) -> str: + if not isinstance(value, str) or not value.strip(): + die(f"{what}: index sha256 is required and must be non-empty") + return value.strip().lower() + + +def verify_sha256(path: Path, expected: str, what: str) -> None: + actual = sha256_file(path) + if actual != expected: + die(f"{what}: sha256 mismatch (expected {expected}, got {actual})") + + +def version_dir(first_dl_url: str) -> str: + parsed = urlparse(first_dl_url) + parent = parsed.path.rsplit("/", 1)[0] + return f"{parsed.scheme}://{parsed.netloc}{parent}/" + + +def verify_index(index_path: Path, sig_path: Path) -> None: + if shutil.which("minisign") is None: + die("minisign is not installed — refuse to trust an unsigned index") + cmd = [ + "minisign", + "-V", + "-q", + "-m", + str(index_path), + "-x", + str(sig_path), + "-P", + MINISIGN_PUBKEY, + ] + result = subprocess.run(cmd, check=False) + if result.returncode != 0: + die("index.json minisign verification FAILED — refusing to proceed") + print("index.json minisign OK") + + +def bin_index(packages: dict) -> dict[str, tuple[str, dict, dict]]: + out: dict[str, tuple[str, dict, dict]] = {} + for pkg_name, pkg in packages.items(): + if pkg_name in BLOCKED: + continue + for b in pkg.get("binaries") or []: + if not isinstance(b, dict): + continue + raw = b.get("name") + if not isinstance(raw, str): + continue + dest = dest_name(raw) + if dest in BLOCKED or pkg_name in BLOCKED: + continue + if dest in out and out[dest][0] != pkg_name: + die(f"index publishes {dest} from both {out[dest][0]} and {pkg_name}") + out[dest] = (pkg_name, pkg, b) + return out + + +def patch_exec_start(text: str, bin_dir: Path) -> str: + lines = [] + for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + rest = line.split("=", 1)[1] + argv = rest.split() + if argv: + name = os.path.basename(argv[0]) + new_path = bin_dir / name + if len(argv) == 1: + line = f"ExecStart={new_path}" + else: + line = f"ExecStart={new_path} {' '.join(argv[1:])}" + lines.append(line) + out = "\n".join(lines) + if text.endswith("\n"): + out += "\n" + return out + + +def wanted_by(text: str) -> list[str]: + targets: list[str] = [] + for line in text.splitlines(): + if line.startswith("WantedBy="): + targets.extend(line.split("=", 1)[1].split()) + return targets or ["default.target"] + + +def assert_safe_archive(path: Path) -> None: + with tarfile.open(path, "r:gz") as tf: + for info in tf.getmembers(): + name = info.name + if info.issym() or info.islnk(): + die(f"refusing archive with symlink entry {name!r}") + if name.startswith("/") or any(p in ("..", "") for p in Path(name).parts if p == ".."): + die(f"refusing archive with unsafe path {name!r}") + if Path(name).is_absolute() or ".." in Path(name).parts: + die(f"refusing archive with unsafe path {name!r}") + + +def stage_file(url: str, dest: Path, sha256: str, what: str, mode: int | None = None) -> None: + fetch(url, dest) + verify_sha256(dest, sha256, what) + if mode is not None: + dest.chmod(mode) + + +def main() -> int: + # CI logs mix stdout/stderr; keep them in source order. + try: + sys.stdout.reconfigure(line_buffering=True) + sys.stderr.reconfigure(line_buffering=True) + except (AttributeError, OSError): + pass + repo = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--home", + default=os.environ.get("LAPTOP_HOME", "/build-home"), + help="builder home to populate (default: $LAPTOP_HOME or /build-home)", + ) + parser.add_argument( + "--lockfile", + default=str(repo / "iso" / "bread-lockfile.toml"), + ) + parser.add_argument("--index-url", default=INDEX_URL) + args = parser.parse_args() + + home = Path(args.home) + lockfile = Path(args.lockfile) + if not lockfile.is_file(): + die(f"lockfile missing: {lockfile}") + + required, optional = load_lockfile(lockfile) + print(f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional") + + bin_dir = home / ".local" / "bin" + state_dir = home / ".local" / "state" / "bakery" + cache_dir = home / ".cache" / "bakery" + share_dir = home / ".local" / "share" + unit_dir = home / ".config" / "systemd" / "user" + for d in (bin_dir, state_dir, cache_dir, share_dir, unit_dir): + d.mkdir(parents=True, exist_ok=True) + + index_path = cache_dir / "index.json" + sig_path = cache_dir / "index.json.minisig" + print(f"fetch {args.index_url}") + fetch(args.index_url, index_path) + print(f"fetch {args.index_url}.minisig") + fetch(args.index_url + ".minisig", sig_path) + verify_index(index_path, sig_path) + + with index_path.open() as f: + idx = json.load(f) + packages = idx.get("packages") + if not isinstance(packages, dict): + die("index.json: missing packages object") + + published = bin_index(packages) + selected: dict[str, dict] = {} + installed_bins: dict[str, list[str]] = {} + installed_sha: dict[str, dict[str, str]] = {} + + def take_bin(name: str, *, required_bin: bool) -> bool: + hit = published.get(name) + if hit is None: + if required_bin: + die(f"required bin {name!r} is not in the verified stable index") + print(f"WARN: optional bin {name} not in index — skipping", file=sys.stderr) + return False + pkg_name, pkg, binary = hit + if pkg_name in BLOCKED or name in BLOCKED: + die(f"refusing blocked package/bin {pkg_name}/{name}") + url = binary.get("dl_url") + if not isinstance(url, str) or not url: + die(f"{name}: missing dl_url") + digest = require_sha256(binary.get("sha256"), f"binary {name}") + dest = bin_dir / name + print(f" {name} <- {url}") + stage_file(url, dest, digest, f"binary {name}", mode=0o755) + selected[pkg_name] = pkg + installed_bins.setdefault(pkg_name, []).append(name) + installed_sha.setdefault(pkg_name, {})[name] = digest + return True + + for name in required: + take_bin(name, required_bin=True) + for name in optional: + take_bin(name, required_bin=False) + + if not selected: + die("no packages selected from lockfile ∩ index") + + now = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + installed: dict[str, dict] = {} + + for pkg_name, pkg in sorted(selected.items()): + bins = pkg.get("binaries") or [] + first_url = None + for b in bins: + if isinstance(b, dict) and b.get("dl_url"): + first_url = b["dl_url"] + break + if not first_url: + die(f"{pkg_name}: no binary dl_url to derive version dir") + base = version_dir(first_url) + service_names: list[str] = [] + + for svc in pkg.get("services") or []: + if not isinstance(svc, dict): + die(f"{pkg_name}: service entry must be an object with unit + sha256") + unit = svc.get("unit") + if not isinstance(unit, str) or not valid_name(unit): + die(f"{pkg_name}: invalid service unit {unit!r}") + digest = require_sha256(svc.get("sha256"), f"{pkg_name} {unit}") + dest = unit_dir / unit + url = urljoin(base, unit) + print(f" {unit} <- {url}") + fetch(url, dest) + verify_sha256(dest, digest, f"unit {unit}") + dest.write_text(patch_exec_start(dest.read_text(), bin_dir)) + dest.chmod(0o644) + if svc.get("enable"): + for target in wanted_by(dest.read_text()): + if not valid_name(target): + die(f"{unit}: invalid WantedBy {target!r}") + wants = unit_dir / f"{target}.wants" + wants.mkdir(parents=True, exist_ok=True) + link = wants / unit + if link.exists() or link.is_symlink(): + link.unlink() + link.symlink_to(Path("..") / unit) + print(f" enabled {target}.wants/{unit}") + service_names.append(unit) + + archive = pkg.get("data_archive") + if archive: + if not isinstance(archive, str) or not valid_name(archive): + die(f"{pkg_name}: invalid data_archive {archive!r}") + digest = require_sha256(pkg.get("data_archive_sha256"), f"{pkg_name} {archive}") + url = urljoin(base, archive) + data_dir = share_dir / pkg_name + data_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"bos-{pkg_name}-") as tmp: + tmp_path = Path(tmp) / archive + print(f" {archive} <- {url}") + stage_file(url, tmp_path, digest, f"{pkg_name} {archive}") + assert_safe_archive(tmp_path) + subprocess.run( + [ + "tar", + "xzf", + str(tmp_path), + "--no-same-owner", + "--no-same-permissions", + "-C", + str(data_dir), + ], + check=True, + ) + print(f" extracted to {data_dir}") + + desktop = pkg.get("desktop_file") + if desktop: + if not isinstance(desktop, str) or not valid_name(desktop): + die(f"{pkg_name}: invalid desktop_file {desktop!r}") + digest = require_sha256(pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}") + dest = share_dir / "applications" / f"{pkg_name}.desktop" + stage_file(urljoin(base, desktop), dest, digest, f"{pkg_name} {desktop}") + + license_file = pkg.get("license_file") + if license_file: + if not isinstance(license_file, str) or not valid_name(license_file): + die(f"{pkg_name}: invalid license_file {license_file!r}") + digest = require_sha256(pkg.get("license_file_sha256"), f"{pkg_name} {license_file}") + dest = share_dir / "licenses" / pkg_name / "LICENSE" + stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}") + + installed[pkg_name] = { + "name": pkg_name, + "version": pkg.get("version"), + "binaries": installed_bins.get(pkg_name, []), + "services": service_names, + "installed_at": now, + "track": "stable", + "binary_sha256": installed_sha.get(pkg_name, {}), + } + + if "breadhelp" in installed: + content = share_dir / "breadhelp" / "content" + if not content.is_dir(): + die(f"breadhelp data_archive did not produce {content}") + + state_path = state_dir / "installed.json" + state_path.write_text(json.dumps({"track": "stable", "packages": installed}, indent=2) + "\n") + print(f"installed.json written ({len(installed)} packages): {', '.join(sorted(installed))}") + print(f"staged bins: {', '.join(sorted(p.name for p in bin_dir.iterdir() if p.is_file()))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh new file mode 100755 index 0000000..1b01cab --- /dev/null +++ b/scripts/ci-verify-bake.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Read-only checks that a builder home (and optionally a staged skel) has +# everything build-local.sh needs before mkarchiso. Exit non-zero on failure. +# +# LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh +# SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +LOCKFILE="${LOCKFILE:-$REPO/iso/bread-lockfile.toml}" +LAPTOP_HOME="${LAPTOP_HOME:-/build-home}" +SKEL="${SKEL:-}" + +pass=0 +fail=0 +ok() { printf ' PASS %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf ' FAIL %s\n' "$1" >&2; fail=$((fail + 1)); } + +if [[ ! -f "$LOCKFILE" ]]; then + echo "ERROR: lockfile missing: $LOCKFILE" >&2 + exit 1 +fi + +eval "$(python3 - "$LOCKFILE" <<'PY' +import sys, tomllib +path = sys.argv[1] +with open(path, "rb") as f: + data = tomllib.load(f) +required = data.get("required_bins") +optional = data.get("optional_bins") or [] +if required is None: + required = data.get("bins") or data.get("binaries") or [] +def emit(name, values): + print(f"{name}=(") + for v in values: + print(f" {v!r}") + print(")") +emit("REQUIRED_BINS", required) +emit("OPTIONAL_BINS", optional) +PY +)" + +echo "== lockfile $LOCKFILE ==" +echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +echo "== builder home $LAPTOP_HOME ==" + +check_exec() { + local path="$1" label="$2" + if [[ -x "$path" && -f "$path" ]]; then + ok "$label executable: $path" + else + bad "$label missing or not executable: $path" + fi +} + +check_dir() { + local path="$1" label="$2" + if [[ -d "$path" ]]; then + ok "$label: $path" + else + bad "$label missing: $path" + fi +} + +check_file() { + local path="$1" label="$2" + if [[ -f "$path" ]]; then + ok "$label: $path" + else + bad "$label missing: $path" + fi +} + +for b in "${REQUIRED_BINS[@]}"; do + check_exec "$LAPTOP_HOME/.local/bin/$b" "required bin $b" +done +for b in "${OPTIONAL_BINS[@]}"; do + if [[ -x "$LAPTOP_HOME/.local/bin/$b" ]]; then + ok "optional bin $b present" + else + printf ' ---- optional bin %s not staged (ok until bread ships it)\n' "$b" + fi +done + +check_dir "$LAPTOP_HOME/.local/share/breadhelp/content" "breadhelp content" +check_file "$LAPTOP_HOME/.cache/bakery/index.json" "bakery index cache" +check_file "$LAPTOP_HOME/.local/state/bakery/installed.json" "bakery installed.json" + +mapfile -t UNITS < <(python3 - "$LAPTOP_HOME/.local/state/bakery/installed.json" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + data = json.load(f) +pkgs = data.get("packages", data) +for pkg in pkgs.values(): + for s in pkg.get("services", []): + print(s["unit"] if isinstance(s, dict) else s) +PY +) +if [[ ${#UNITS[@]} -eq 0 ]]; then + bad "installed.json lists no service units" +else + for unit in "${UNITS[@]}"; do + [[ -n "$unit" ]] || continue + check_file "$LAPTOP_HOME/.config/systemd/user/$unit" "unit $unit" + done +fi + +if [[ -n "$SKEL" ]]; then + echo "== staged skel $SKEL ==" + for b in "${REQUIRED_BINS[@]}"; do + check_exec "$SKEL/.local/bin/$b" "skel required bin $b" + done + check_dir "$SKEL/.local/share/breadhelp/content" "skel breadhelp content" + check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache" + for unit in "${UNITS[@]}"; do + [[ -n "$unit" ]] || continue + if [[ -f "$SKEL/.config/systemd/user/$unit" ]]; then + ok "skel unit $unit" + else + bad "skel unit missing: $SKEL/.config/systemd/user/$unit" + fi + done +fi + +echo +printf 'Result: %d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 2d5264e..ab70559 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -46,9 +46,16 @@ done check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" echo "== bread ecosystem on PATH ==" -for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do +for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do check "$bin found" "command -v $bin" done +for bin in bread-emit bread-module-host; do + if command -v "$bin" >/dev/null 2>&1; then + ok "$bin found" + else + note "$bin not on PATH (optional until stable bread ships it)" + fi +done echo "== bos-settings ==" check "bos-settings installed" "command -v bos-settings" From d7c4fbd51fc29ce86cfe7e683b03cdefc857cd14 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:34:31 +0800 Subject: [PATCH 06/30] hyprland: place 3-finger workspace swipe after binds, before autostart The gesture was already on main but dangling at EOF. settings.lua no longer carries the old hyprlang gestures table. --- iso/airootfs/etc/skel/.config/hypr/hyprland.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 41c807f..5262654 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -94,6 +94,12 @@ pcall(function() bindings = binds.bindings, }) end) +-- 3-finger horizontal trackpad swipe → workspace switch (1:1 gesture) +hl.gesture({ + fingers = 3, + direction = "horizontal", + action = "workspace", +}) -- --------------------------------------------------------------------------- -- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper @@ -168,10 +174,3 @@ hl.on("hyprland.start", function() hl.dispatch(hl.dsp.exec_cmd(cmd)) end end) - - -hl.gesture({ - fingers = 3, - direction = "horizontal", - action = "workspace", -}) From 84033b879ef2c6bad427a062d25ca45343e4db8b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:35:58 +0800 Subject: [PATCH 07/30] iso: switch UEFI bootmode from systemd-boot to GRUB systemd-boot can only read the ESP it launched from, so mkarchiso copies vmlinuz + initramfs into efiboot.img on top of the ISO9660 copy (~244 MiB duplicate). uefi.grub reads ISO9660 directly; iso/grub configs were already present and BOS-branded. --- iso/profiledef.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iso/profiledef.sh b/iso/profiledef.sh index aa92318..c438138 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -8,7 +8,12 @@ iso_application="Bread Operating System" iso_version="$(date +%Y.%m.%d)" install_dir="arch" buildmodes=('iso') -bootmodes=('bios.syslinux' 'uefi.systemd-boot') +# systemd-boot can only read files from the ESP it was launched from, so +# mkarchiso's _make_bootmode_uefi.systemd-boot copies vmlinuz + initramfs +# INTO the FAT efiboot.img on top of the copy already on ISO9660 (~244 MiB +# duplicate). uefi.grub's ESP is only EFI + shell*.efi — GRUB reads ISO9660 +# directly. iso/grub/{grub,loopback}.cfg are already BOS-branded. +bootmodes=('bios.syslinux' 'uefi.grub') arch="x86_64" pacman_conf="pacman.conf" airootfs_image_type="squashfs" From b91fe0aeb8e8af10f5078b9dcad9ff6e9c644593 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:35:58 +0800 Subject: [PATCH 08/30] iso: trim package set -- nvidia firmware, CJK fonts, toolchain, zathura List linux-firmware subpackages so nvidia blobs stay off the image. Drop unused noto-fonts-cjk, base-devel, linux-headers, and zathura (skel already maps PDF to Zen). Keep webkit2gtk-4.1, breadlock, cage, and the bakery-vs-pacman comments; rustup was already gone. --- iso/packages.x86_64 | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index ad76c71..cc8e2e2 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -1,9 +1,26 @@ # Base system base -base-devel linux -linux-firmware -linux-headers +# linux-firmware metapackage pulls every mandatory vendor blob (incl. nvidia). +# List the subpackages we actually need so nvidia (~103 MiB, nouveau-only — +# BOS ships no NVIDIA driver) can stay off the image. Turing+ nouveau needs +# the GSP blobs; reinstall linux-firmware-nvidia when lspci sees NVIDIA. +# linux-firmware +linux-firmware-amdgpu +linux-firmware-atheros +linux-firmware-broadcom +linux-firmware-cirrus +linux-firmware-intel +linux-firmware-mediatek +linux-firmware-realtek +linux-firmware-radeon +linux-firmware-other +# linux-firmware-nvidia +# base-devel + linux-headers are for AUR/DKMS builds. yay needs base-devel, +# but those builds need network anyway — pacman -S base-devel at that point. +# linux-headers is DKMS-only and BOS ships no DKMS packages. +# base-devel +# linux-headers # CPU microcode — applied early by GRUB on the installed system (picked up by # the bootloader module). amd-ucode for the dev laptop's Ryzen; intel-ucode for # Intel targets. bos-copy-kernel also stages these into the live target /boot. @@ -44,7 +61,7 @@ sbctl squashfs-tools # rsync: unpackfs copies the unpacked rootfs onto the target with rsync. rsync -# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.systemd-boot) +# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.grub) # mkinitcpio-archiso provides the initramfs hooks that find and mount # airootfs.sfs and switch root into it — without it the live ISO drops # to emergency mode on boot. @@ -131,7 +148,9 @@ wayland-protocols # Fonts noto-fonts -noto-fonts-cjk +# noto-fonts-cjk is ~299 MiB installed / ~196 MiB on the ISO and only useful +# to CJK-locale users. Install on first run for zh/ja/ko. +# noto-fonts-cjk noto-fonts-emoji ttf-jetbrains-mono # Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi) @@ -157,13 +176,12 @@ file-roller # GUI applications a general desktop is expected to have out of the box. # gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator: -# calculator; loupe: Wayland-native image viewer (default for image files); -# zathura(+pdf-mupdf): lightweight Wayland PDF viewer (BOS had no PDF reader). +# calculator; loupe: Wayland-native image viewer (default for image files). +# PDF is handled by Zen (skel mimeapps.list maps application/pdf to zen.desktop); +# zathura+zathura-pdf-mupdf would pull libmupdf (~56 MiB) as a never-default viewer. gnome-text-editor gnome-calculator loupe -zathura -zathura-pdf-mupdf # Media player — BOS ships gstreamer codecs but otherwise has no player app. vlc # Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished From 00fe0d7fce7323c08f767f3a2602977c3be6b704 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:35:58 +0800 Subject: [PATCH 09/30] calamares: strip live-only packages, mask homed, cap journald Remove Calamares, archiso boot-chain, and memtest/EFI-shell packages from the installed system (plus orphan sweep). Socket-activate avahi. Mask the systemd-homed stack so presets cannot re-enable it. Cap journald at 256 MiB instead of 10% of the @log pool. --- iso/airootfs/etc/calamares/post-install.sh | 52 +++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 66ba35b..2076dcf 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -33,6 +33,32 @@ rm -f /usr/local/bin/bos-live-setup /usr/local/bin/bos-launch-calamares rm -f /etc/sudoers.d/99-bos-live userdel -r liveuser 2>/dev/null || true +# unpackfs copies the entire live squashfs onto the target. Remove live-only +# packages (Calamares + archiso boot chain + memtest/EFI-shell payloads) so +# they do not stay on disk forever. pacman -Rs (not -Rns) keeps /etc configs +# and reaps newly-orphaned KF6/Qt6 deps. Each name is independent so one +# missing package cannot abort the rest. Offline-safe: never touches the +# network. qt6-declarative is NOT reaped — qt6-wayland still needs it. +LIVE_ONLY_PKGS=( + calamares + squashfs-tools + mkinitcpio-archiso + mkinitcpio-nfs-utils + memtest86+ + memtest86+-efi + edk2-shell + syslinux +) +for pkg in "${LIVE_ONLY_PKGS[@]}"; do + pacman -Qq "$pkg" &>/dev/null || continue + pacman -Rs --noconfirm "$pkg" &>/dev/null \ + || echo "WARN: could not remove live-only package $pkg" +done +while orphans="$(pacman -Qtdq 2>/dev/null)" && [[ -n "$orphans" ]]; do + # shellcheck disable=SC2086 + pacman -Rs --noconfirm $orphans &>/dev/null || break +done + # Root used a passwordless entry on the live medium; lock it (sudo model). passwd -l root || true @@ -344,15 +370,39 @@ fi # greetd — graphical login (shipped disabled; live uses tty autologin) # grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service, # NOT grub-btrfs.path, which no longer exists) +# avahi-daemon.service → avahi-daemon.socket: the package ships both; socket +# activation still answers nss-mdns and CUPS discovery but stays off the idle +# RSS until something asks. The host stops announcing itself over mDNS until +# the socket is first touched. # --------------------------------------------------------------------------- for unit in NetworkManager.service bluetooth.service systemd-timesyncd.service \ tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \ - fstrim.timer cups.socket avahi-daemon.service ufw.service \ + fstrim.timer cups.socket avahi-daemon.socket ufw.service \ fwupd-refresh.timer reflector.timer; do systemctl enable "$unit" || echo "WARN: failed to enable $unit" done systemctl set-default graphical.target || echo "WARN: set-default graphical failed" +# Arch's 90-systemd.preset enables systemd-homed / userdbd / nsresourced. +# BOS creates classic /etc/passwd accounts and never calls homectl. Mask +# (not disable) so preset-all or a systemd upgrade cannot re-enable them. +for unit in systemd-homed.service systemd-homed-activate.service \ + systemd-userdbd.service systemd-userdbd.socket \ + systemd-nsresourced.service systemd-nsresourced.socket; do + systemctl mask "$unit" || echo "WARN: failed to mask $unit" +done + +# journald defaults SystemMaxUse to 10% of the filesystem holding /var/log. +# /var/log is the @log subvolume of the root pool, so that ceiling is tens +# of GB. Cap it; less history for postmortems. +install -d -m 0755 /etc/systemd/journald.conf.d +cat >/etc/systemd/journald.conf.d/90-bos-journal.conf <<'JOURNALEOF' +[Journal] +SystemMaxUse=256M +SystemMaxFileSize=32M +RuntimeMaxUse=32M +JOURNALEOF + # --------------------------------------------------------------------------- # mDNS resolution (nss-mdns): insert mdns_minimal into the hosts: line so the # resolver answers *.local (network printers, other hosts) via avahi. Idempotent. From 6eda6cb5f7b3442ee6878202cffe719a9ed2e0c3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:35:58 +0800 Subject: [PATCH 10/30] packaging/bibata: ship only the Bibata-Modern-Ice variant Upstream tarball has 12 variants. BOS only selects Bibata-Modern-Ice; keep that plus the left-handed -Right sibling. Needs a [breadway] rebuild to take effect on the ISO. --- packaging/bibata/PKGBUILD | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packaging/bibata/PKGBUILD b/packaging/bibata/PKGBUILD index b49e382..b713436 100644 --- a/packaging/bibata/PKGBUILD +++ b/packaging/bibata/PKGBUILD @@ -16,7 +16,13 @@ options=('!strip') source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz") sha256sums=('172e33c4ae415278384dcecc7d1a9b7a024266bc944bc751fd86532be1cc6251') +# Upstream tarball has all 12 variants (~322 MiB). BOS only ever selects +# Bibata-Modern-Ice (hyprland.lua XCURSOR_THEME, gsettings, gtk settings.ini). +# Ship that plus its -Right sibling. +_variants=(Bibata-Modern-Ice Bibata-Modern-Ice-Right) package() { install -d "$pkgdir/usr/share/icons" - cp -r Bibata* "$pkgdir/usr/share/icons" + for v in "${_variants[@]}"; do + cp -r "$v" "$pkgdir/usr/share/icons/" + done } From 3bd278c1b9327a3af443889c9b1ceb2b5c7d226d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:35:58 +0800 Subject: [PATCH 11/30] iso/pacman.conf: document optional NoExtract size levers Commented-out only. Keep the bakery-vs-pacman and SigLevel = Never honesty comments already on main. --- iso/pacman.conf | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/iso/pacman.conf b/iso/pacman.conf index 7e15c7a..506b4bb 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -9,6 +9,23 @@ Architecture = auto CheckSpace ParallelDownloads = 5 +# Optional NoExtract size levers — left disabled. This file is both the ISO +# build config AND the installed system's pacman.conf, so enabling any line +# also stops future pacman -Syu from restoring those files. +# Measured against the 844-package closure (xz squashfs, profiledef.sh opts): +# usr/share/locale (non-en) 405.0 MiB raw -> 92.28 MiB ISO +# usr/share/doc 130.5 MiB raw -> 26.54 MiB ISO +# usr/share/man 41.5 MiB raw -> 38.77 MiB ISO +# usr/share/info 12.9 MiB raw -> 11.12 MiB ISO +# usr/share/gtk-doc 16.0 MiB raw -> 1.18 MiB ISO +# usr/include 193.6 MiB raw -> 25.26 MiB ISO +# Non-en locales make every GUI English-only until the package is reinstalled +# without this NoExtract; dropping man/info means `man` returns nothing. +#NoExtract = usr/share/locale/* !usr/share/locale/en* !usr/share/locale/locale.alias +#NoExtract = usr/share/doc/* usr/share/gtk-doc/* usr/share/info/* +#NoExtract = usr/share/man/* +#NoExtract = usr/include/* + Color VerbosePkgLists ILoveCandy From 96a2f685a2e6222af4af77492e0345e840770b2a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:53:01 +0800 Subject: [PATCH 12/30] 1.0 polish: os-release, snapper pre, lockfile pins, listen, docs Point os-release at the bos repo and issues; drop Arch privacy terms. Take a best-effort snapper pre snapshot before pacman and bakery. Pin current stable bakery versions so CI fetches the same bits per commit. Autostart breadpaper/breadshot listen behind command -v. Document signed-repo setup and Mesa/NVIDIA/grub-btrfs recovery. --- README.md | 41 +++-- docs/hardware.md | 21 +++ docs/signed-repo.md | 85 +++++++++ iso/airootfs/etc/os-release | 6 +- .../etc/skel/.config/hypr/autostart.json | 4 +- .../etc/skel/.config/hypr/hyprland.lua | 15 +- .../.config/hypr/scripts/system/autostart.lua | 4 + iso/airootfs/usr/local/bin/bos-update | 18 ++ iso/bread-lockfile.toml | 27 ++- scripts/ci-stage-bakery.py | 162 +++++++++++++++--- 10 files changed, 336 insertions(+), 47 deletions(-) create mode 100644 docs/hardware.md create mode 100644 docs/signed-repo.md diff --git a/README.md b/README.md index 6ad797b..ccaf576 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,12 @@ wiring up dotfiles, no per-tool bakery installs. - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing with avahi mDNS discovery, TLP power management, fwupd firmware updates. Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is - unsupported out of the box (see Known limitations). + unsupported out of the box (see [docs/hardware.md](docs/hardware.md)). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot, - not `snapper rollback` (GRUB pins `rootflags=subvol=@`). + not `snapper rollback` (GRUB pins `rootflags=subvol=@`). See + [docs/hardware.md](docs/hardware.md). - **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via `sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the @@ -62,8 +63,10 @@ wiring up dotfiles, no per-tool bakery installs. | **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) | | **Not shipped** | `breadcast`, `breadarr` | -The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml). -`build-local.sh` fails if any **required** binary is missing on the builder. +The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml) +(plus optional `[versions]` / `[[pin]]` so CI fetches +`https://dl.breadway.dev///...`). `build-local.sh` fails if any +**required** binary is missing on the builder. ## Repo layout @@ -74,7 +77,7 @@ repos and arrive via bakery. ``` bos/ ├── iso/ # archiso profile -│ ├── bread-lockfile.toml # bakery bins (required + optional) +│ ├── bread-lockfile.toml # bakery bins + optional version pins │ ├── profiledef.sh │ ├── packages.x86_64 # live + installed pacman set │ └── airootfs/ # files overlaid onto the image @@ -91,6 +94,9 @@ bos/ │ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME │ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso │ └── smoke-test.sh +├── docs/ +│ ├── hardware.md # Mesa only, NVIDIA, grub-btrfs recovery +│ └── signed-repo.md # future dl.breadway.dev/arch signing ├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release ├── build-local.sh # native ISO build for this machine ├── README.md @@ -133,8 +139,10 @@ non-zero** if any **required** lockfile binary (or breadhelp content) is missing. Optional bins are skipped with a warning. CI stages the builder from the **minisign-verified** stable bakery index -(`index.json` + `index.json.minisig`); local builds still snapshot the -builder. The lockfile is names only. +(`index.json` + `index.json.minisig`) and prefers lockfile `[versions]` +URLs (`https://dl.breadway.dev///...`) when set, so two bakes +of the same commit fetch the same bits. Local builds still snapshot the +builder. ### Why some packages are in-house @@ -158,7 +166,8 @@ dedicated release-signing key (not reused from anything else): The public half is committed at [`KEYS.asc`](KEYS.asc). That key signs **ISO checksums only** — it does not sign the `[breadway]` pacman repo (Forgejo's Arch registry has no pacman-compatible db signatures; that -section stays `SigLevel = Never`). To verify a download: +section stays `SigLevel = Never` until a signed repo exists — see +[docs/signed-repo.md](docs/signed-repo.md)). To verify a download: ```sh gpg --import KEYS.asc @@ -303,9 +312,13 @@ cheatsheet in-session; first boot shows a short welcome (once). ## Known limitations +See [docs/hardware.md](docs/hardware.md) (GPUs, NVIDIA, recovery) and +[docs/signed-repo.md](docs/signed-repo.md) (`[breadway]` stays unsigned +until `dl.breadway.dev/arch` exists). + - **GPUs**: ships the generic Mesa stack — AMD and Intel work out of the box. - The **NVIDIA proprietary driver is not included**; NVIDIA users must install - `nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install. + NVIDIA is **unsupported** (no proprietary driver, no NVIDIA firmware). See + [docs/hardware.md](docs/hardware.md). - **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use `virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is noticeably laggy. @@ -324,7 +337,10 @@ cheatsheet in-session; first boot shows a short welcome (once). BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id). - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default - btrfs subvolume layout the installer creates. + btrfs subvolume layout the installer creates. Recovery is the GRUB + snapshots submenu, not `snapper rollback` — [docs/hardware.md](docs/hardware.md). +- **`[breadway]` signatures**: `SigLevel = Never` until a signed repo is + stood up at `dl.breadway.dev/arch`. See [docs/signed-repo.md](docs/signed-repo.md). ## Recovery @@ -335,7 +351,8 @@ Snapshots and pick a snapshot to boot, **or** choose one from the **GRUB Do **not** run `snapper rollback` as the default recovery step. BOS GRUB pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not what the installed grub.cfg will boot next. Use the grub-btrfs entry so the -kernel command line matches the snapshot you want. +kernel command line matches the snapshot you want. Details: +[docs/hardware.md](docs/hardware.md). A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is not shipped. diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..4bc2d40 --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,21 @@ +# Hardware and recovery + +## GPUs + +BOS ships the generic **Mesa** stack. AMD and Intel work out of the box. + +**NVIDIA is unsupported.** The proprietary driver is not included, NVIDIA +firmware is not on the image, and there is no Hyprland NVIDIA env wiring. +Installing `nvidia` / `nvidia-utils` after the fact is not a product path. + +## Recovery + +An update that breaks the system is recovered from the **GRUB "snapshots" +submenu** (grub-btrfs), not `snapper rollback`. + +BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default +subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs +entry so the kernel command line matches the snapshot you want. + +A/B root swapping is not implemented. See the README Recovery section for +the "system will not boot" GRUB/EFI repair path. diff --git a/docs/signed-repo.md b/docs/signed-repo.md new file mode 100644 index 0000000..112e254 --- /dev/null +++ b/docs/signed-repo.md @@ -0,0 +1,85 @@ +# Signed `[breadway]` repo + +Today the ISO's `[Breadway.os.git.breadway.dev]` section is +`SigLevel = Never`. That is TLS-only integrity: packages come from Forgejo's +Arch registry, which does **not** serve pacman-compatible database +signatures. `KEYS.asc` signs **ISO `SHA256SUMS` only** — it is not imported +as a pacman repo key. Do not flip `SigLevel` to `Required` on that section +until a signed repo exists and has been verified; Required without +signatures breaks the ISO and every installed system. + +The signed repo belongs at `https://dl.breadway.dev/arch`, not on Forgejo's +registry. + +## Stand up `dl.breadway.dev/arch` + +Use the same release-signing key already in CI: + +- Public half: [`KEYS.asc`](../KEYS.asc) + (`5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2`, + `releases@breadway.dev`) +- Private half: the `GPG_PRIVATE_KEY` Forgejo secret (armoured secret key, + no passphrase). Same secret `release-iso.yml` uses to sign `SHA256SUMS`. + +Layout (example for `x86_64`): + +``` +https://dl.breadway.dev/arch/x86_64/ + breadlock--1-x86_64.pkg.tar.zst + breadlock--1-x86_64.pkg.tar.zst.sig + breadway.db + breadway.db.sig + breadway.files + breadway.files.sig +``` + +Build the database **and sign it** with `repo-add -s`: + +```sh +export GNUPGHOME=/tmp/gnupg-breadway-repo +mkdir -m 700 -p "$GNUPGHOME" +printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import + +cd /srv/dl.breadway.dev/arch/x86_64 +repo-add -s -k releases@breadway.dev breadway.db.tar.gz *.pkg.tar.zst +``` + +`repo-add -s` writes `breadway.db.tar.gz.sig` (and the `.files` pair). +Pacman fetches `
.db` + `
.db.sig` from `Server`. + +Package signatures are separate from the database signature. Detach-sign +each `.pkg.tar.zst` as a **binary** sidecar (pacman wants `.sig`, not +armoured `.asc`): + +```sh +gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign breadlock--1-x86_64.pkg.tar.zst +# → breadlock--1-x86_64.pkg.tar.zst.sig +``` + +## breadlock `package.yml` sidecar + +[`breadlock` `package.yml`](https://git.breadway.dev/Breadway/breadlock/src/branch/main/.forgejo/workflows/package.yml) +already `makepkg`s and PUTs the archive at Forgejo's registry. When the +signed repo exists, that job can also emit the sidecar and publish both +files to `dl.breadway.dev/arch`: + +```sh +PKG=$(find packaging/arch -name '*.pkg.tar.zst' | head -1) +printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import +gpg --batch --yes --local-user releases@breadway.dev --detach-sign "$PKG" +# upload "$PKG" and "${PKG}.sig" to dl.breadway.dev/arch/x86_64/ +# then repo-add -s as above +``` + +Keep publishing to Forgejo until installs have been switched. The ISO +section stays `SigLevel = Never` until the signed tree is live. + +## After the signed repo exists + +1. Import `KEYS.asc` into the ISO keyring (`pacman-key --add` + `--lsign-key`). +2. Point `[breadway]` `Server` at `https://dl.breadway.dev/arch/$arch`. +3. Only then flip that section to `SigLevel = Required`. + +Do not do those three steps against Forgejo's registry. See +`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. diff --git a/iso/airootfs/etc/os-release b/iso/airootfs/etc/os-release index 4f28e07..f6b380c 100644 --- a/iso/airootfs/etc/os-release +++ b/iso/airootfs/etc/os-release @@ -5,7 +5,7 @@ ID_LIKE=arch BUILD_ID=rolling ANSI_COLOR="38;2;23;147;209" HOME_URL="https://breadway.dev" -DOCUMENTATION_URL="https://wiki.archlinux.org/" -SUPPORT_URL="https://bbs.archlinux.org/" +DOCUMENTATION_URL="https://git.breadway.dev/Breadway/bos" +SUPPORT_URL="https://git.breadway.dev/Breadway/bos/issues" BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" -PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" +PRIVACY_POLICY_URL="https://breadway.dev" diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json index e18222b..a8032d3 100644 --- a/iso/airootfs/etc/skel/.config/hypr/autostart.json +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -3,6 +3,8 @@ { "command": "breadbar", "label": "Bar (breadbar)", "enabled": true }, { "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true }, { "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true }, - { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true } + { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, + { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, + { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true } ] } diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 5262654..ac760f4 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -106,8 +106,10 @@ hl.gesture({ -- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's -- timing/order-sensitive infrastructure, not something a settings UI should -- expose for a user to disable or reorder. The extra, genuinely toggleable --- apps (breadbar, hypridle, bos-netcheck, breadhelp) come from --- autostart.json via scripts/system/autostart.lua, appended after. +-- apps (breadbar, hypridle, bos-netcheck, breadhelp, breadpaper/breadshot +-- listen) come from autostart.json via scripts/system/autostart.lua, +-- appended after. listen is wrapped with `command -v` so a missing +-- binary does not brick login (Hyprland exec is already fire-and-forget). -- (bos-live-setup appends the live-installer launch below this on the ISO.) -- --------------------------------------------------------------------------- hl.on("hyprland.start", function() @@ -168,7 +170,14 @@ hl.on("hyprland.start", function() -- autostart.json/its loader broke — fall back to the same apps BOS -- has always started, so a bad JSON edit degrades to "normal -- desktop" rather than "no bar, no idle lock, no onboarding". - extra = { "breadbar", "hypridle", "bos-netcheck", "breadhelp --autostart" } + extra = { + "breadbar", + "hypridle", + "bos-netcheck", + "breadhelp --autostart", + "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", + "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", + } end for _, cmd in ipairs(extra) do hl.dispatch(hl.dsp.exec_cmd(cmd)) diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua index 7fb119d..f51bdda 100644 --- a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -12,11 +12,15 @@ -- because the valid result happens to be empty. local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") +-- breadpaper/breadshot `listen` is wrapped so a missing binary (stable +-- does not ship the command-bus verb yet) cannot take down the session. local DEFAULT_EXTRA = { { command = "breadbar", enabled = true }, { command = "hypridle", enabled = true }, { command = "bos-netcheck", enabled = true }, { command = "breadhelp --autostart", enabled = true }, + { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, + { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, } return function() diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index aeb6fbd..65f27c0 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -19,6 +19,24 @@ set -uo pipefail bold() { printf '\033[1m%s\033[0m\n' "$1"; } +# Timed snapper pre snapshot before either channel. snap-pac already +# snapshots root around pacman; bakery writes ~/.local/bin ($HOME / @home), +# which is outside that root snapshot. This extra snapshot is still +# best-effort and covers bakery $HOME updates as well as possible — a +# home config if the installer created one, otherwise the root timeline +# around the whole update. Never fail the update if snapper is missing +# or the create errors. +if command -v snapper >/dev/null; then + if snapper -c home list >/dev/null 2>&1; then + snapper -c home create -t pre -c number \ + -d "bos-update (pre bakery)" \ + || echo "WARN: snapper home pre snapshot failed" + fi + snapper -c root create -t pre -c number \ + -d "bos-update (pre bakery)" \ + || echo "WARN: snapper pre snapshot failed" +fi + bold "==> System packages (pacman -Syu)" if command -v pacman >/dev/null; then sudo pacman -Syu || echo "WARN: pacman update failed" diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index d704c33..0b84ee7 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -9,8 +9,12 @@ # A flat `bins` list is still accepted and treated as required_bins. # # CI populates the builder from the minisign-verified stable bakery index -# (https://dl.breadway.dev/index.json). Local builds still snapshot whatever -# is installed on the builder; this file only names what must / may be present. +# (https://dl.breadway.dev/index.json). Optional [versions] (or [[pin]] +# tables with package + version) pin bakery package versions so two ISO +# bakes of the same git commit fetch the same bits: +# https://dl.breadway.dev///... +# Bump pins after new bakery stables land. Local builds still snapshot +# whatever is installed on the builder. # # Not shipped (even if they appear in the index): breadcast, breadarr. # breadlock is pacman (see packages.x86_64), not bakery. @@ -42,3 +46,22 @@ optional_bins = [ "bread-emit", "bread-module-host", ] + +# Package name → version, matching today's stable index. CI prefers the +# pinned version URL when a key is set. [[pin]] { package, version } is +# accepted as well and merged (conflict = bake error). +[versions] +bakery = "0.7.1" +bread = "0.7.0" +bread-theme = "0.7.1" +breadbar = "0.3.0" +breadbox = "0.3.0" +breadcrumbs = "2.1.6" +breadpad = "0.5.0" +breadpaper = "0.1.11" +breadmon = "0.1.2" +breadsearch = "0.3.0" +breadclip = "0.1.1" +breadshot = "0.1.1" +bos-settings = "0.7.1" +breadhelp = "0.2.3" diff --git a/scripts/ci-stage-bakery.py b/scripts/ci-stage-bakery.py index 3545f6f..20dbfb1 100755 --- a/scripts/ci-stage-bakery.py +++ b/scripts/ci-stage-bakery.py @@ -24,6 +24,7 @@ from pathlib import Path from urllib.parse import urljoin, urlparse INDEX_URL = "https://dl.breadway.dev/index.json" +DL_ORIGIN = "https://dl.breadway.dev" # Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs. MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8" BLOCKED = frozenset({"breadcast", "breadarr"}) @@ -46,7 +47,41 @@ def valid_name(name: str) -> bool: return bool(name) and "/" not in name and name not in (".", "..") -def load_lockfile(path: Path) -> tuple[list[str], list[str]]: +def load_versions(data: dict, path: Path) -> dict[str, str]: + """Optional [versions] map and/or [[pin]] tables → package → version.""" + versions: dict[str, str] = {} + + raw_map = data.get("versions") + if raw_map is not None: + if not isinstance(raw_map, dict): + die(f"{path}: [versions] must be a table of package = \"version\"") + for pkg, ver in raw_map.items(): + if not isinstance(pkg, str) or not valid_name(pkg): + die(f"{path}: invalid [versions] package {pkg!r}") + if not isinstance(ver, str) or not valid_name(ver): + die(f"{path}: invalid [versions] version for {pkg}: {ver!r}") + versions[pkg] = ver + + pins = data.get("pin") + if pins is not None: + if not isinstance(pins, list): + die(f"{path}: [[pin]] must be an array of tables") + for i, entry in enumerate(pins): + if not isinstance(entry, dict): + die(f"{path}: [[pin]] #{i} must be a table") + pkg = entry.get("package", entry.get("pkg")) + ver = entry.get("version") + if not isinstance(pkg, str) or not valid_name(pkg): + die(f"{path}: [[pin]] #{i}: missing valid package") + if not isinstance(ver, str) or not valid_name(ver): + die(f"{path}: [[pin]] #{i}: missing valid version") + if pkg in versions and versions[pkg] != ver: + die(f"{path}: conflicting pin for {pkg}: {versions[pkg]} vs {ver}") + versions[pkg] = ver + return versions + + +def load_lockfile(path: Path) -> tuple[list[str], list[str], dict[str, str]]: with path.open("rb") as f: data = tomllib.load(f) required = data.get("required_bins") @@ -66,15 +101,36 @@ def load_lockfile(path: Path) -> tuple[list[str], list[str]]: overlap = set(required) & set(optional) if overlap: die(f"{path}: bins in both required and optional: {sorted(overlap)}") - return list(required), list(optional) + return list(required), list(optional), load_versions(data, path) -def fetch(url: str, dest: Path) -> None: +def pinned_artifact_url(pkg: str, version: str, filename: str) -> str: + if not valid_name(pkg) or not valid_name(version) or not valid_name(filename): + die(f"refusing pinned URL with unsafe path {pkg}/{version}/{filename}") + return f"{DL_ORIGIN}/{pkg}/{version}/{filename}" + + +def package_base_url(pkg_name: str, versions: dict[str, str], first_url: str) -> str: + pin = versions.get(pkg_name) + if pin: + if not valid_name(pkg_name) or not valid_name(pin): + die(f"refusing pinned version dir {pkg_name}/{pin}") + return f"{DL_ORIGIN}/{pkg_name}/{pin}/" + return version_dir(first_url) + + +def fetch(url: str, dest: Path, *, required: bool = True) -> bool: dest.parent.mkdir(parents=True, exist_ok=True) try: urllib.request.urlretrieve(url, dest) + return True except (urllib.error.URLError, OSError) as e: - die(f"download failed: {url}: {e}") + if required: + die(f"download failed: {url}: {e}") + print(f"WARN: download failed: {url}: {e}", file=sys.stderr) + if dest.exists(): + dest.unlink() + return False def sha256_file(path: Path) -> str: @@ -183,11 +239,22 @@ def assert_safe_archive(path: Path) -> None: die(f"refusing archive with unsafe path {name!r}") -def stage_file(url: str, dest: Path, sha256: str, what: str, mode: int | None = None) -> None: - fetch(url, dest) - verify_sha256(dest, sha256, what) +def stage_file( + url: str, + dest: Path, + sha256: str | None, + what: str, + mode: int | None = None, + *, + required: bool = True, +) -> bool: + if not fetch(url, dest, required=required): + return False + if sha256 is not None: + verify_sha256(dest, sha256, what) if mode is not None: dest.chmod(mode) + return True def main() -> int: @@ -216,8 +283,11 @@ def main() -> int: if not lockfile.is_file(): die(f"lockfile missing: {lockfile}") - required, optional = load_lockfile(lockfile) - print(f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional") + required, optional, versions = load_lockfile(lockfile) + print( + f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional" + + (f", {len(versions)} pinned" if versions else "") + ) bin_dir = home / ".local" / "bin" state_dir = home / ".local" / "state" / "bakery" @@ -245,6 +315,24 @@ def main() -> int: selected: dict[str, dict] = {} installed_bins: dict[str, list[str]] = {} installed_sha: dict[str, dict[str, str]] = {} + fetched_url: dict[str, str] = {} + + pin_warned: set[str] = set() + + def pin_digest(pkg_name: str, pkg: dict, value: object, what: str) -> str | None: + """Index sha256 is only valid when it describes the pinned version.""" + digest = require_sha256(value, what) + pin = versions.get(pkg_name) + if pin and str(pkg.get("version")) != pin: + if pkg_name not in pin_warned: + print( + f"WARN: {pkg_name} pin {pin} != index {pkg.get('version')}; " + f"fetching pinned URL without index sha256", + file=sys.stderr, + ) + pin_warned.add(pkg_name) + return None + return digest def take_bin(name: str, *, required_bin: bool) -> bool: hit = published.get(name) @@ -256,16 +344,30 @@ def main() -> int: pkg_name, pkg, binary = hit if pkg_name in BLOCKED or name in BLOCKED: die(f"refusing blocked package/bin {pkg_name}/{name}") - url = binary.get("dl_url") + raw = binary.get("name") + index_url = binary.get("dl_url") + pin = versions.get(pkg_name) + if pin: + if not isinstance(raw, str) or not valid_name(raw): + die(f"{name}: missing binary filename for pinned URL") + url = pinned_artifact_url(pkg_name, pin, raw) + else: + url = index_url if not isinstance(url, str) or not url: die(f"{name}: missing dl_url") - digest = require_sha256(binary.get("sha256"), f"binary {name}") + digest = pin_digest(pkg_name, pkg, binary.get("sha256"), f"binary {name}") dest = bin_dir / name - print(f" {name} <- {url}") - stage_file(url, dest, digest, f"binary {name}", mode=0o755) + note = f" (pin {pkg_name}={pin})" if pin else "" + print(f" {name} <- {url}{note}") + if not stage_file( + url, dest, digest, f"binary {name}", mode=0o755, required=required_bin + ): + return False selected[pkg_name] = pkg installed_bins.setdefault(pkg_name, []).append(name) - installed_sha.setdefault(pkg_name, {})[name] = digest + if digest is not None: + installed_sha.setdefault(pkg_name, {})[name] = digest + fetched_url.setdefault(pkg_name, url) return True for name in required: @@ -281,14 +383,15 @@ def main() -> int: for pkg_name, pkg in sorted(selected.items()): bins = pkg.get("binaries") or [] - first_url = None - for b in bins: - if isinstance(b, dict) and b.get("dl_url"): - first_url = b["dl_url"] - break + first_url = fetched_url.get(pkg_name) + if not first_url: + for b in bins: + if isinstance(b, dict) and b.get("dl_url"): + first_url = b["dl_url"] + break if not first_url: die(f"{pkg_name}: no binary dl_url to derive version dir") - base = version_dir(first_url) + base = package_base_url(pkg_name, versions, first_url) service_names: list[str] = [] for svc in pkg.get("services") or []: @@ -297,12 +400,13 @@ def main() -> int: unit = svc.get("unit") if not isinstance(unit, str) or not valid_name(unit): die(f"{pkg_name}: invalid service unit {unit!r}") - digest = require_sha256(svc.get("sha256"), f"{pkg_name} {unit}") + digest = pin_digest(pkg_name, pkg, svc.get("sha256"), f"{pkg_name} {unit}") dest = unit_dir / unit url = urljoin(base, unit) print(f" {unit} <- {url}") fetch(url, dest) - verify_sha256(dest, digest, f"unit {unit}") + if digest is not None: + verify_sha256(dest, digest, f"unit {unit}") dest.write_text(patch_exec_start(dest.read_text(), bin_dir)) dest.chmod(0o644) if svc.get("enable"): @@ -322,7 +426,9 @@ def main() -> int: if archive: if not isinstance(archive, str) or not valid_name(archive): die(f"{pkg_name}: invalid data_archive {archive!r}") - digest = require_sha256(pkg.get("data_archive_sha256"), f"{pkg_name} {archive}") + digest = pin_digest( + pkg_name, pkg, pkg.get("data_archive_sha256"), f"{pkg_name} {archive}" + ) url = urljoin(base, archive) data_dir = share_dir / pkg_name data_dir.mkdir(parents=True, exist_ok=True) @@ -349,7 +455,9 @@ def main() -> int: if desktop: if not isinstance(desktop, str) or not valid_name(desktop): die(f"{pkg_name}: invalid desktop_file {desktop!r}") - digest = require_sha256(pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}") + digest = pin_digest( + pkg_name, pkg, pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}" + ) dest = share_dir / "applications" / f"{pkg_name}.desktop" stage_file(urljoin(base, desktop), dest, digest, f"{pkg_name} {desktop}") @@ -357,13 +465,15 @@ def main() -> int: if license_file: if not isinstance(license_file, str) or not valid_name(license_file): die(f"{pkg_name}: invalid license_file {license_file!r}") - digest = require_sha256(pkg.get("license_file_sha256"), f"{pkg_name} {license_file}") + digest = pin_digest( + pkg_name, pkg, pkg.get("license_file_sha256"), f"{pkg_name} {license_file}" + ) dest = share_dir / "licenses" / pkg_name / "LICENSE" stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}") installed[pkg_name] = { "name": pkg_name, - "version": pkg.get("version"), + "version": versions.get(pkg_name, pkg.get("version")), "binaries": installed_bins.get(pkg_name, []), "services": service_names, "installed_at": now, From ca550c3ffc08e0f4222cd75073514622b0fa7f3b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:11:28 +0800 Subject: [PATCH 13/30] Add notification history bind; pin bakery versions --- .../etc/skel/.config/hypr/autostart.json | 6 +++- iso/airootfs/etc/skel/.config/hypr/binds.json | 1 + .../etc/skel/.config/hypr/hyprland.lua | 4 +++ .../.config/hypr/scripts/system/autostart.lua | 4 +++ iso/bread-lockfile.toml | 28 +++++++++---------- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json index a8032d3..87de11a 100644 --- a/iso/airootfs/etc/skel/.config/hypr/autostart.json +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -5,6 +5,10 @@ { "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true }, { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, - { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true } + { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true }, + { "command": "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", "label": "Launcher command bus (breadbox listen)", "enabled": true }, + { "command": "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", "label": "Help command bus (breadhelp listen)", "enabled": true }, + { "command": "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", "label": "Search command bus (breadsearch listen)", "enabled": true }, + { "command": "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", "label": "Capture command bus (breadpad listen)", "enabled": true } ] } diff --git a/iso/airootfs/etc/skel/.config/hypr/binds.json b/iso/airootfs/etc/skel/.config/hypr/binds.json index 4b959ce..67f19f4 100644 --- a/iso/airootfs/etc/skel/.config/hypr/binds.json +++ b/iso/airootfs/etc/skel/.config/hypr/binds.json @@ -18,6 +18,7 @@ { "action": "exec", "command": "breadclip", "key": "V", "label": "Clipboard history (breadclip)", "category": "apps", "demo_cmd": "breadclip" }, { "action": "exec", "command": "breadclip", "key": "V", "mods": ["SUPER", "SHIFT"], "label": "Clipboard history (breadclip) — same as SUPER + V", "category": "apps" }, + { "action": "exec", "command": "breadbar --history", "key": "N", "mods": ["SUPER", "SHIFT"], "label": "Notification history (breadbar)", "category": "apps", "demo_cmd": "breadbar --history" }, { "action": "layout", "layout": "togglesplit", "key": "T", "label": "Toggle split direction", "category": "windows" }, { "action": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" }, diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index ac760f4..57360a3 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -177,6 +177,10 @@ hl.on("hyprland.start", function() "breadhelp --autostart", "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", + "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", + "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", + "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", + "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", } end for _, cmd in ipairs(extra) do diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua index f51bdda..ce4465f 100644 --- a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -21,6 +21,10 @@ local DEFAULT_EXTRA = { { command = "breadhelp --autostart", enabled = true }, { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, + { command = "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", enabled = true }, + { command = "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", enabled = true }, + { command = "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", enabled = true }, + { command = "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", enabled = true }, } return function() diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index 0b84ee7..6adbf1e 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -51,17 +51,17 @@ optional_bins = [ # pinned version URL when a key is set. [[pin]] { package, version } is # accepted as well and merged (conflict = bake error). [versions] -bakery = "0.7.1" -bread = "0.7.0" -bread-theme = "0.7.1" -breadbar = "0.3.0" -breadbox = "0.3.0" -breadcrumbs = "2.1.6" -breadpad = "0.5.0" -breadpaper = "0.1.11" -breadmon = "0.1.2" -breadsearch = "0.3.0" -breadclip = "0.1.1" -breadshot = "0.1.1" -bos-settings = "0.7.1" -breadhelp = "0.2.3" +bakery = "0.7.2" +bread = "0.8.0" +bread-theme = "0.7.2" +breadbar = "0.3.1" +breadbox = "0.3.1" +breadcrumbs = "2.1.7" +breadpad = "0.5.1" +breadpaper = "0.1.12" +breadmon = "0.1.3" +breadsearch = "0.3.1" +breadclip = "0.2.2" +breadshot = "0.1.2" +bos-settings = "0.8.0" +breadhelp = "0.2.4" From 59fa81de980f54b26a5a8ea60c466dc630e59a6f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:39:33 +0800 Subject: [PATCH 14/30] iso: pin bakery versions to the published stable index Newer git tags for breadpad, breadmon, breadclip, breadshot, breadhelp, and breadcrumbs have no artifacts on dl.breadway.dev because bakery release CI failed. Pin what the signed index actually serves so the ISO bake can verify sha256. --- iso/bread-lockfile.toml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index 6adbf1e..ac34e31 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -3,7 +3,7 @@ # 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 +# skipped with a warning when it does not (today: bread 0.8.0 has no # bread-emit / bread-module-host). # # A flat `bins` list is still accepted and treated as required_bins. @@ -47,21 +47,24 @@ optional_bins = [ "bread-module-host", ] -# Package name → version, matching today's stable index. CI prefers the -# pinned version URL when a key is set. [[pin]] { package, version } is -# accepted as well and merged (conflict = bake error). +# Package name → version. Must exist at dl.breadway.dev/// and +# should match the signed index so CI can verify sha256. Newer git tags +# that bakery release CI did not publish (breadpad 0.5.1, breadmon 0.1.3, +# breadclip 0.2.2, breadshot 0.1.2, breadhelp 0.2.4, breadcrumbs 2.1.7) +# stay off this list until those artifacts exist. +# [[pin]] { package, version } is accepted as well and merged (conflict = bake error). [versions] bakery = "0.7.2" bread = "0.8.0" bread-theme = "0.7.2" breadbar = "0.3.1" breadbox = "0.3.1" -breadcrumbs = "2.1.7" -breadpad = "0.5.1" +breadcrumbs = "2.1.6" +breadpad = "0.5.0" breadpaper = "0.1.12" -breadmon = "0.1.3" +breadmon = "0.1.2" breadsearch = "0.3.1" -breadclip = "0.2.2" -breadshot = "0.1.2" +breadclip = "0.1.1" +breadshot = "0.1.1" bos-settings = "0.8.0" -breadhelp = "0.2.4" +breadhelp = "0.2.3" From fd385bafae4018f71134d55338c7487c7c2264f5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:47:03 +0800 Subject: [PATCH 15/30] ci: install grub on the ISO builder profiledef.sh uses uefi.grub. mkarchiso checks for grub-install on the host before building; archiso does not pull grub, so the v0.6.0 bake aborted after bakery staging. --- .forgejo/workflows/release-iso.yml | 5 ++++- scripts/ci-verify-bake.sh | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index 7191c22..a30e291 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -41,7 +41,10 @@ jobs: steps: - name: Install build dependencies run: | - pacman -Syu --noconfirm archiso curl python git minisign + # grub is required by profiledef.sh bootmodes=('uefi.grub'): + # mkarchiso validates grub-install on the *builder*, not the image. + # archiso pulls syslinux/squashfs-tools/libisoburn; it does not pull grub. + pacman -Syu --noconfirm archiso grub curl python git minisign - name: Determine tag and version id: vars diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 1b01cab..d02d0b0 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -42,6 +42,12 @@ PY echo "== lockfile $LOCKFILE ==" echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +echo "== host tools ==" +if command -v grub-install >/dev/null 2>&1; then + ok "grub-install (uefi.grub bootmode)" +else + bad "grub-install missing — mkarchiso uefi.grub will abort (install grub on the builder)" +fi echo "== builder home $LAPTOP_HOME ==" check_exec() { From 744f18cd90788fd05ff0c9c23c1d8edc7ebb1fcd Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:59:38 +0800 Subject: [PATCH 16/30] iso: add bos-rescue, first-boot probe, optional Calamares refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-ISO bos-rescue finds the installed btrfs @ and ESP, then offers arch-chroot and/or the same GRUB NVRAM + --removable sequence as post-install.sh. Recovery is grub-btrfs or this reinstall — GRUB pins rootflags=subvol=@. bos-first-boot runs once after the first graphical login: NVIDIA offer file + notify (no driver install), VM-without-GL notify, HiDPI hint file (never rewrites monitors.json). Re-enable the Calamares packages module as a refresh-only step with skip_if_no_internet and ignore_update_db_error so offline installs cannot abort on pacman -Sy. --- README.md | 9 +- docs/hardware.md | 16 +- .../etc/calamares/modules/packages.conf | 26 +- iso/airootfs/etc/calamares/settings.conf | 12 +- .../etc/skel/.config/hypr/autostart.json | 1 + .../etc/skel/.config/hypr/hyprland.lua | 1 + .../.config/hypr/scripts/system/autostart.lua | 1 + iso/airootfs/usr/local/bin/bos-first-boot | 187 ++++++ iso/airootfs/usr/local/bin/bos-rescue | 598 ++++++++++++++++++ iso/profiledef.sh | 2 + scripts/smoke-test.sh | 3 + 11 files changed, 838 insertions(+), 18 deletions(-) create mode 100755 iso/airootfs/usr/local/bin/bos-first-boot create mode 100755 iso/airootfs/usr/local/bin/bos-rescue diff --git a/README.md b/README.md index ccaf576..5c7348c 100644 --- a/README.md +++ b/README.md @@ -360,14 +360,15 @@ not shipped. **The system won't boot (broken GRUB / lost EFI entry):** 1. Boot the BOS ISO and open a terminal (`SUPER+Return`). -2. Mount the installed root and EFI, then chroot: +2. Run `sudo bos-rescue`. It finds the installed btrfs `@` and the ESP, + prints the devices it will use, and asks `YES` before writing. It can + `arch-chroot` and/or reinstall GRUB with the same sequence the + installer uses (NVRAM + `--removable` + `grub-mkconfig`). +3. Manual equivalent, if you would rather type it: ```sh mount -o subvol=@ /dev/sdXN /mnt mount /dev/sdXP /mnt/boot/efi # the EFI partition arch-chroot /mnt - ``` -3. Reinstall the bootloader (the same sequence the installer uses): - ```sh grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck grub-mkconfig -o /boot/grub/grub.cfg diff --git a/docs/hardware.md b/docs/hardware.md index 4bc2d40..8add314 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -8,6 +8,15 @@ BOS ships the generic **Mesa** stack. AMD and Intel work out of the box. firmware is not on the image, and there is no Hyprland NVIDIA env wiring. Installing `nvidia` / `nvidia-utils` after the fact is not a product path. +On first graphical login, `bos-first-boot` probes `lspci` / `/proc` and, +if an NVIDIA GPU is present, writes `~/.local/state/bos/nvidia-offer.json` +and notifies that the proprietary driver is not on the ISO. It does **not** +install anything. bos-settings can grow a panel that reads that file later. + +The same probe leaves a HiDPI hint at `~/.local/state/bos/hidpi-hint.json` +when scale > 1 or the panel is dense; it never rewrites `monitors.json`. +A VM without `/dev/dri` gets a notification only. + ## Recovery An update that breaks the system is recovered from the **GRUB "snapshots" @@ -17,5 +26,8 @@ BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs entry so the kernel command line matches the snapshot you want. -A/B root swapping is not implemented. See the README Recovery section for -the "system will not boot" GRUB/EFI repair path. +If the system will not boot (lost EFI entry / broken GRUB), boot the live +ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same +`grub-install` NVRAM + `--removable` sequence as `post-install.sh`. + +A/B root swapping is not implemented. See the README Recovery section. diff --git a/iso/airootfs/etc/calamares/modules/packages.conf b/iso/airootfs/etc/calamares/modules/packages.conf index c327cb6..a3646e0 100644 --- a/iso/airootfs/etc/calamares/modules/packages.conf +++ b/iso/airootfs/etc/calamares/modules/packages.conf @@ -1,10 +1,24 @@ --- +# Optional online pacman refresh. The previous packages step used +# update_db:true with no skip/ignore, so `pacman -Sy` aborted offline +# installs (the case bos-netcheck exists for). skip_if_no_internet +# skips the whole module when Calamares sees no network; +# ignore_update_db_error keeps a flake-mirror -Sy from failing the +# install. update_system stays false — this is not a -Syu. +# +# try_install is empty: pipewire-pulse / pipewire-alsa already come +# from packages.x86_64 via unpackfs. No extra packages (and no +# nvidia) are pulled here. backend: pacman -options: - - update_db: true +skip_if_no_internet: true +update_db: true +ignore_update_db_error: true +update_system: false -operations: - - try_install: - - pipewire-pulse - - pipewire-alsa +pacman: + num_retries: 1 + disable_download_timeout: false + needed_only: true + +operations: [] diff --git a/iso/airootfs/etc/calamares/settings.conf b/iso/airootfs/etc/calamares/settings.conf index 8872182..1daec33 100644 --- a/iso/airootfs/etc/calamares/settings.conf +++ b/iso/airootfs/etc/calamares/settings.conf @@ -35,12 +35,6 @@ sequence: - users - networkcfg - hwclock - # packages module removed: it set update_db:true with no - # skip_if_no_internet/ignore_update_db_error, so an offline install (the - # exact case bos-welcome's nmtui step exists for) aborted here with a - # fatal pacman -Sy failure. Its only try_install packages (pipewire-pulse, - # pipewire-alsa) are already in packages.x86_64 and installed by - # unpackfs, so the step did nothing useful even when it succeeded. # archiso strips the kernel from the squashfs; stage it, drop the archiso # initramfs config, and write a stock mkinitcpio preset before initcpio runs. - shellprocess@kernel @@ -57,6 +51,12 @@ sequence: # BOS finalization: GRUB install + cleanup + snapper + services + dotfiles. # All fast, and runs after initcpio so /boot has the kernel + initramfs. - shellprocess + # Optional online pacman -Sy. After post-install so the target keyring + # exists. skip_if_no_internet + ignore_update_db_error: an offline + # install (or a flake-mirror -Sy) must not abort. operations is empty — + # pipewire-pulse/alsa already come from unpackfs; nothing extra (and + # no nvidia) is installed here. + - packages - umount - show: - finished diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json index 87de11a..ee66107 100644 --- a/iso/airootfs/etc/skel/.config/hypr/autostart.json +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -3,6 +3,7 @@ { "command": "breadbar", "label": "Bar (breadbar)", "enabled": true }, { "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true }, { "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true }, + { "command": "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", "label": "First-boot hardware probe", "enabled": true }, { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true }, diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 57360a3..d71731a 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -174,6 +174,7 @@ hl.on("hyprland.start", function() "breadbar", "hypridle", "bos-netcheck", + "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", "breadhelp --autostart", "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua index ce4465f..46f4970 100644 --- a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -18,6 +18,7 @@ local DEFAULT_EXTRA = { { command = "breadbar", enabled = true }, { command = "hypridle", enabled = true }, { command = "bos-netcheck", enabled = true }, + { command = "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", enabled = true }, { command = "breadhelp --autostart", enabled = true }, { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, diff --git a/iso/airootfs/usr/local/bin/bos-first-boot b/iso/airootfs/usr/local/bin/bos-first-boot new file mode 100755 index 0000000..d17fc70 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-first-boot @@ -0,0 +1,187 @@ +#!/bin/bash +# bos-first-boot — one-shot hardware probe after the first graphical login. +# +# Detects NVIDIA (offer file + notify; never auto-installs a driver), a VM +# without GL, and HiDPI (hint file only — never rewrites monitors.json). +# +# Non-fatal: missing tools, notify-send, or hyprctl must not block login. +# Guarded with `command -v`. Flag: ~/.local/state/bos/first-boot-done. +set -u + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/bos" +FLAG="$STATE_DIR/first-boot-done" +NVIDIA_OFFER="$STATE_DIR/nvidia-offer.json" +HIDPI_HINT="$STATE_DIR/hidpi-hint.json" +VM_HINT="$STATE_DIR/vm-gl-hint.json" + +# Never run on the live/installer session — only on an installed system. +[[ "$(id -un)" == "liveuser" ]] && exit 0 + +# Already probed this home. +[[ -f "$FLAG" ]] && exit 0 + +notify() { + local msg="$1" + local urgency="${2:-normal}" + command -v notify-send >/dev/null 2>&1 || return 0 + notify-send -u "$urgency" "BOS" "$msg" 2>/dev/null || true +} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +iso_now() { + date -Iseconds 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ +} + +# Best-effort: hyprland.start can beat the notification daemon by a beat. +if [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then + sleep 1 +fi + +mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 + +# --------------------------------------------------------------------------- +# NVIDIA — hardware only. Do not install nvidia / nvidia-utils. +# --------------------------------------------------------------------------- +nvidia_present=0 +nvidia_pci="" +if command -v lspci >/dev/null 2>&1; then + nvidia_pci="$(lspci -d 10de: -nn 2>/dev/null | grep -iE 'VGA|3D|Display' || true)" + [[ -n "$nvidia_pci" ]] && nvidia_present=1 +fi +if [[ "$nvidia_present" != "1" ]]; then + if [[ -d /proc/driver/nvidia || -d /sys/module/nvidia ]]; then + nvidia_present=1 + nvidia_pci="${nvidia_pci:-module}" + fi +fi +if [[ "$nvidia_present" == "1" ]]; then + cat >"$NVIDIA_OFFER" </dev/null 2>&1; then + virt="$(systemd-detect-virt 2>/dev/null || true)" + [[ -n "$virt" ]] || virt="none" +fi +has_gl=0 +shopt -s nullglob +dri_nodes=(/dev/dri/card* /dev/dri/renderD*) +(( ${#dri_nodes[@]} > 0 )) && has_gl=1 +shopt -u nullglob + +if [[ "$virt" != "none" && "$has_gl" != "1" ]]; then + cat >"$VM_HINT" < 1 from hyprctl, or computed DPI >= 140. +# --------------------------------------------------------------------------- +if command -v hyprctl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then + # Compositor may still be settling when autostart fires. + mon_json="" + tries=0 + while [[ -z "$mon_json" && "$tries" -lt 5 ]]; do + mon_json="$(hyprctl -j monitors 2>/dev/null || true)" + if [[ -z "$mon_json" || "$mon_json" == "[]" ]]; then + mon_json="" + sleep 1 + fi + tries=$((tries + 1)) + done + if [[ -n "$mon_json" ]]; then + BOS_HYPR_MONITORS="$mon_json" python3 - "$HIDPI_HINT" "$(iso_now)" <<'PY' || true +import json, os, sys +hint_path, noted_at = sys.argv[1], sys.argv[2] +try: + monitors = json.loads(os.environ.get("BOS_HYPR_MONITORS") or "") +except Exception: + sys.exit(0) +if not isinstance(monitors, list): + sys.exit(0) + +hits = [] +for m in monitors: + if not isinstance(m, dict): + continue + name = m.get("name") or m.get("output") or "" + try: + scale = float(m.get("scale") or 1) + except (TypeError, ValueError): + scale = 1.0 + try: + w = int(m.get("width") or 0) + h = int(m.get("height") or 0) + except (TypeError, ValueError): + w = h = 0 + mm_w = mm_h = 0 + phys = m.get("physicalSize") + if isinstance(phys, dict): + mm_w = phys.get("x") or phys.get("width") or 0 + mm_h = phys.get("y") or phys.get("height") or 0 + elif isinstance(phys, (list, tuple)) and len(phys) >= 2: + mm_w, mm_h = phys[0], phys[1] + else: + mm_w = m.get("physicalWidth") or 0 + mm_h = m.get("physicalHeight") or 0 + try: + mm_w = float(mm_w or 0) + mm_h = float(mm_h or 0) + except (TypeError, ValueError): + mm_w = mm_h = 0.0 + dpi = round(w / (mm_w / 25.4), 1) if mm_w and w else 0.0 + px_per_mm = round(w / mm_w, 3) if mm_w and w else 0.0 + # High px/mm (dense panel) or Hyprland already chose scale > 1. + hidpi = scale > 1.01 or dpi >= 140 + if hidpi: + hits.append({ + "name": name, + "width": w, + "height": h, + "scale": scale, + "dpi": dpi, + "px_per_mm": px_per_mm, + }) + +if not hits: + sys.exit(0) +with open(hint_path, "w") as f: + json.dump({ + "suggested": True, + "rewrote_monitors_json": False, + "reason": "scale > 1 or DPI >= 140", + "monitors": hits, + "noted_at": noted_at, + }, f, indent=2) + f.write("\n") +PY + fi +fi + +# Mark done even if every probe was a no-op — do not nag next login. +printf '%s\n' "$(iso_now)" >"$FLAG" 2>/dev/null || true +exit 0 diff --git a/iso/airootfs/usr/local/bin/bos-rescue b/iso/airootfs/usr/local/bin/bos-rescue new file mode 100755 index 0000000..f814865 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-rescue @@ -0,0 +1,598 @@ +#!/bin/bash +# bos-rescue — live-ISO helper for an installed BOS that will not boot. +# +# Finds the installed btrfs `@` and the ESP, mounts them, then offers to +# arch-chroot and/or reinstall GRUB using the same sequence as +# post-install.sh / README Recovery: +# UEFI: grub-install NVRAM + --removable, then grub-mkconfig +# BIOS: grub-install i386-pc onto the disk hosting / +# +# Recovery is this script or the GRUB "snapshots" submenu (grub-btrfs). +# GRUB pins rootflags=subvol=@ — a snapper-swapped default subvolume is +# not what the installed grub.cfg will boot. Never snapper-rollback. +# +# Safe: prints the devices it will use and requires YES before writing. +# Best-effort: do not use `set -e`; a failed probe must not abort the rest. +set -uo pipefail + +MNT="${BOS_RESCUE_MNT:-}" +MOUNTED_ROOT=0 +MOUNTED_ESP=0 +ROOT_DEV="" +ESP_DEV="" +ROOT_ENCRYPTED=0 + +bold() { printf '\033[1m%s\033[0m\n' "$1" >&2; } +info() { printf ' %s\n' "$1" >&2; } +warn() { printf 'WARN: %s\n' "$1" >&2; } + +usage() { + cat <<'EOF' +Usage: bos-rescue + +Live-ISO helper: find the installed BOS btrfs @ and ESP, mount them, +then arch-chroot and/or reinstall GRUB. + + UEFI: grub-install (NVRAM) + grub-install --removable + grub-mkconfig + BIOS: grub-install --target=i386-pc onto the disk hosting / + +Prints the devices it will use and asks YES before writing anything. + +Do not snapper-rollback. GRUB pins rootflags=subvol=@. Pick a grub-btrfs +snapshot entry, or reinstall GRUB with this script. + +Must be run as root. Intended from the live ISO (SUPER+Return). +EOF +} + +need_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "bos-rescue must run as root (sudo bos-rescue)." >&2 + exit 1 + fi +} + +confirm_yes() { + local prompt="$1" + local reply="" + printf '%s [type YES]: ' "$prompt" >&2 + read -r reply || return 1 + [[ "$reply" == "YES" ]] +} + +is_live_iso() { + [[ -d /run/archiso ]] || [[ -x /usr/local/bin/bos-live-setup ]] +} + +already_on_installed() { + # Installed BOS: / is the @ subvolume and this is not the live medium. + is_live_iso && return 1 + local src opts + src="$(findmnt -no SOURCE / 2>/dev/null | sed 's/\[.*\]//')" + opts="$(findmnt -no OPTIONS / 2>/dev/null || true)" + [[ -n "$src" ]] || return 1 + [[ "$opts" == *subvol=/@* || "$opts" == *subvol=@* ]] || return 1 + [[ -f /etc/os-release ]] && grep -qE '^ID=bos$' /etc/os-release +} + +pick_mnt() { + if [[ -n "$MNT" ]]; then + return + fi + if findmnt -n /mnt >/dev/null 2>&1; then + MNT=/mnt/bos-rescue + info "/mnt is already a mountpoint — using $MNT" + else + MNT=/mnt + fi +} + +lsblk_line() { + lsblk -pnlo NAME,FSTYPE,SIZE,LABEL,UUID,PARTTYPENAME "$1" 2>/dev/null | head -n1 +} + +# Open LUKS containers so a later btrfs scan can see @. +offer_luks() { + command -v cryptsetup >/dev/null || return 0 + local dev name reply + while read -r dev; do + [[ -n "$dev" ]] || continue + [[ -e "$dev" ]] || continue + if lsblk -no TYPE "$dev" 2>/dev/null | grep -qx crypt; then + continue + fi + # Skip already-mapped parents. + if lsblk -nlo TYPE "$dev" 2>/dev/null | grep -qx crypt; then + continue + fi + printf '\nLUKS container: %s\n %s\n' "$dev" "$(lsblk_line "$dev")" >&2 + printf 'Unlock this container? [y/N]: ' >&2 + read -r reply || reply="" + if [[ "$reply" == [yY] ]]; then + name="bos-rescue-$(basename "$dev")" + if cryptsetup open "$dev" "$name"; then + info "opened $dev as /dev/mapper/$name" + else + warn "cryptsetup open failed for $dev" + fi + fi + done < <(lsblk -pnlo NAME,FSTYPE | awk '$2 == "crypto_LUKS" { print $1 }') +} + +# Probe a btrfs device for an @ subvolume that looks like BOS (or any @). +# Prints: DEVICEKINDPRETTY where KIND is bos|other +probe_btrfs_dev() { + local dev="$1" + local tmp pretty kind id + tmp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || return 1 + kind="other" + pretty="" + if mount -o ro,subvol=@ "$dev" "$tmp" 2>/dev/null; then + if [[ -f "$tmp/etc/os-release" ]]; then + id="$(grep -E '^ID=' "$tmp/etc/os-release" | head -n1 | cut -d= -f2- | tr -d '"')" + pretty="$(grep -E '^PRETTY_NAME=' "$tmp/etc/os-release" | head -n1 | cut -d= -f2- | tr -d '"')" + [[ "$id" == "bos" ]] && kind="bos" + fi + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + rmdir "$tmp" 2>/dev/null || true + printf '%s\t%s\t%s\n' "$dev" "$kind" "${pretty:-btrfs @}" + return 0 + fi + # Some volumes only accept a top-level probe first. + if mount -o ro,subvolid=5 "$dev" "$tmp" 2>/dev/null; then + if [[ -d "$tmp/@" ]] || btrfs subvolume show "$tmp/@" &>/dev/null; then + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + rmdir "$tmp" 2>/dev/null || true + printf '%s\t%s\t%s\n' "$dev" "other" "btrfs @ (unreadable os-release)" + return 0 + fi + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + fi + rmdir "$tmp" 2>/dev/null || true + return 1 +} + +find_root_candidates() { + local dev + while read -r dev; do + [[ -n "$dev" ]] || continue + probe_btrfs_dev "$dev" || true + done < <(lsblk -pnlo NAME,FSTYPE | awk '$2 == "btrfs" { print $1 }') +} + +# Prefer the ESP named in the installed fstab; else EFI type / BOS bits. +find_esp_for_root() { + local root="$1" + local tmp fstab_uuid fstab_dev dev fstype parttype label + tmp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || return 1 + if mount -o ro,subvol=@ "$root" "$tmp" 2>/dev/null; then + if [[ -f "$tmp/etc/fstab" ]]; then + fstab_uuid="$(awk '$2 == "/boot/efi" { + if ($1 ~ /^UUID=/) { sub(/^UUID=/, "", $1); print $1; exit } + }' "$tmp/etc/fstab")" + fi + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + fi + rmdir "$tmp" 2>/dev/null || true + + if [[ -n "${fstab_uuid:-}" ]]; then + fstab_dev="$(blkid -U "$fstab_uuid" 2>/dev/null || true)" + if [[ -n "$fstab_dev" ]]; then + printf '%s\n' "$fstab_dev" + return 0 + fi + fi + + local best="" scored=0 score + # PARTTYPE is the GPT GUID — no spaces, unlike PARTTYPENAME ("EFI System"). + local efi_guid="c12a7328-f81f-11d2-ba4b-00a716dde993" + while read -r dev fstype parttype; do + [[ -n "$dev" ]] || continue + score=0 + [[ "$fstype" == "vfat" || "$fstype" == "fat32" || "$fstype" == "FAT-32" ]] && score=$((score + 1)) + [[ "${parttype,,}" == "$efi_guid" ]] && score=$((score + 3)) + if (( score > scored )); then + best="$dev" + scored=$score + fi + done < <(lsblk -pnlo NAME,FSTYPE,PARTTYPE) + + # Prefer an ESP that already has BOS or removable fallback bits. + local probe mp + for dev in $best $(lsblk -pnlo NAME,FSTYPE | awk '$2 == "vfat" { print $1 }'); do + [[ -n "$dev" ]] || continue + mp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || continue + if mount -o ro "$dev" "$mp" 2>/dev/null; then + if [[ -f "$mp/EFI/BOS/grubx64.efi" || -f "$mp/EFI/BOOT/BOOTX64.EFI" ]]; then + umount "$mp" 2>/dev/null || true + rmdir "$mp" 2>/dev/null || true + printf '%s\n' "$dev" + return 0 + fi + umount "$mp" 2>/dev/null || true + fi + rmdir "$mp" 2>/dev/null || true + done + + [[ -n "$best" ]] && printf '%s\n' "$best" +} + +select_from_list() { + local title="$1" + shift + local -a items=("$@") + local i choice + if (( ${#items[@]} == 0 )); then + return 1 + fi + if (( ${#items[@]} == 1 )); then + printf '%s\n' "${items[0]}" + return 0 + fi + bold "$title" + for i in "${!items[@]}"; do + printf ' %d) %s\n' "$((i + 1))" "${items[$i]}" >&2 + done + printf 'Select [1-%d]: ' "${#items[@]}" >&2 + read -r choice || return 1 + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#items[@]} )); then + printf '%s\n' "${items[$((choice - 1))]}" + return 0 + fi + return 1 +} + +discover_and_choose() { + bold "Scanning for an installed BOS (btrfs @) …" + offer_luks + + local -a bos_devs=() other_devs=() + local dev kind pretty line + while IFS=$'\t' read -r dev kind pretty; do + [[ -n "$dev" ]] || continue + line="$dev (${pretty:-$kind})" + if [[ "$kind" == "bos" ]]; then + bos_devs+=("$dev") + else + other_devs+=("$dev") + fi + info "found $line" + done < <(find_root_candidates) + + if (( ${#bos_devs[@]} == 0 && ${#other_devs[@]} == 0 )); then + echo "No btrfs @ subvolume found. Unlock LUKS first if the install is encrypted." >&2 + return 1 + fi + + if (( ${#bos_devs[@]} == 1 )); then + ROOT_DEV="${bos_devs[0]}" + info "Using BOS root $ROOT_DEV" + elif (( ${#bos_devs[@]} > 1 )); then + ROOT_DEV="$(select_from_list "More than one BOS @ found:" "${bos_devs[@]}")" || return 1 + else + warn "No ID=bos os-release on @ — offering every btrfs @ found" + ROOT_DEV="$(select_from_list "Select the installed root device:" "${other_devs[@]}")" || return 1 + fi + + ESP_DEV="$(find_esp_for_root "$ROOT_DEV" || true)" + if [[ -n "$ESP_DEV" ]]; then + info "Using ESP $ESP_DEV" + fi + if [[ -z "$ESP_DEV" ]]; then + local -a esps=() + while read -r dev; do + [[ -n "$dev" ]] && esps+=("$dev") + done < <(lsblk -pnlo NAME,FSTYPE,PARTTYPE | awk ' + $2 == "vfat" || tolower($3) == "c12a7328-f81f-11d2-ba4b-00a716dde993" { print $1 } + ') + if (( ${#esps[@]} == 0 )); then + warn "No ESP found. GRUB reinstall on UEFI will fail; chroot is still available." + else + ESP_DEV="$(select_from_list "Select the EFI System Partition:" "${esps[@]}")" || true + fi + fi +} + +mount_install() { + pick_mnt + mkdir -p "$MNT" + if ! findmnt -n "$MNT" >/dev/null 2>&1; then + if ! mount -o subvol=@ "$ROOT_DEV" "$MNT"; then + warn "failed to mount $ROOT_DEV subvol=@ at $MNT" + return 1 + fi + MOUNTED_ROOT=1 + fi + if [[ -n "$ESP_DEV" ]]; then + mkdir -p "$MNT/boot/efi" + if ! findmnt -n "$MNT/boot/efi" >/dev/null 2>&1; then + if mount "$ESP_DEV" "$MNT/boot/efi"; then + MOUNTED_ESP=1 + else + warn "failed to mount ESP $ESP_DEV at $MNT/boot/efi" + fi + fi + fi + if [[ "$(lsblk -no TYPE "$ROOT_DEV" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 + fi +} + +unmount_install() { + if [[ "$MOUNTED_ESP" == "1" ]]; then + umount "$MNT/boot/efi" 2>/dev/null || umount -l "$MNT/boot/efi" 2>/dev/null || true + MOUNTED_ESP=0 + fi + if [[ "$MOUNTED_ROOT" == "1" ]]; then + umount "$MNT" 2>/dev/null || umount -l "$MNT" 2>/dev/null || true + MOUNTED_ROOT=0 + fi +} + +print_plan() { + echo >&2 + bold "Devices" + info "root: ${ROOT_DEV:-unset} $([[ -n "$ROOT_DEV" ]] && lsblk_line "$ROOT_DEV")" + info "ESP: ${ESP_DEV:-none} $([[ -n "$ESP_DEV" ]] && lsblk_line "$ESP_DEV")" + info "mount: ${MNT:-unset}" + if [[ -d /sys/firmware/efi ]]; then + info "firmware: UEFI" + else + info "firmware: BIOS" + fi + if [[ "$ROOT_ENCRYPTED" == "1" ]]; then + info "root is LUKS (grub-install will include cryptodisk modules)" + fi + echo >&2 + info "Recovery is grub-btrfs (GRUB snapshots submenu) or this GRUB reinstall." + info "GRUB pins rootflags=subvol=@ — do not swap the default subvolume." +} + +run_in_target() { + local cmd="$1" + if command -v arch-chroot >/dev/null; then + arch-chroot "$MNT" bash -c "$cmd" + return $? + fi + # arch-install-scripts is not guaranteed on the ISO — bind the API + # filesystems the same way arch-chroot would, then chroot. + mount --bind /proc "$MNT/proc" 2>/dev/null || mount -t proc proc "$MNT/proc" + mount --bind /sys "$MNT/sys" 2>/dev/null || mount -t sysfs sys "$MNT/sys" + mount --bind /dev "$MNT/dev" 2>/dev/null || mount -t devtmpfs udev "$MNT/dev" + mkdir -p "$MNT/run" + mount --bind /run "$MNT/run" 2>/dev/null || mount -t tmpfs tmpfs "$MNT/run" + if [[ -d /sys/firmware/efi ]]; then + mkdir -p "$MNT/sys/firmware/efi/efivars" + mount -t efivarfs efivarfs "$MNT/sys/firmware/efi/efivars" 2>/dev/null || true + fi + chroot "$MNT" bash -c "$cmd" + local rc=$? + umount "$MNT/sys/firmware/efi/efivars" 2>/dev/null || true + umount "$MNT/run" 2>/dev/null || true + umount "$MNT/dev" 2>/dev/null || true + umount "$MNT/sys" 2>/dev/null || true + umount "$MNT/proc" 2>/dev/null || true + return "$rc" +} + +grub_commands_preview() { + if [[ -d /sys/firmware/efi ]]; then + cat <<'EOF' >&2 + grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck + grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck + grub-mkconfig -o /boot/grub/grub.cfg +EOF + else + cat <<'EOF' >&2 + grub-install --target=i386-pc --recheck + grub-mkconfig -o /boot/grub/grub.cfg +EOF + fi +} + +reinstall_grub() { + if [[ ! -d "$MNT/boot" ]]; then + warn "target $MNT/boot missing — mount the installed @ first" + return 1 + fi + echo >&2 + bold "This will write a bootloader using:" + info "root ${ROOT_DEV:-/} ESP ${ESP_DEV:-n/a} chroot $MNT" + grub_commands_preview + echo >&2 + if ! confirm_yes "Reinstall GRUB now?"; then + info "skipped" + return 0 + fi + + # Same sequence as post-install.sh (UEFI NVRAM + --removable, or BIOS MBR). + local script + script="$(cat <<'EOS' +set -uo pipefail +ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" +if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 +else + ROOT_ENCRYPTED=0 +fi +if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \ + && ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then + echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \ + || echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed" +fi +if ! command -v grub-install >/dev/null; then + echo "ERROR: grub-install not found in the installed system" >&2 + exit 1 +fi +CRYPT_MODULES=() +[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2") +if [[ -d /sys/firmware/efi ]]; then + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (nvram) failed" + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --removable --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (removable) failed" +else + ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" + ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)" + if [[ -n "$ROOT_DISK" ]]; then + grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \ + || echo "WARN: grub-install (BIOS) failed" + else + echo "WARN: could not determine the disk hosting / — BIOS grub-install skipped" + fi +fi +if command -v grub-mkconfig >/dev/null; then + grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" +else + echo "WARN: grub-mkconfig not found" +fi +EOS +)" + if run_in_target "$script"; then + bold "GRUB reinstall finished." + info "Firmware that lost its NVRAM entry can still boot EFI/BOOT/BOOTX64.EFI." + else + warn "GRUB reinstall returned non-zero — see messages above" + return 1 + fi +} + +do_chroot() { + if [[ ! -d "$MNT/etc" ]]; then + warn "target $MNT is not a mounted system" + return 1 + fi + bold "Entering chroot at $MNT (exit to return)." + if command -v arch-chroot >/dev/null; then + arch-chroot "$MNT" + else + run_in_target "exec bash -l" + fi +} + +menu_live() { + local choice + while true; do + echo + bold "bos-rescue" + print_plan + cat <<'EOF' >&2 + 1) arch-chroot into the installed system + 2) Reinstall GRUB (NVRAM + --removable + grub-mkconfig) + 3) Reinstall GRUB, then chroot + 4) Unmount and quit + q) Quit (leave mounts) +EOF + printf 'Choice: ' >&2 + read -r choice || choice="q" + case "$choice" in + 1) do_chroot ;; + 2) reinstall_grub ;; + 3) reinstall_grub; do_chroot ;; + 4) unmount_install; bold "Unmounted."; return 0 ;; + q|Q) info "Leaving mounts in place at $MNT"; return 0 ;; + *) info "unknown choice" ;; + esac + done +} + +menu_installed() { + ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" + ESP_DEV="$(findmnt -no SOURCE /boot/efi 2>/dev/null || true)" + MNT="/" + if [[ "$(lsblk -no TYPE "$ROOT_DEV" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 + fi + echo + bold "Already running the installed BOS (not the live ISO)." + info "Root and ESP are already mounted — chroot is not needed." + print_plan + if confirm_yes "Reinstall GRUB on this running system?"; then + # Running on the installed root: no extra mount/chroot. + local old_mnt="$MNT" + MNT="/" + # run_in_target would chroot into / — just run locally. + if [[ -d /sys/firmware/efi && -z "$ESP_DEV" ]]; then + warn " /boot/efi is not mounted — refusing to write" + return 1 + fi + bash -c "$(cat <<'EOS' +set -uo pipefail +ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" +if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 +else + ROOT_ENCRYPTED=0 +fi +if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \ + && ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then + echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \ + || echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed" +fi +CRYPT_MODULES=() +[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2") +if [[ -d /sys/firmware/efi ]]; then + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (nvram) failed" + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --removable --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (removable) failed" +else + ROOT_DISK="$(lsblk -no pkname "$ROOT_SRC" 2>/dev/null)" + if [[ -n "$ROOT_DISK" ]]; then + grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \ + || echo "WARN: grub-install (BIOS) failed" + fi +fi +grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" +EOS +)" + MNT="$old_mnt" + else + info "skipped" + fi +} + +main() { + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 + fi + need_root + local req + for req in mount lsblk blkid findmnt; do + if ! command -v "$req" >/dev/null; then + echo "bos-rescue: missing required tool '$req'" >&2 + exit 1 + fi + done + bold "bos-rescue" + info "Live-ISO recovery helper. Prints devices and asks YES before writing." + info "Use grub-btrfs (GRUB snapshots submenu) for a bootable snapshot." + info "Do not snapper-rollback — GRUB pins rootflags=subvol=@." + echo + + if already_on_installed; then + menu_installed + return 0 + fi + + if ! is_live_iso; then + warn "This does not look like the BOS live ISO (/run/archiso missing)." + info "Continuing anyway — will scan disks for a BOS @." + fi + + discover_and_choose || exit 1 + print_plan + if ! confirm_yes "Mount these devices and continue?"; then + info "nothing mounted" + exit 0 + fi + mount_install || exit 1 + menu_live +} + +main "$@" diff --git a/iso/profiledef.sh b/iso/profiledef.sh index c438138..5034cbc 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -29,4 +29,6 @@ file_permissions=( ["/usr/local/bin/bos-session"]="0:0:755" ["/usr/local/bin/bos-netcheck"]="0:0:755" ["/usr/local/bin/bos-update"]="0:0:755" + ["/usr/local/bin/bos-rescue"]="0:0:755" + ["/usr/local/bin/bos-first-boot"]="0:0:755" ) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ab70559..19e7d75 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -65,6 +65,8 @@ check "breadhelp installed" "command -v breadhelp" check "breadhelp content installed" \ "[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]" check "bos-netcheck present" "command -v bos-netcheck" +check "bos-rescue present" "command -v bos-rescue" +check "bos-first-boot present" "command -v bos-first-boot" echo "== default dotfiles ==" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" @@ -72,6 +74,7 @@ check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]" check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]" check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]" check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]" +check "autostart includes first-boot probe" "grep -q bos-first-boot \"\$HOME/.config/hypr/autostart.json\"" check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]" check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]" check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]" From 34043086b964cde8c98fc4088c175bdde62be31a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:10:32 +0800 Subject: [PATCH 17/30] iso: bake bakery apps into /usr/local BOS opts in to bakery's system prefix so desktop apps live on @ and ride snapper/grub-btrfs snapshots. The builder home stays ~/.local; build-local.sh copies bins, share/data, and user units onto the image. Per-user installed.json and the index cache stay in skel. Recovery is still grub-btrfs, not snapper rollback. --- AGENTS.md | 5 +- README.md | 27 ++-- build-local.sh | 122 ++++++++++++------ docs/hardware.md | 4 +- iso/airootfs/etc/bakery/config.toml | 2 + iso/airootfs/etc/calamares/post-install.sh | 7 +- iso/airootfs/etc/greetd/breadgreet.toml | 5 +- iso/airootfs/etc/pacman.conf | 2 +- iso/airootfs/etc/profile.d/bos-local-bin.sh | 10 +- .../etc/skel/.config/hypr/hyprland.lua | 11 +- .../skel/.config/systemd/user/breadd.service | 4 +- iso/airootfs/etc/skel/.zshrc | 2 +- iso/airootfs/usr/local/bin/bos-session | 9 +- iso/airootfs/usr/local/bin/bos-update | 16 +-- iso/bread-lockfile.toml | 2 +- iso/packages.x86_64 | 4 +- iso/pacman.conf | 2 +- scripts/ci-verify-bake.sh | 85 ++++++++++-- scripts/smoke-test.sh | 2 +- 19 files changed, 218 insertions(+), 103 deletions(-) create mode 100644 iso/airootfs/etc/bakery/config.toml diff --git a/AGENTS.md b/AGENTS.md index 3a9f4bf..8019d5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,4 +52,7 @@ There is no `dev` integration branch. - Don't bake an ISO (`sudo ./build-local.sh`) unless asked — lockfile/docs work does not require it. - Don't tell users to `snapper rollback` blindly; GRUB pins - `rootflags=subvol=@`. Recovery is grub-btrfs reboot. + `rootflags=subvol=@`. Recovery is grub-btrfs reboot. Bakery desktop + apps on BOS are system-prefix `/usr/local` (`/etc/bakery/config.toml`); + snapper `@` snapshots include them. Do not move those bits back to + `~/.local` on the image (hermes / default bakery stay user-layout). diff --git a/README.md b/README.md index 5c7348c..387c5f4 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ wiring up dotfiles, no per-tool bakery installs. - **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated 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 +- **bread ecosystem**, baked into `/usr/local` from bakery-managed binaries + (no network needed at install time; per-user bakery state is seeded in + `/etc/skel`): the `bread`/`breadd` automation daemon (`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), @@ -58,7 +59,7 @@ wiring up dotfiles, no per-tool bakery installs. | Channel | What | |---------|------| -| **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, 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 `/usr/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` | @@ -123,9 +124,15 @@ Push `origin` (and `github` when mirroring). Do not treat origin as GitHub. ## Building the ISO -`build-local.sh` builds the image natively (no container) and bakes this -machine's bakery-installed bread binaries + breadhelp content into -`/etc/skel`: +`build-local.sh` builds the image natively (no container) and copies this +machine's bakery-installed bread binaries + breadhelp content from the +builder's `~/.local` into the image at `/usr/local` (bins, share/data, +desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user +bakery state (`installed.json` + index cache) is seeded in `/etc/skel`. +BOS opts in via `/etc/bakery/config.toml` (`prefix = "/usr/local"`); +default bakery without that file is still `~/.local`. Snapper `@` +snapshots include `/usr/local`; recovery is still grub-btrfs, not +`snapper rollback`. ```sh sudo ./build-local.sh # release-quality (xz squashfs) @@ -190,8 +197,8 @@ Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to avoid memory pressure. Post-install, `scripts/smoke-test.sh` (run as the installed user) checks -subvolumes, services, bakery bins, and breadhelp content under -`~/.local/share/breadhelp/content`. +subvolumes, services, bakery bins on PATH, and breadhelp content under +`/usr/local/share/breadhelp/content`. ## bos-settings @@ -234,7 +241,7 @@ repo, not here. ## The bread ecosystem Everything below is a separate bakery-distributed project with its own repo -and release cadence, baked into `/etc/skel` at ISO build time so a fresh +and release cadence, baked into `/usr/local` at ISO build time so a fresh install has them all with no network round-trip. Some ship more than one binary from a single package — that's noted where it applies. Most have a corresponding **bos-settings** panel; this table is about *using* the app @@ -258,7 +265,7 @@ directly. | `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` | | `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` | | `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search | -| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `~/.local/share/breadhelp/content` (bakery `content.tar.gz`, baked into skel). | `SUPER+/` | +| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `/usr/local/share/breadhelp/content` (bakery `content.tar.gz`, baked into the image). | `SUPER+/` | **System** diff --git a/build-local.sh b/build-local.sh index b06f58d..8548d86 100755 --- a/build-local.sh +++ b/build-local.sh @@ -41,13 +41,15 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then fi grep airootfs_image_tool_options "$STAGE/profiledef.sh" -# --- Bake this machine's bakery-installed bread ecosystem into /etc/skel ------ +# --- Bake this machine's bakery-installed bread ecosystem into the image ------ # The bread desktop apps are bakery-managed (release binaries from # dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time, # which the live/installed image doesn't have — so instead of running bakery # on the target, we copy the binaries + bakery manifest this builder already -# has into skel. Every user created from skel then gets those versions fully -# offline. Copied at build time so the binaries never bloat the git repo. +# has. Builder home stays user-layout (~/.local); the *image* is system-prefix +# /usr/local so apps live on @ and ride snapper/grub-btrfs snapshots. +# installed.json + index cache stay per-user in skel. 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. required_bins fail the bake if @@ -97,9 +99,14 @@ BAKERY_BIN="$LAPTOP_HOME/.local/bin" BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery" BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery" BAKERY_SHARE="$LAPTOP_HOME/.local/share" -SKEL="$STAGE/airootfs/etc/skel" +AIROOTFS="$STAGE/airootfs" +IMAGE_BIN="$AIROOTFS/usr/local/bin" +IMAGE_SHARE="$AIROOTFS/usr/local/share" +IMAGE_UNITS="$AIROOTFS/usr/lib/systemd/user" +SKEL="$AIROOTFS/etc/skel" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)" +echo "image prefix: /usr/local (bins $IMAGE_BIN, share $IMAGE_SHARE, units $IMAGE_UNITS)" missing=() for b in "${REQUIRED_BINS[@]}"; do @@ -124,9 +131,9 @@ for b in "${OPTIONAL_BINS[@]}"; do fi done -install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" +install -d -m 0755 "$IMAGE_BIN" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" for b in "${BREAD_BINS[@]}"; do - install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b" + install -m 0755 "$BAKERY_BIN/$b" "$IMAGE_BIN/$b" done # Drop packages that are not in the lockfile (breadcast/breadarr must not @@ -164,26 +171,27 @@ if [[ ! -f "$BAKERY_CACHE/index.json" ]]; then exit 1 fi install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json" -echo "baked bins: $(ls "$SKEL/.local/bin")" +echo "baked bins: $(ls "$IMAGE_BIN")" # --- Bake bakery data dirs the apps need offline ------------------------------ # bakery extracts data_archive (breadhelp's content.tar.gz) to -# ~/.local/share// and writes desktop entries + licenses next to it. -# Copy those — never laptop-local state (clipboard history, WebKit cache, -# bread sync-repo, models). -echo "=== baking bakery share/data into skel ===" +# $prefix/share// and writes desktop entries + licenses next to it. +# Builder home is still ~/.local/share; copy into the image at +# /usr/local/share. Never laptop-local state (clipboard history, WebKit +# cache, bread sync-repo, models). +echo "=== baking bakery share/data into /usr/local/share ===" BREADHELP_CONTENT="$BAKERY_SHARE/breadhelp/content" if [[ ! -d "$BREADHELP_CONTENT" ]]; then echo "ERROR: breadhelp content missing: $BREADHELP_CONTENT" >&2 - echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content" >&2 + echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content on the builder" >&2 echo "A breadhelp binary without content is a hollow ISO." >&2 exit 1 fi -install -d -m 0755 "$SKEL/.local/share" -cp -a "$BAKERY_SHARE/breadhelp" "$SKEL/.local/share/breadhelp" -echo " baked $SKEL/.local/share/breadhelp/content" +install -d -m 0755 "$IMAGE_SHARE" +cp -a "$BAKERY_SHARE/breadhelp" "$IMAGE_SHARE/breadhelp" +echo " baked $IMAGE_SHARE/breadhelp/content" -python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$SKEL/.local/share" "${BREAD_BINS[@]}" <<'PY' +python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$IMAGE_SHARE" "${BREAD_BINS[@]}" <<'PY' import json, os, shutil, sys index_path, src_share, dest_share, *bins = sys.argv[1:] wanted = set(bins) @@ -242,14 +250,16 @@ PY # silently left out, so those daemons never start on a fresh install/live # boot until the user re-runs `bakery install` (which needs network). # Source of truth is the *filtered* installed.json we just wrote: only -# lockfile packages. Copy each unit with ExecStart rewritten from this -# laptop's literal home path to the portable `%h` specifier, and recreate -# whichever *.target.wants enable symlink bakery created locally. Units -# already committed by hand (breadd.service carries a -# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are left alone. -echo "=== baking bakery service units into skel ===" +# lockfile packages. Units go to /usr/lib/systemd/user with ExecStart +# rewritten to /usr/local/bin (not %h/.local/bin). Recreate whichever +# *.target.wants enable symlink bakery created locally (or that skel +# already ships). Hand-committed skel units (breadd.service carries a +# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source +# for that unit and also get their ExecStart rewritten in skel. +echo "=== baking bakery service units into /usr/lib/systemd/user ===" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user" +install -d -m 0755 "$IMAGE_UNITS" mapfile -t SERVICE_UNITS < <(python3 - "$SKEL/.local/state/bakery/installed.json" <<'PY' import json, sys with open(sys.argv[1]) as f: @@ -259,40 +269,72 @@ for pkg in d.get("packages", d).values(): print(s["unit"] if isinstance(s, dict) else s) PY ) +rewrite_exec_start() { + local src="$1" dest="$2" + python3 - "$src" "$dest" <<'PY' +import os, sys +src, dest = sys.argv[1], sys.argv[2] +text = open(src).read() +lines = [] +for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + key, rest = line.split("=", 1) + argv = rest.split() + if argv: + name = os.path.basename(argv[0]) + argv[0] = "/usr/local/bin/" + name + line = key + "=" + " ".join(argv) + lines.append(line) +out = "\n".join(lines) +if text.endswith("\n"): + out += "\n" +os.makedirs(os.path.dirname(dest), exist_ok=True) +with open(dest, "w") as f: + f.write(out) +PY +} for unit in "${SERVICE_UNITS[@]}"; do [[ -n "$unit" ]] || continue if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then - echo " $unit already committed in skel, leaving as-is" - continue + src="$SKEL_SYSTEMD/$unit" + echo " $unit using committed skel unit as source" + else + src="$SYSTEMD_USER_DIR/$unit" + if [[ ! -f "$src" ]]; then + echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 + echo "Refusing to bake an image whose daemons will never start." >&2 + exit 1 + fi fi - src="$SYSTEMD_USER_DIR/$unit" - if [[ ! -f "$src" ]]; then - echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 - echo "Refusing to bake a skel whose daemons will never start." >&2 - exit 1 + rewrite_exec_start "$src" "$IMAGE_UNITS/$unit" + if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then + rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit" fi - install -d -m 0755 "$SKEL_SYSTEMD" - sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit" - for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do - [[ -L "$wants_dir/$unit" ]] || continue - target_name="$(basename "$wants_dir")" - install -d -m 0755 "$SKEL_SYSTEMD/$target_name" - ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit" + for base in "$SYSTEMD_USER_DIR" "$SKEL_SYSTEMD"; do + [[ -d "$base" ]] || continue + for wants_dir in "$base"/*.target.wants; do + [[ -e "$wants_dir" || -L "$wants_dir" ]] || continue + [[ -L "$wants_dir/$unit" ]] || continue + target_name="$(basename "$wants_dir")" + install -d -m 0755 "$IMAGE_UNITS/$target_name" + ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit" + done done - echo " baked $unit" + echo " baked $unit -> $IMAGE_UNITS/$unit" done # mkarchiso resets every airootfs file to 0644, so executables must be declared # in profiledef.sh's file_permissions array or they ship non-executable and the # exec-once launches fail with "permission denied". Inject a 0755 entry for each -# baked binary right after the array opener (keeps the binary list in one place). +# baked bakery binary right after the array opener (bos-* bins are already +# listed; keeps the bakery list in one place — the lockfile). perm_file="$(mktemp)" for b in "${BREAD_BINS[@]}"; do - printf ' ["/etc/skel/.local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file" + printf ' ["/usr/local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file" done sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh" rm -f "$perm_file" -echo "=== file_permissions after injection ==="; grep -A14 '^file_permissions=(' "$STAGE/profiledef.sh" +echo "=== file_permissions after injection ==="; grep -A40 '^file_permissions=(' "$STAGE/profiledef.sh" # Pin one timestamp for the whole build. Without this, mkarchiso derives the # boot-config UUID (%ARCHISO_UUID%) when it starts and the iso9660 volume UUID diff --git a/docs/hardware.md b/docs/hardware.md index 8add314..2f87838 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -24,7 +24,9 @@ submenu** (grub-btrfs), not `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs -entry so the kernel command line matches the snapshot you want. +entry so the kernel command line matches the snapshot you want. Bakery +desktop apps live under `/usr/local` on `@`, so those same snapshots +include them. If the system will not boot (lost EFI entry / broken GRUB), boot the live ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same diff --git a/iso/airootfs/etc/bakery/config.toml b/iso/airootfs/etc/bakery/config.toml new file mode 100644 index 0000000..996b04a --- /dev/null +++ b/iso/airootfs/etc/bakery/config.toml @@ -0,0 +1,2 @@ +# Bakery desktop apps live under /usr/local so they ride snapper @ snapshots. +prefix = "/usr/local" diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 2076dcf..ac13a97 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -425,9 +425,10 @@ if command -v ufw &>/dev/null; then fi # The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs, -# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: the -# binaries and bakery manifest live in /etc/skel/.local (baked in at ISO -# build time from iso/bread-lockfile.toml) and are copied into the user's +# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: +# binaries, share/data, and user units are baked into /usr/local and +# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json +# + index cache) is seeded from /etc/skel/.local and copied into the user's # home below, so the install works fully offline with no DNS for bakery. # --------------------------------------------------------------------------- diff --git a/iso/airootfs/etc/greetd/breadgreet.toml b/iso/airootfs/etc/greetd/breadgreet.toml index 311dfad..24b48a4 100644 --- a/iso/airootfs/etc/greetd/breadgreet.toml +++ b/iso/airootfs/etc/greetd/breadgreet.toml @@ -7,8 +7,9 @@ # alongside BOS's own bos.desktop, and breadgreet's session picker matches by # .desktop file stem — with no override it picks "hyprland.desktop" over # "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for -# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is -# never sourced any other way). Confirmed via breadgreet's own test suite +# per-user tools; bakery apps are in /usr/local/bin). greetd starts no login +# shell, so /etc/profile.d is never sourced any other way. Confirmed via +# breadgreet's own test suite # (sessions.rs: discover_prefers_configured_default_over_first_entry). [sessions] diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 7e15c7a..4e4435e 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -29,7 +29,7 @@ Include = /etc/pacman.d/mirrorlist # Breadway custom repo — breadlock plus AUR republishes the ISO needs # (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, # zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp -# are NOT here; they are bakery-baked into /etc/skel at ISO build time. +# are NOT here; they are bakery-baked into /usr/local at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). diff --git a/iso/airootfs/etc/profile.d/bos-local-bin.sh b/iso/airootfs/etc/profile.d/bos-local-bin.sh index 642af43..734db46 100644 --- a/iso/airootfs/etc/profile.d/bos-local-bin.sh +++ b/iso/airootfs/etc/profile.d/bos-local-bin.sh @@ -1,8 +1,8 @@ -# Put the per-user bakery bin dir on PATH. The bread ecosystem (breadd, breadbar, -# breadbox, …) is installed there by bakery, and the Hyprland session launches -# them via `exec-once`, which resolves against the PATH it inherits from the -# login shell. Arch's stock /etc/profile does not add ~/.local/bin, so do it here -# for every login shell (live user and installed user alike). +# Keep ~/.local/bin on PATH for per-user tools. Arch already includes +# /usr/local/bin (where bakery desktop apps live on BOS). The Hyprland +# session resolves exec-once against the PATH it inherits from the login +# shell; Arch's stock /etc/profile does not add ~/.local/bin, so do it +# here for every login shell (live user and installed user alike). case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index d71731a..07ed155 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -124,8 +124,8 @@ hl.on("hyprland.start", function() "gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice", "gsettings set org.gnome.desktop.interface cursor-size 24", -- Clipboard history is breadclipd, a bakery-managed systemd --user - -- service (auto-started via skel — see build-local.sh's service bake) - -- rather than an exec-once here. + -- service (auto-started from /usr/lib/systemd/user — see + -- build-local.sh's service bake) rather than an exec-once here. "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", "awww-daemon", -- Set the default wallpaper once the daemon is up (retry until ready). @@ -140,8 +140,8 @@ hl.on("hyprland.start", function() -- breadpaper reads) is baked into skel too, right beside colors.json. -- pywal only runs for real once the user picks a wallpaper themselves. [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], - -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service, - -- enabled in skel). It autostarts at login but before Hyprland exists, so + -- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service, + -- plus a skel copy). It autostarts at login but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd -- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland. "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE", @@ -162,7 +162,8 @@ hl.on("hyprland.start", function() -- breadbox-sync is a Type=oneshot systemd --user service -- (WantedBy=default.target, no Hyprland IPC dependency) — it already - -- runs on login via the unit baked into skel, independent of this list. + -- runs on login via the unit baked into /usr/lib/systemd/user, + -- independent of this list. local ok, extra = pcall(function() return dofile(script_dir .. "system/autostart.lua")() end) diff --git a/iso/airootfs/etc/skel/.config/systemd/user/breadd.service b/iso/airootfs/etc/skel/.config/systemd/user/breadd.service index 49d6741..945c09c 100644 --- a/iso/airootfs/etc/skel/.config/systemd/user/breadd.service +++ b/iso/airootfs/etc/skel/.config/systemd/user/breadd.service @@ -3,8 +3,8 @@ Description=Bread Runtime Daemon [Service] Type=simple -# %h = the user's home — works for any account created from this skel. -ExecStart=%h/.local/bin/breadd +# System-prefix bakery install — same path for every account. +ExecStart=/usr/local/bin/breadd Restart=on-failure RestartSec=2 UMask=0077 diff --git a/iso/airootfs/etc/skel/.zshrc b/iso/airootfs/etc/skel/.zshrc index e4f1e69..3de4cc4 100644 --- a/iso/airootfs/etc/skel/.zshrc +++ b/iso/airootfs/etc/skel/.zshrc @@ -89,7 +89,7 @@ alias alt-install='yay -S' alias alt-uninstall='yay -R' alias alt-srchpkg='yay -Ss' -# ~/.local/bin holds the bread* binaries baked in at build time. +# Per-user tools. Bakery desktop apps live in /usr/local/bin (already on PATH). export PATH="$HOME/.local/bin:$PATH" # Powerlevel10k prompt configuration. diff --git a/iso/airootfs/usr/local/bin/bos-session b/iso/airootfs/usr/local/bin/bos-session index d7655fc..5f27974 100644 --- a/iso/airootfs/usr/local/bin/bos-session +++ b/iso/airootfs/usr/local/bin/bos-session @@ -2,11 +2,10 @@ # BOS graphical session launcher, run by greetd on the INSTALLED system after # the user authenticates (see /etc/greetd/config.toml). # -# greetd does not start a login shell, so /etc/profile.d is never sourced — which -# means ~/.local/bin (where bakery installs the bread ecosystem: breadd, breadbar, -# breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once` -# launches would fail. Source the login profile here so PATH is correct, set the -# Wayland session hints, then hand off to Hyprland. +# greetd does not start a login shell, so /etc/profile.d is never sourced. +# Bakery desktop apps live in /usr/local/bin (already on Arch PATH). Source +# the login profile here so ~/.local/bin (per-user tools) is also on PATH, +# set the Wayland session hints, then hand off to Hyprland. # # Launched via start-hyprland (ships with the hyprland package) rather than the # raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 65f27c0..3b3cc04 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -8,11 +8,12 @@ # 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 /usr/local (whatever `bakery list` # reports as installed — bakery, bread, breadbar, breadbox, # breadcrumbs, breadpad, breadman, bread-theme, breadpaper, # breadmon, breadsearch, breadclip, breadshot, bos-settings, -# breadhelp, ...). +# breadhelp, ...). Those bits live on @ and ride snapper +# root snapshots; recover via grub-btrfs, not `snapper rollback`. # # Best-effort: a failure in one channel doesn't abort the other. set -uo pipefail @@ -20,12 +21,11 @@ set -uo pipefail bold() { printf '\033[1m%s\033[0m\n' "$1"; } # Timed snapper pre snapshot before either channel. snap-pac already -# snapshots root around pacman; bakery writes ~/.local/bin ($HOME / @home), -# which is outside that root snapshot. This extra snapshot is still -# best-effort and covers bakery $HOME updates as well as possible — a -# home config if the installer created one, otherwise the root timeline -# around the whole update. Never fail the update if snapper is missing -# or the create errors. +# snapshots root around pacman; bakery now writes /usr/local (on @), so +# that root snapshot includes the desktop apps. This extra snapshot is +# still best-effort — a home config if the installer created one (user +# bakery state), plus a root timeline around the whole update. Never +# fail the update if snapper is missing or the create errors. if command -v snapper >/dev/null; then if snapper -c home list >/dev/null 2>&1; then snapper -c home create -t pre -c number \ diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index ac34e31..e1ef73a 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -1,4 +1,4 @@ -# Bakery binaries baked into the live/installed skel. +# Bakery binaries baked into the live/installed image at /usr/local. # # 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. diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index cc8e2e2..498ed39 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -206,10 +206,10 @@ yay-bin # /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/ # bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, # bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings, -# breadhelp — is bakery-managed and baked into /etc/skel/.local at ISO build +# breadhelp — is bakery-managed and baked into /usr/local at ISO build # time from iso/bread-lockfile.toml (see build-local.sh). breadcast and # breadarr are not shipped. bos-settings/breadhelp desktop entries are -# committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime +# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime # deps stay listed even though no bread package depends on them via pacman # (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …). diff --git a/iso/pacman.conf b/iso/pacman.conf index 506b4bb..be3d52f 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -46,7 +46,7 @@ Include = /etc/pacman.d/mirrorlist # Breadway custom repo — breadlock plus AUR republishes the ISO needs # (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, # zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp -# are NOT here; they are bakery-baked into /etc/skel at ISO build time. +# are NOT here; they are bakery-baked into /usr/local at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the # .forgejo/workflows/*.yml workflows in this repo (and breadlock's). diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index d02d0b0..0fcd06a 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -1,15 +1,20 @@ #!/usr/bin/env bash -# Read-only checks that a builder home (and optionally a staged skel) has +# Read-only checks that a builder home (and optionally a staged image) has # everything build-local.sh needs before mkarchiso. Exit non-zero on failure. # +# Builder home stays user-layout (~/.local). The image is system-prefix +# /usr/local; pass SKEL and/or AIROOTFS to check those destinations. +# # LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh # SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh +# AIROOTFS=/tmp/bos-iso-stage/airootfs ./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:-}" +AIROOTFS="${AIROOTFS:-}" pass=0 fail=0 @@ -112,21 +117,73 @@ else 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" +if [[ -n "$SKEL" && -z "$AIROOTFS" ]]; then + if [[ -d "$SKEL/usr/local/bin" ]]; then + AIROOTFS="$SKEL" + SKEL="$AIROOTFS/etc/skel" + elif [[ -d "$SKEL/../../usr/local" ]]; then + AIROOTFS="$(cd "$SKEL/../.." && pwd)" + fi +elif [[ -n "$AIROOTFS" && -z "$SKEL" ]]; then + SKEL="$AIROOTFS/etc/skel" +fi + +if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then + if [[ -n "$AIROOTFS" ]]; then + echo "== staged image $AIROOTFS ==" + check_file "$AIROOTFS/etc/bakery/config.toml" "bakery prefix config" + if [[ -f "$AIROOTFS/etc/bakery/config.toml" ]] && grep -q 'prefix[[:space:]]*=[[:space:]]*"/usr/local"' "$AIROOTFS/etc/bakery/config.toml"; then + ok "bakery prefix = /usr/local" else - bad "skel unit missing: $SKEL/.config/systemd/user/$unit" + bad "bakery prefix is not /usr/local in $AIROOTFS/etc/bakery/config.toml" fi - done + for b in "${REQUIRED_BINS[@]}"; do + check_exec "$AIROOTFS/usr/local/bin/$b" "image required bin $b" + done + check_dir "$AIROOTFS/usr/local/share/breadhelp/content" "image breadhelp content" + fi + if [[ -n "$SKEL" ]]; then + echo "== staged skel $SKEL ==" + check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache" + check_file "$SKEL/.local/state/bakery/installed.json" "skel bakery installed.json" + for b in "${REQUIRED_BINS[@]}"; do + if [[ -e "$SKEL/.local/bin/$b" ]]; then + bad "skel still has bakery bin $b (belongs in /usr/local/bin)" + fi + done + fi + image_units_json="" + if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then + image_units_json="$SKEL/.local/state/bakery/installed.json" + fi + if [[ -n "$image_units_json" ]]; then + mapfile -t IMAGE_UNITS < <(python3 - "$image_units_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 +) + else + IMAGE_UNITS=("${UNITS[@]}") + fi + if [[ -n "$AIROOTFS" ]]; then + for unit in "${IMAGE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue + check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "image unit $unit" + if [[ -f "$AIROOTFS/usr/lib/systemd/user/$unit" ]]; then + if grep -q '^ExecStart=/usr/local/bin/' "$AIROOTFS/usr/lib/systemd/user/$unit"; then + ok "image unit $unit ExecStart uses /usr/local/bin" + elif grep -q '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit"; then + bad "image unit $unit ExecStart is not /usr/local/bin: $(grep '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit")" + fi + fi + done + fi fi echo diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 19e7d75..d5b6fa9 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -63,7 +63,7 @@ check "bos-settings installed" "command -v bos-settings" echo "== breadhelp ==" check "breadhelp installed" "command -v breadhelp" check "breadhelp content installed" \ - "[ -d \"\$HOME/.local/share/breadhelp/content\" ] || [ -d /etc/skel/.local/share/breadhelp/content ]" + "[ -d /usr/local/share/breadhelp/content ] || [ -d \"\$HOME/.local/share/breadhelp/content\" ]" check "bos-netcheck present" "command -v bos-netcheck" check "bos-rescue present" "command -v bos-rescue" check "bos-first-boot present" "command -v bos-first-boot" From 70d4dd424bb62eb722d91c8d9682622c33ddc197 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:27:14 +0800 Subject: [PATCH 18/30] iso: enable bakery user units globally for later accounts Bins live in /usr/local, so a later useradd no longer gets ~/.local/bin copies. systemctl --global enable the bakery --user units (bake writes /etc/systemd/user/*.wants/, and post-install + live-setup run the same enable) so first login starts breadd, breadbox-sync, breadclipd, breadcrumbs, and breadmill. Stock useradd -m copies skel (Hyprland + bakery state). Rollback is still grub-btrfs. --- README.md | 43 +++++- build-local.sh | 126 ++++++++++++++++-- iso/airootfs/etc/calamares/post-install.sh | 9 ++ iso/airootfs/etc/default/useradd | 3 + .../etc/skel/.config/hypr/hyprland.lua | 3 +- .../user/default.target.wants/breadd.service | 1 + .../systemd/user-preset/90-bos-bakery.preset | 12 ++ .../local/bin/bos-enable-bakery-user-units | 90 +++++++++++++ iso/airootfs/usr/local/bin/bos-live-setup | 12 +- iso/profiledef.sh | 1 + scripts/ci-verify-bake.sh | 29 ++++ scripts/smoke-test.sh | 28 ++++ 12 files changed, 336 insertions(+), 21 deletions(-) create mode 120000 iso/airootfs/etc/systemd/user/default.target.wants/breadd.service create mode 100644 iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset create mode 100755 iso/airootfs/usr/local/bin/bos-enable-bakery-user-units diff --git a/README.md b/README.md index 387c5f4..e8f2eba 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,11 @@ machine's bakery-installed bread binaries + breadhelp content from the builder's `~/.local` into the image at `/usr/local` (bins, share/data, desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user bakery state (`installed.json` + index cache) is seeded in `/etc/skel`. -BOS opts in via `/etc/bakery/config.toml` (`prefix = "/usr/local"`); -default bakery without that file is still `~/.local`. Snapper `@` -snapshots include `/usr/local`; recovery is still grub-btrfs, not -`snapper rollback`. +User units are `systemctl --global enable`'d so a later `useradd -m` +starts them on first login. BOS opts in via `/etc/bakery/config.toml` +(`prefix = "/usr/local"`); default bakery without that file is still +`~/.local`. Snapper `@` snapshots include `/usr/local`; recovery is +still grub-btrfs, not `snapper rollback`. ```sh sudo ./build-local.sh # release-quality (xz squashfs) @@ -197,8 +198,38 @@ Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to avoid memory pressure. Post-install, `scripts/smoke-test.sh` (run as the installed user) checks -subvolumes, services, bakery bins on PATH, and breadhelp content under -`/usr/local/share/breadhelp/content`. +subvolumes, services, bakery bins on PATH, breadhelp content under +`/usr/local/share/breadhelp/content`, and that bakery user units are +`--global` enabled (or the preset / wants files exist). + +## Second account + +Bakery desktop apps live in `/usr/local` — shared, already on PATH. A later +account does **not** get a private copy of those binaries. + +`/etc/default/useradd` keeps `SKEL=/etc/skel`. Stock `useradd -m` is enough: + +```sh +sudo useradd -m alice +sudo passwd alice +``` + +- **Apps**: `/usr/local/bin` (and `/usr/local/share`) — already there. +- **Session files**: `useradd -m` copies `/etc/skel` (Hyprland, bread + config, bakery `installed.json` + index cache) so first login has a + session. Skel does not contain bakery binaries. +- **Daemons**: `breadd`, `breadbox-sync`, `breadclipd`, `breadcrumbs`, + `breadmill`, … are `systemctl --global enable`'d at install (and on + the live image). Creating a user starts them on first login. +- **Login**: greetd/breadgreet lists any local user with a login shell + (`SHELL=/usr/bin/zsh` is the useradd default). + +`breadclipd` is WantedBy=`graphical-session.target`. BOS does not activate +that target (no uwsm), so Hyprland still `systemctl --user start`s it after +the compositor is up. `--global enable` still records it for every account. + +Rollback is still the GRUB snapshots submenu (grub-btrfs), not +`snapper rollback`. `/usr/local` rides the `@` snapshot. ## bos-settings diff --git a/build-local.sh b/build-local.sh index 8548d86..e360d42 100755 --- a/build-local.sh +++ b/build-local.sh @@ -249,26 +249,87 @@ PY # bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was # silently left out, so those daemons never start on a fresh install/live # boot until the user re-runs `bakery install` (which needs network). -# Source of truth is the *filtered* installed.json we just wrote: only -# lockfile packages. Units go to /usr/lib/systemd/user with ExecStart -# rewritten to /usr/local/bin (not %h/.local/bin). Recreate whichever +# Units come from installed.json + the bakery index + local unit files +# whose ExecStart is a lockfile binary (installed.json has omitted +# breadcrumbs.service before). Units go to /usr/lib/systemd/user with +# ExecStart rewritten to /usr/local/bin. Recreate whichever # *.target.wants enable symlink bakery created locally (or that skel -# already ships). Hand-committed skel units (breadd.service carries a +# already ships), and write /etc/systemd/user/*.wants/ (--global). +# Hand-committed skel units (breadd.service carries a # RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source # for that unit and also get their ExecStart rewritten in skel. echo "=== baking bakery service units into /usr/lib/systemd/user ===" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user" install -d -m 0755 "$IMAGE_UNITS" -mapfile -t SERVICE_UNITS < <(python3 - "$SKEL/.local/state/bakery/installed.json" <<'PY' -import json, sys -with open(sys.argv[1]) as f: - d = json.load(f) -for pkg in d.get("packages", d).values(): - for s in pkg.get("services", []): - print(s["unit"] if isinstance(s, dict) else s) +# installed.json on the builder can omit a service even when the index and +# the local unit file exist (breadcrumbs has done this). Merge all three +# so every lockfile daemon is baked and can be --global enabled. +mapfile -t SERVICE_UNITS < <(python3 - \ + "$SKEL/.local/state/bakery/installed.json" \ + "$BAKERY_CACHE/index.json" \ + "$SYSTEMD_USER_DIR" \ + "${BREAD_BINS[@]}" <<'PY' +import json, os, sys + +installed_path, index_path, user_dir, *bins = sys.argv[1:] +wanted = set(bins) +units = set() + +def add_svc(svc): + name = svc["unit"] if isinstance(svc, dict) else svc + if not name or str(name).startswith(("breadcast", "breadarr")): + return + units.add(str(name)) + +if os.path.isfile(installed_path): + with open(installed_path) as f: + data = json.load(f) + for pkg in data.get("packages", data).values(): + if isinstance(pkg, dict): + for svc in pkg.get("services") or []: + add_svc(svc) + +if os.path.isfile(index_path): + with open(index_path) as f: + idx = json.load(f) + for name, pkg in (idx.get("packages") or {}).items(): + if not isinstance(pkg, dict): + continue + pbins = [] + for b in pkg.get("binaries") or []: + n = b["name"] if isinstance(b, dict) else b + pbins.append(str(n).removesuffix("-x86_64")) + if name in wanted or any(b in wanted for b in pbins): + for svc in pkg.get("services") or []: + add_svc(svc) + +if os.path.isdir(user_dir): + for fn in os.listdir(user_dir): + if not fn.endswith(".service"): + continue + path = os.path.join(user_dir, fn) + if not os.path.isfile(path): + continue + try: + text = open(path).read() + except OSError: + continue + for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + argv0 = line.split("=", 1)[1].split() + if argv0 and os.path.basename(argv0[0]) in wanted: + add_svc(fn) + break + +for unit in sorted(units): + print(unit) PY ) +if [[ ! " ${SERVICE_UNITS[*]} " =~ " breadd.service " ]]; then + echo "ERROR: breadd.service not in the bakery unit list — refusing to bake" >&2 + exit 1 +fi rewrite_exec_start() { local src="$1" dest="$2" python3 - "$src" "$dest" <<'PY' @@ -301,7 +362,7 @@ for unit in "${SERVICE_UNITS[@]}"; do else src="$SYSTEMD_USER_DIR/$unit" if [[ ! -f "$src" ]]; then - echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2 + echo "ERROR: $unit listed as a bakery service but not found at $src" >&2 echo "Refusing to bake an image whose daemons will never start." >&2 exit 1 fi @@ -320,9 +381,50 @@ for unit in "${SERVICE_UNITS[@]}"; do ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit" done done + # systemctl --global enable equivalent: /etc/systemd/user/.wants/ + # so the live image and a later useradd inherit the unit without a per-home + # enable. Vendor wants above are extra; this is what --global writes. + python3 - "$IMAGE_UNITS/$unit" "$AIROOTFS/etc/systemd/user" "$unit" <<'PY' +import os, sys +unit_path, etc_user, unit = sys.argv[1:] +in_install = False +targets = [] +for line in open(unit_path): + s = line.strip() + if s.startswith("[") and s.endswith("]"): + in_install = s == "[Install]" + continue + if in_install and s.startswith("WantedBy="): + targets.extend(t for t in s.split("=", 1)[1].split() if t) +for target in targets: + wants = os.path.join(etc_user, f"{target}.wants") + os.makedirs(wants, exist_ok=True) + dest = os.path.join(wants, unit) + if os.path.lexists(dest): + os.remove(dest) + os.symlink(f"/usr/lib/systemd/user/{unit}", dest) + print(f" global enable {unit} -> {dest}") +PY echo " baked $unit -> $IMAGE_UNITS/$unit" done +# Document the baked set. The committed preset is the fallback; the staged +# copy lists whatever this bake actually shipped. +preset_dest="$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" +install -d -m 0755 "$(dirname "$preset_dest")" +{ + echo "# Bakery systemd --user units baked into this image." + echo "# Applied by systemctl --global enable (post-install + live setup)" + echo "# so a later useradd starts them on first login." + echo "# breadclipd is also started from hyprland.lua: WantedBy=" + echo "# graphical-session.target is not reached on BOS (no uwsm)." + for unit in "${SERVICE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue + printf 'enable %s\n' "$unit" + done +} >"$preset_dest" +echo " wrote $preset_dest" + # mkarchiso resets every airootfs file to 0644, so executables must be declared # in profiledef.sh's file_permissions array or they ship non-executable and the # exec-once launches fail with "permission denied". Inject a 0755 entry for each diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index ac13a97..5817f2e 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -430,6 +430,15 @@ fi # /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json # + index cache) is seeded from /etc/skel/.local and copied into the user's # home below, so the install works fully offline with no DNS for bakery. +# +# systemd --user units in /usr/lib/systemd/user are not enabled for new +# accounts unless enabled --global (or the user enables them). Do that here +# so a later `useradd -m` starts breadd / breadbox-sync / breadclipd / +# breadcrumbs / breadmill on first login. Safe if the helper is missing. +if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then + /usr/local/bin/bos-enable-bakery-user-units \ + || echo "WARN: enabling bakery user units globally failed" +fi # --------------------------------------------------------------------------- # Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares diff --git a/iso/airootfs/etc/default/useradd b/iso/airootfs/etc/default/useradd index f16b7d8..4cae86c 100644 --- a/iso/airootfs/etc/default/useradd +++ b/iso/airootfs/etc/default/useradd @@ -3,5 +3,8 @@ GROUP=users HOME=/home INACTIVE=-1 EXPIRE= +# useradd -m copies Hyprland + bakery per-user state from here. Bakery +# binaries live in /usr/local/bin (not skel). User units are enabled +# --global so a second account starts them on first login. SKEL=/etc/skel CREATE_MAIL_SPOOL=no diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 07ed155..c6c8e0b 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -141,7 +141,8 @@ hl.on("hyprland.start", function() -- pywal only runs for real once the user picks a wallpaper themselves. [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], -- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service, - -- plus a skel copy). It autostarts at login but before Hyprland exists, so + -- enabled --global so every account starts it). It autostarts at login + -- but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd -- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland. "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE", diff --git a/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service new file mode 120000 index 0000000..d284527 --- /dev/null +++ b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service @@ -0,0 +1 @@ +/usr/lib/systemd/user/breadd.service \ No newline at end of file diff --git a/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset new file mode 100644 index 0000000..f7f73f9 --- /dev/null +++ b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset @@ -0,0 +1,12 @@ +# Bakery systemd --user units. `systemctl --global enable` (post-install and +# live setup) applies these so a later `useradd -m` starts them on first login. +# Bake rewrites this list from the units actually copied into the image. +# +# breadclipd is WantedBy=graphical-session.target. BOS does not activate that +# target (no uwsm); Hyprland still `systemctl --user start`s it after the +# compositor is up. --global enable still records it for every account. +enable breadd.service +enable breadbox-sync.service +enable breadclipd.service +enable breadcrumbs.service +enable breadmill.service diff --git a/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units new file mode 100755 index 0000000..46c128e --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units @@ -0,0 +1,90 @@ +#!/bin/bash +# Enable bakery systemd --user units for every account (current and future). +# +# `systemctl --global enable` writes /etc/systemd/user/.wants/ so a +# later `useradd -m` does not need per-home enablement. Bins live in +# /usr/local; only per-user state comes from skel. +# +# Safe on the live image and in the Calamares post-install chroot. +# Idempotent. Does not start units (no user session required). +# +# breadclipd is WantedBy=graphical-session.target. BOS does not activate +# that target (no uwsm), so Hyprland still `systemctl --user start`s it. +# --global enable still records it for every account / bos-settings. +set -uo pipefail + +UNITS_DIR=/usr/lib/systemd/user +PRESET=/usr/lib/systemd/user-preset/90-bos-bakery.preset + +is_blocked() { + case "$1" in + breadcast*|breadarr*) return 0 ;; + *) return 1 ;; + esac +} + +is_bakery_unit() { + local unit="$1" path="$UNITS_DIR/$unit" + [[ -f "$path" ]] || return 1 + is_blocked "$unit" && return 1 + grep -qE '^ExecStart=/usr/local/bin/' "$path" +} + +list_from_preset() { + [[ -f "$PRESET" ]] || return 0 + awk '/^enable[[:space:]]/ { print $2 }' "$PRESET" +} + +list_from_units_dir() { + [[ -d "$UNITS_DIR" ]] || return 0 + local path unit + for path in "$UNITS_DIR"/*.service; do + [[ -f "$path" ]] || continue + unit="$(basename "$path")" + is_bakery_unit "$unit" && printf '%s\n' "$unit" + done +} + +list_from_installed_json() { + local json=/etc/skel/.local/state/bakery/installed.json + [[ -f "$json" ]] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + python3 - "$json" <<'PY' +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +for pkg in data.get("packages", data).values(): + if not isinstance(pkg, dict): + continue + for svc in pkg.get("services") or []: + name = svc["unit"] if isinstance(svc, dict) else svc + if name and not str(name).startswith(("breadcast", "breadarr")): + print(name) +PY +} + +mapfile -t units < <( + { list_from_preset; list_from_units_dir; list_from_installed_json; } \ + | sed '/^$/d' | sort -u +) + +if [[ ${#units[@]} -eq 0 ]]; then + echo "WARN: no bakery user units found to enable globally" + exit 0 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "WARN: systemctl missing — cannot --global enable bakery user units" + exit 0 +fi + +for unit in "${units[@]}"; do + [[ -f "$UNITS_DIR/$unit" ]] || continue + is_blocked "$unit" && continue + if ! grep -q '^\[Install\]' "$UNITS_DIR/$unit"; then + echo "WARN: $unit has no [Install] section — skip --global enable" + continue + fi + systemctl --global enable "$unit" \ + || echo "WARN: systemctl --global enable $unit failed" +done diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index f7b1d26..0609cac 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -7,9 +7,17 @@ # bos-launch-calamares). Runs once at boot, before the tty1 autologin getty. set -e +# Bakery user units live in /usr/lib/systemd/user. --global enable writes +# /etc/systemd/user/*.wants/ so liveuser (and any later account) starts +# them on first login. Idempotent; bins are already in /usr/local. +if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then + /usr/local/bin/bos-enable-bakery-user-units \ + || echo "WARN: enabling bakery user units globally failed" +fi + # useradd -m copies /etc/skel, so the live user gets the real BOS desktop -# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality, -# not an installer kiosk. +# (hypr + bread config + bakery state) — proper live-media functionality, +# not an installer kiosk. Binaries are /usr/local, not skel. if ! id liveuser &>/dev/null; then useradd -m -s /usr/bin/zsh liveuser for g in wheel video input audio storage power; do diff --git a/iso/profiledef.sh b/iso/profiledef.sh index 5034cbc..f52295b 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -31,4 +31,5 @@ file_permissions=( ["/usr/local/bin/bos-update"]="0:0:755" ["/usr/local/bin/bos-rescue"]="0:0:755" ["/usr/local/bin/bos-first-boot"]="0:0:755" + ["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755" ) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 0fcd06a..68e357d 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -151,6 +151,7 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then bad "skel still has bakery bin $b (belongs in /usr/local/bin)" fi done + check_file "$SKEL/.config/hypr/hyprland.lua" "skel hyprland.lua" fi image_units_json="" if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then @@ -183,6 +184,34 @@ PY fi fi done + check_file "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" \ + "bakery user preset" + if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]] \ + || [[ -f "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]]; then + ok "breadd.service globally enabled (etc wants)" + else + bad "breadd.service missing from /etc/systemd/user/default.target.wants" + fi + # After bake the image has /usr/local/bin/breadd and every preset unit. + # The committed airootfs only has the preset + breadd wants. + if [[ -f "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" ]] \ + && [[ -x "$AIROOTFS/usr/local/bin/breadd" ]]; then + while read -r verb unit; do + [[ "$verb" == enable && -n "$unit" ]] || continue + check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "preset unit $unit" + if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/$unit" ]] \ + || [[ -L "$AIROOTFS/etc/systemd/user/graphical-session.target.wants/$unit" ]]; then + ok "$unit globally enabled (etc wants)" + else + bad "$unit missing from /etc/systemd/user/*.target.wants" + fi + done < "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" + fi + if [[ -x "$AIROOTFS/usr/local/bin/bos-enable-bakery-user-units" ]]; then + ok "bos-enable-bakery-user-units executable" + else + bad "bos-enable-bakery-user-units missing or not executable" + fi fi fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index d5b6fa9..81bad6a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -68,6 +68,34 @@ check "bos-netcheck present" "command -v bos-netcheck" check "bos-rescue present" "command -v bos-rescue" check "bos-first-boot present" "command -v bos-first-boot" +echo "== bakery user units (global enable) ==" +# A later useradd does not enable --user units unless they were enabled +# --global (or the user enables them). post-install + live-setup + bake +# write /etc/systemd/user/.wants/ and a preset listing the set. +check "bakery user preset present" \ + "[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]" +check "bos-enable-bakery-user-units present" \ + "command -v bos-enable-bakery-user-units" +check "breadd.service globally enabled" \ + "systemctl --global is-enabled breadd.service || [ -L /etc/systemd/user/default.target.wants/breadd.service ]" +if [[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]]; then + while read -r verb unit; do + [[ "$verb" == enable && -n "$unit" ]] || continue + [[ -f /usr/lib/systemd/user/$unit ]] || continue + check "$unit globally enabled" \ + "systemctl --global is-enabled $unit || [ -L /etc/systemd/user/default.target.wants/$unit ] || [ -L /etc/systemd/user/graphical-session.target.wants/$unit ]" + done < /usr/lib/systemd/user-preset/90-bos-bakery.preset +fi +check "skel hyprland.lua present" "[ -f /etc/skel/.config/hypr/hyprland.lua ]" +check "skel bakery installed.json present" \ + "[ -f /etc/skel/.local/state/bakery/installed.json ]" +check "skel bakery index cache present" \ + "[ -f /etc/skel/.cache/bakery/index.json ]" +check "skel has no bakery binaries" \ + "! [ -e /etc/skel/.local/bin/bakery ] && ! [ -e /etc/skel/.local/bin/breadd ]" +check "useradd SKEL is /etc/skel" \ + "grep -q '^SKEL=/etc/skel' /etc/default/useradd" + echo "== default dotfiles ==" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]" From f3d6c55234c602fbfde529791042acb5344c4242 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:35:26 +0800 Subject: [PATCH 19/30] iso: prefer bread-polkit when it is on PATH The themed agent lives in bread-ecosystem and is not on the ISO lockfile yet. Fall back to polkit-gnome so install-time auth still works. --- iso/airootfs/etc/skel/.config/hypr/hyprland.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index c6c8e0b..d16a363 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -126,7 +126,10 @@ hl.on("hyprland.start", function() -- Clipboard history is breadclipd, a bakery-managed systemd --user -- service (auto-started from /usr/lib/systemd/user — see -- build-local.sh's service bake) rather than an exec-once here. - "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", + -- Prefer bread-polkit when bakery has published it; otherwise the + -- ISO's polkit-gnome agent. command -v so a missing binary does not + -- leave the session without an auth agent. + "sh -c 'if command -v bread-polkit >/dev/null; then exec bread-polkit; else exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1; fi'", "awww-daemon", -- Set the default wallpaper once the daemon is up (retry until ready). -- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real From 43c0a5e2b54d2c70e62713eaeb72f6c0d9c2ea00 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:48:25 +0800 Subject: [PATCH 20/30] iso: print GRUB snapshot recovery after bos-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery is reboot → GRUB “snapshots” submenu. snapper rollback does not change what GRUB boots (rootflags=subvol=@). Same wording in README Recovery and docs/hardware.md. --- README.md | 19 ++++++++++--------- docs/hardware.md | 18 +++++++++++------- iso/airootfs/usr/local/bin/bos-update | 4 ++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e8f2eba..d50f3d0 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ also get live systemd status + Start/Stop/Restart/Logs. | Packages | `bakery` installed list + updates, pacman system update | | AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | | Firmware | `fwupd` device list + updates | -| Snapshots | `snapper` list / boot-into (grub-btrfs) / delete | +| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete | Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) repo, not here. @@ -382,15 +382,16 @@ until `dl.breadway.dev/arch` exists). ## Recovery -**An update broke something (system still boots):** open BOS Settings → -Snapshots and pick a snapshot to boot, **or** choose one from the **GRUB -“snapshots” submenu** (grub-btrfs) at boot, then reboot into it. +**An update broke something (system still boots):** reboot → **GRUB +“snapshots” submenu** (grub-btrfs), then boot that entry. -Do **not** run `snapper rollback` as the default recovery step. BOS GRUB -pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not -what the installed grub.cfg will boot next. Use the grub-btrfs entry so the -kernel command line matches the snapshot you want. Details: -[docs/hardware.md](docs/hardware.md). +BOS Settings → Snapshots lists each snapshot’s number, date, and +description so you know which GRUB entry to pick. It does not roll the +running root back in place. + +Do **not** run `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`, so +a snapper-swapped default subvolume is not what the installed grub.cfg +will boot next. Details: [docs/hardware.md](docs/hardware.md). A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is not shipped. diff --git a/docs/hardware.md b/docs/hardware.md index 2f87838..9f31711 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -19,14 +19,18 @@ A VM without `/dev/dri` gets a notification only. ## Recovery -An update that breaks the system is recovered from the **GRUB "snapshots" -submenu** (grub-btrfs), not `snapper rollback`. +An update that breaks the system is recovered by **reboot → GRUB +“snapshots” submenu** (grub-btrfs). `snapper rollback` will not change +what GRUB boots (`rootflags=subvol=@`). -BOS GRUB pins `rootflags=subvol=@`. `snapper rollback` swaps the default -subvolume; the installed `grub.cfg` will still boot `@`. Pick the grub-btrfs -entry so the kernel command line matches the snapshot you want. Bakery -desktop apps live under `/usr/local` on `@`, so those same snapshots -include them. +`snapper rollback` swaps the default subvolume; the installed `grub.cfg` +still boots `@`. Pick the grub-btrfs entry so the kernel command line +matches the snapshot you want. + +BOS Settings → Snapshots lists snapshot number, date, and description so +you know which GRUB entry to pick. It does not roll the running root back +in place. Bakery desktop apps live under `/usr/local` on `@`, so those +same snapshots include them. If the system will not boot (lost EFI entry / broken GRUB), boot the live ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 3b3cc04..16b9da8 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -54,3 +54,7 @@ fi echo bold "==> BOS is up to date." +echo +bold "Recovery" +echo "If this update goes badly: reboot → GRUB “snapshots” submenu." +echo "snapper rollback will not change what GRUB boots (rootflags=subvol=@)." From 863fb80de2d5ee25717fc4341223f18fb17d865a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:54:40 +0800 Subject: [PATCH 21/30] iso: add bos-nvidia-setup for optional proprietary NVIDIA Click-to-install path for machines first-boot already offers. Installs nvidia + nvidia-utils (never cuda), writes ~/.config/hypr/nvidia.lua, and hyprland.lua dofiles that file only if it exists. Mesa stays unchanged. Not on the ISO. Reboot after. --- docs/hardware.md | 15 +- .../etc/skel/.config/hypr/hyprland.lua | 11 ++ iso/airootfs/usr/local/bin/bos-nvidia-setup | 150 ++++++++++++++++++ iso/profiledef.sh | 1 + scripts/ci-verify-bake.sh | 11 ++ scripts/smoke-test.sh | 9 ++ 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100755 iso/airootfs/usr/local/bin/bos-nvidia-setup diff --git a/docs/hardware.md b/docs/hardware.md index 9f31711..cf6ebbe 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -4,14 +4,21 @@ BOS ships the generic **Mesa** stack. AMD and Intel work out of the box. -**NVIDIA is unsupported.** The proprietary driver is not included, NVIDIA -firmware is not on the image, and there is no Hyprland NVIDIA env wiring. -Installing `nvidia` / `nvidia-utils` after the fact is not a product path. +The proprietary NVIDIA driver is **not on the ISO**. NVIDIA firmware is +not on the image either (`linux-firmware-nvidia` stays commented out in +`packages.x86_64`). Default Hyprland env is vendor-neutral. On first graphical login, `bos-first-boot` probes `lspci` / `/proc` and, if an NVIDIA GPU is present, writes `~/.local/state/bos/nvidia-offer.json` and notifies that the proprietary driver is not on the ISO. It does **not** -install anything. bos-settings can grow a panel that reads that file later. +install anything. + +The optional proprietary path is `bos-nvidia-setup` or the Settings → +Updates NVIDIA button. That installs `nvidia` + `nvidia-utils` (never +cuda) and writes `~/.config/hypr/nvidia.lua`. `hyprland.lua` dofiles that +drop-in **only if the file exists**, so Mesa machines stay unchanged. +Reboot after. Installing the packages by hand without the drop-in is not +enough for a working Hyprland session. The same probe leaves a HiDPI hint at `~/.local/state/bos/hidpi-hint.json` when scale > 1 or the panel is dense; it never rewrites `monitors.json`. diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index d16a363..565da5b 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -77,6 +77,17 @@ hl.env("SDL_VIDEODRIVER", "wayland") hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") +-- Optional NVIDIA env from bos-nvidia-setup. Mesa machines have no file. +-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent +do + local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua" + local f = io.open(nvidia, "r") + if f then + f:close() + pcall(dofile, nvidia) + end +end + -- kitty sets its own background_opacity (see kitty.conf), so the global blur -- above blurs behind the terminal while keeping text fully opaque. diff --git a/iso/airootfs/usr/local/bin/bos-nvidia-setup b/iso/airootfs/usr/local/bin/bos-nvidia-setup new file mode 100755 index 0000000..d287f53 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-nvidia-setup @@ -0,0 +1,150 @@ +#!/bin/bash +# bos-nvidia-setup — optional proprietary NVIDIA driver + Hyprland env. +# +# Installs nvidia + nvidia-utils only (never cuda). Writes +# ~/.config/hypr/nvidia.lua, which skel hyprland.lua dofiles only when +# the file exists — Mesa machines stay unchanged. Existing installs get +# the same include patched in if it is missing. +# +# Click-to-install from Settings, or run by hand. Not invoked from +# bos-first-boot. Idempotent. Prints "reboot required". +# +# Must run on an installed system. Elevates via pkexec, then sudo. +set -uo pipefail + +usage() { + cat <<'EOF' +Usage: bos-nvidia-setup [--home DIR] + +Install nvidia + nvidia-utils (not cuda) and write the Hyprland NVIDIA +env drop-in for this user. Reboot after. + + --home DIR user home that owns ~/.config/hypr (required under pkexec + if PKEXEC_UID / SUDO_USER cannot be resolved) +EOF +} + +TARGET_HOME="" +while [[ $# -gt 0 ]]; do + case "$1" in + --home) + TARGET_HOME="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "bos-nvidia-setup: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(id -un)" == "liveuser" || -d /run/archiso ]]; then + echo "bos-nvidia-setup is for an installed system, not the live ISO." >&2 + exit 1 +fi + +if [[ "$(id -u)" -ne 0 ]]; then + home="${TARGET_HOME:-${HOME:-}}" + if [[ -z "$home" ]]; then + echo "bos-nvidia-setup: cannot determine home; pass --home" >&2 + exit 1 + fi + self="$(command -v bos-nvidia-setup 2>/dev/null || true)" + [[ -n "$self" ]] || self="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")" + if command -v pkexec >/dev/null 2>&1; then + exec pkexec "$self" --home "$home" + fi + if command -v sudo >/dev/null 2>&1; then + exec sudo "$self" --home "$home" + fi + echo "bos-nvidia-setup: need root (pkexec or sudo)" >&2 + exit 1 +fi + +if [[ -z "$TARGET_HOME" ]]; then + if [[ -n "${PKEXEC_UID:-}" ]]; then + TARGET_HOME="$(getent passwd "$PKEXEC_UID" | cut -d: -f6 || true)" + elif [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != root ]]; then + TARGET_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6 || true)" + fi +fi + +if [[ -z "$TARGET_HOME" || "$TARGET_HOME" == /root || ! -d "$TARGET_HOME" ]]; then + echo "bos-nvidia-setup: cannot determine user home (pass --home)" >&2 + exit 1 +fi + +HYPR_DIR="$TARGET_HOME/.config/hypr" +NVIDIA_LUA="$HYPR_DIR/nvidia.lua" +HYPR_LUA="$HYPR_DIR/hyprland.lua" + +# Hyprland 0.56 (Aquamarine). Wiki (https://wiki.hypr.land/Nvidia/): +# LIBVA_DRIVER_NAME + __GLX_VENDOR_LIBRARY_NAME. NVD_BACKEND is the +# current VA-API hint. No WLR_* (not wlroots). No GBM_BACKEND (not +# required; older docs cargo-culted it and it can break Firefox). +NVIDIA_LUA_BODY='-- Written by bos-nvidia-setup. hyprland.lua dofiles this only when it exists. +-- Hyprland 0.56 (Aquamarine) — no WLR_* variables. +-- https://wiki.hypr.land/Nvidia/ +hl.env("LIBVA_DRIVER_NAME", "nvidia") +hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") +hl.env("NVD_BACKEND", "direct") +' + +# Self-contained so it is safe to append to a hand-edited hyprland.lua. +HYPR_INCLUDE='-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent +do + local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua" + local f = io.open(nvidia, "r") + if f then + f:close() + pcall(dofile, nvidia) + end +end +' + +own_as_user() { + local path="$1" + [[ -e "$path" ]] || return 0 + local owner + owner="$(stat -c '%u:%g' "$TARGET_HOME" 2>/dev/null || true)" + [[ -n "$owner" ]] || return 0 + chown "$owner" "$path" 2>/dev/null || true +} + +echo "==> Installing nvidia + nvidia-utils (not cuda)" +if ! command -v pacman >/dev/null 2>&1; then + echo "bos-nvidia-setup: pacman not found" >&2 + exit 1 +fi +if ! pacman -S --needed --noconfirm -- nvidia nvidia-utils; then + echo "bos-nvidia-setup: pacman install failed" >&2 + exit 1 +fi + +echo "==> Writing $NVIDIA_LUA" +mkdir -p "$HYPR_DIR" || { + echo "bos-nvidia-setup: cannot create $HYPR_DIR" >&2 + exit 1 +} +printf '%s' "$NVIDIA_LUA_BODY" >"$NVIDIA_LUA" || { + echo "bos-nvidia-setup: cannot write $NVIDIA_LUA" >&2 + exit 1 +} +own_as_user "$NVIDIA_LUA" + +if [[ -f "$HYPR_LUA" ]] && ! grep -q 'nvidia\.lua' "$HYPR_LUA"; then + echo "==> Including nvidia.lua from $HYPR_LUA" + if [[ -n "$(tail -c1 "$HYPR_LUA" 2>/dev/null || true)" ]]; then + printf '\n' >>"$HYPR_LUA" + fi + printf '%s\n' "$HYPR_INCLUDE" >>"$HYPR_LUA" + own_as_user "$HYPR_LUA" +fi + +echo "reboot required" +exit 0 diff --git a/iso/profiledef.sh b/iso/profiledef.sh index f52295b..462f74e 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -31,5 +31,6 @@ file_permissions=( ["/usr/local/bin/bos-update"]="0:0:755" ["/usr/local/bin/bos-rescue"]="0:0:755" ["/usr/local/bin/bos-first-boot"]="0:0:755" + ["/usr/local/bin/bos-nvidia-setup"]="0:0:755" ["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755" ) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 68e357d..e55b11e 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -47,6 +47,11 @@ PY echo "== lockfile $LOCKFILE ==" echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +if grep -qE '^nvidia(-utils|-dkms|-open)?$' "$REPO/iso/packages.x86_64"; then + bad "iso/packages.x86_64 lists an nvidia driver package" +else + ok "iso/packages.x86_64 has no nvidia driver package" +fi echo "== host tools ==" if command -v grub-install >/dev/null 2>&1; then ok "grub-install (uefi.grub bootmode)" @@ -140,6 +145,7 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then for b in "${REQUIRED_BINS[@]}"; do check_exec "$AIROOTFS/usr/local/bin/$b" "image required bin $b" done + check_exec "$AIROOTFS/usr/local/bin/bos-nvidia-setup" "image bos-nvidia-setup" check_dir "$AIROOTFS/usr/local/share/breadhelp/content" "image breadhelp content" fi if [[ -n "$SKEL" ]]; then @@ -152,6 +158,11 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then fi done check_file "$SKEL/.config/hypr/hyprland.lua" "skel hyprland.lua" + if grep -q 'nvidia.lua' "$SKEL/.config/hypr/hyprland.lua"; then + ok "skel hyprland.lua includes nvidia.lua only if present" + else + bad "skel hyprland.lua does not mention nvidia.lua" + fi fi image_units_json="" if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 81bad6a..ccdc571 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -67,6 +67,13 @@ check "breadhelp content installed" \ check "bos-netcheck present" "command -v bos-netcheck" check "bos-rescue present" "command -v bos-rescue" check "bos-first-boot present" "command -v bos-first-boot" +check "bos-nvidia-setup present" "command -v bos-nvidia-setup" +if pacman -Qq nvidia >/dev/null 2>&1; then + note "nvidia installed (optional proprietary path)" + check "nvidia env drop-in present" "[ -f \"\$HOME/.config/hypr/nvidia.lua\" ]" +else + check "nvidia not on the default image" "! pacman -Qq nvidia" +fi echo "== bakery user units (global enable) ==" # A later useradd does not enable --user units unless they were enabled @@ -98,6 +105,8 @@ check "useradd SKEL is /etc/skel" \ echo "== default dotfiles ==" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" +check "hyprland.lua includes nvidia.lua only if present" \ + "grep -q 'nvidia.lua' \"\$HOME/.config/hypr/hyprland.lua\"" check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]" check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]" check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]" From 8cd5ec9e16fb9ee684f06c3a4edfd4cd9b6221b8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:57:39 +0800 Subject: [PATCH 22/30] iso: ship restic for Settings home backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapper remains root (@) only. Home backup is Settings → Backup (restic). Restore goes to ~/bos-restore-, not $HOME. --- README.md | 15 ++++++++++----- iso/packages.x86_64 | 2 ++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d50f3d0..f0987b8 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ wiring up dotfiles, no per-tool bakery installs. Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is unsupported out of the box (see [docs/hardware.md](docs/hardware.md)). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every - pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). - A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot, - not `snapper rollback` (GRUB pins `rootflags=subvol=@`). See + pacman transaction (**root `@` only** — snapper does not cover `@home`); + home backup is **Settings → Backup** (restic, local path or SFTP); zram + swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is + **not** implemented. Recovery is a grub-btrfs reboot, not + `snapper rollback` (GRUB pins `rootflags=subvol=@`). See [docs/hardware.md](docs/hardware.md). - **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via @@ -264,7 +266,8 @@ also get live systemd status + Start/Stop/Restart/Logs. | Packages | `bakery` installed list + updates, pacman system update | | AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | | Firmware | `fwupd` device list + updates | -| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete | +| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete — **root (`@`) only** | +| Backup | restic of `$HOME` (`@home`) via Settings → Backup; snapper does not cover home | Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) repo, not here. @@ -387,7 +390,9 @@ until `dl.breadway.dev/arch` exists). BOS Settings → Snapshots lists each snapshot’s number, date, and description so you know which GRUB entry to pick. It does not roll the -running root back in place. +running root back in place. Snapper is root only. Home files are +**Settings → Backup** (restic restore into `~/bos-restore-`, not +over `$HOME`). Do **not** run `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not what the installed grub.cfg diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 498ed39..ff90edf 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -78,6 +78,8 @@ snapper snap-pac grub-btrfs inotify-tools +# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME. +restic # Wayland / Hyprland hyprland From 9fe02eeea613c009ce734bb0c253bc5ed15263fd Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:55:59 +0800 Subject: [PATCH 23/30] ci: publish signed [breadway] repo to dl.breadway.dev/arch Host job on hestia (no container) collects breadlock plus the ISO AUR republishes from the Forgejo registry, detach-signs them, repo-add -s, and writes /srv/breadway-dl/arch/x86_64/. ISO SigLevel stays Never. --- .forgejo/workflows/release-iso.yml | 4 +- .forgejo/workflows/signed-repo.yml | 52 +++++ README.md | 5 +- docs/signed-repo.md | 126 +++++++++--- scripts/ci-publish-signed-repo.sh | 298 +++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 32 deletions(-) create mode 100644 .forgejo/workflows/signed-repo.yml create mode 100755 scripts/ci-publish-signed-repo.sh diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index a30e291..162fa16 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -12,8 +12,8 @@ name: Build and release ISO # 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 verifying ISO SHA256SUMS only. That key -# does not sign the [breadway] pacman repo. No passphrase +# at KEYS.asc. Signs ISO SHA256SUMS here; the same secret +# signs the [breadway] repo in signed-repo.yml. No passphrase # (CI-only key, access controlled via the Forgejo secret # store). diff --git a/.forgejo/workflows/signed-repo.yml b/.forgejo/workflows/signed-repo.yml new file mode 100644 index 0000000..3ef8a65 --- /dev/null +++ b/.forgejo/workflows/signed-repo.yml @@ -0,0 +1,52 @@ +name: Publish signed [breadway] repo + +# Host job on hestia (no container:) so it can write /srv/breadway-dl, same +# as bakery releases. breadlock package.yml uses archlinux:latest and cannot +# see host /srv — do not add container: here. +# +# Collects breadlock + the ISO AUR republishes from the Forgejo Arch +# registry, detach-signs each .pkg.tar.zst, repo-add -s, publishes +# https://dl.breadway.dev/arch/x86_64/. Does not PUT to the registry +# (existing packaging workflows keep doing that). Does not flip ISO SigLevel. +# +# Required secret: GPG_PRIVATE_KEY (same BOS release key as release-iso.yml). + +on: + workflow_dispatch: + repository_dispatch: + types: [publish-signed-repo] + workflow_run: + workflows: + - Build and publish calamares + - Build and publish bibata-cursor-theme + - Build and publish powerlevel10k + - Build and publish yay-bin + types: [completed] + +concurrency: + group: signed-repo + cancel-in-progress: false + +jobs: + publish: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: [self-hosted, hestia] + steps: + - name: Clone repository + run: | + set -euo pipefail + REF="${GITHUB_REF_NAME:-main}" + rm -rf src + git clone --depth 1 --branch "$REF" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: Sign packages and publish repo + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "${GPG_PRIVATE_KEY:-}" ]; then + echo "GPG_PRIVATE_KEY secret is missing; refusing to publish an unsigned [breadway] repo." >&2 + exit 1 + fi + bash src/scripts/ci-publish-signed-repo.sh diff --git a/README.md b/README.md index f0987b8..1a833b6 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,12 @@ bos/ ├── scripts/ │ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME │ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso +│ ├── ci-publish-signed-repo.sh # CI: signed [breadway] repo → /srv/breadway-dl/arch │ └── smoke-test.sh ├── docs/ │ ├── hardware.md # Mesa only, NVIDIA, grub-btrfs recovery -│ └── signed-repo.md # future dl.breadway.dev/arch signing -├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release +│ └── signed-repo.md # dl.breadway.dev/arch signing +├── .forgejo/workflows/ # CI: AUR republish + signed repo + tagged ISO ├── build-local.sh # native ISO build for this machine ├── README.md └── DESIGN.md # historical plan diff --git a/docs/signed-repo.md b/docs/signed-repo.md index 112e254..02b5340 100644 --- a/docs/signed-repo.md +++ b/docs/signed-repo.md @@ -3,16 +3,24 @@ Today the ISO's `[Breadway.os.git.breadway.dev]` section is `SigLevel = Never`. That is TLS-only integrity: packages come from Forgejo's Arch registry, which does **not** serve pacman-compatible database -signatures. `KEYS.asc` signs **ISO `SHA256SUMS` only** — it is not imported -as a pacman repo key. Do not flip `SigLevel` to `Required` on that section -until a signed repo exists and has been verified; Required without -signatures breaks the ISO and every installed system. +signatures. `KEYS.asc` signs **ISO `SHA256SUMS`** and, once published, the +`dl.breadway.dev/arch` database. It is **not** imported as a pacman repo key +on the ISO yet. Do not flip `SigLevel` to `Required` on that section until +a signed repo exists and has been verified; Required without signatures +breaks the ISO and every installed system. The signed repo belongs at `https://dl.breadway.dev/arch`, not on Forgejo's -registry. +registry. Forgejo publishing stays as it is (`package.yml` / packaging +workflows PUT unsigned `.pkg.tar.zst` so existing Never installs keep +working). ## Stand up `dl.breadway.dev/arch` +CI job: **Publish signed `[breadway]` repo** +(`.forgejo/workflows/signed-repo.yml`), host runner on hestia — **no +container**, so it can write `/srv/breadway-dl` like bakery releases. +breadlock `package.yml` uses `archlinux:latest` and cannot see host `/srv`. + Use the same release-signing key already in CI: - Public half: [`KEYS.asc`](../KEYS.asc) @@ -20,6 +28,7 @@ Use the same release-signing key already in CI: `releases@breadway.dev`) - Private half: the `GPG_PRIVATE_KEY` Forgejo secret (armoured secret key, no passphrase). Same secret `release-iso.yml` uses to sign `SHA256SUMS`. + The workflow **fails** if this secret is missing. Layout (example for `x86_64`): @@ -33,53 +42,114 @@ https://dl.breadway.dev/arch/x86_64/ breadway.files.sig ``` -Build the database **and sign it** with `repo-add -s`: +On disk: `/srv/breadway-dl/arch/x86_64/` (nginx already serves +`/srv/breadway-dl` as `https://dl.breadway.dev/`). + +The job collects the current ISO `[breadway]` set from the Forgejo Arch +registry (breadlock + calamares, zen-browser-bin, bibata-cursor-theme-bin, +zsh-theme-powerlevel10k, yay-bin). Leftover bakery-channel pacman packages +still sitting in that registry are **not** copied. Optional +`BREADWAY_PKG_DIR` on the runner overrides individual files. + +Then it detach-signs each `.pkg.tar.zst` as a **binary** sidecar (pacman +wants `.sig`, not armoured `.asc`) and builds the database with +`repo-add -s`: ```sh export GNUPGHOME=/tmp/gnupg-breadway-repo mkdir -m 700 -p "$GNUPGHOME" printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import -cd /srv/dl.breadway.dev/arch/x86_64 +gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign breadlock--1-x86_64.pkg.tar.zst +# → breadlock--1-x86_64.pkg.tar.zst.sig + +cd /srv/breadway-dl/arch/x86_64 repo-add -s -k releases@breadway.dev breadway.db.tar.gz *.pkg.tar.zst ``` `repo-add -s` writes `breadway.db.tar.gz.sig` (and the `.files` pair). Pacman fetches `
.db` + `
.db.sig` from `Server`. -Package signatures are separate from the database signature. Detach-sign -each `.pkg.tar.zst` as a **binary** sidecar (pacman wants `.sig`, not -armoured `.asc`): +## Dispatch the workflow + +Forgejo UI: **Actions → "Publish signed [breadway] repo" → Run workflow**. +Select this branch (`feature/signed-repo`) until it is on `main`. + +API (`workflow_dispatch`): ```sh -gpg --batch --yes --local-user releases@breadway.dev \ - --detach-sign breadlock--1-x86_64.pkg.tar.zst -# → breadlock--1-x86_64.pkg.tar.zst.sig +curl -fsS -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://git.breadway.dev/api/v1/repos/Breadway/bos/actions/workflows/signed-repo.yml/dispatches" \ + -d '{"ref":"feature/signed-repo"}' ``` +After merge, use `"ref":"main"`. + +It also runs after the in-repo AUR republish workflows complete +(`calamares` / `bibata` / `powerlevel10k` / `yay-bin`). breadlock lives in +another repo; that job can fire this one with `repository_dispatch` event +`publish-signed-repo` (or dispatch from the UI after a breadlock tag). + +## Verify + +Confirm the signed db is actually served **before** touching ISO +`SigLevel` or `Server`: + +```sh +curl -fsSIL https://dl.breadway.dev/arch/x86_64/breadway.db +curl -fsSIL https://dl.breadway.dev/arch/x86_64/breadway.db.sig +``` + +Both must be HTTP 200. A 404 on `breadway.db.sig` means do **not** flip +`SigLevel` to `Required`. + +Import `KEYS.asc` and check the detached signatures: + +```sh +gpg --import KEYS.asc +curl -fsSL -o /tmp/breadway.db https://dl.breadway.dev/arch/x86_64/breadway.db +curl -fsSL -o /tmp/breadway.db.sig https://dl.breadway.dev/arch/x86_64/breadway.db.sig +gpg --verify /tmp/breadway.db.sig /tmp/breadway.db +``` + +On a throwaway Arch box (not the ISO tree): + +```sh +sudo pacman-key --add KEYS.asc +sudo pacman-key --lsign-key 56203B86A110695AE7F310934AF3323D678EB5E2 + +# Temporary /etc/pacman.conf snippet — do not commit this to the ISO: +# [breadway] +# SigLevel = Required +# Server = https://dl.breadway.dev/arch/$arch + +sudo pacman -Sy +``` + +`pacman -Sy` must fetch `breadway.db` + `breadway.db.sig` without +"missing or invalid signature". Then `pacman -Si breadlock` (and the AUR +republishes) should list the `[breadway]` section. + ## breadlock `package.yml` sidecar [`breadlock` `package.yml`](https://git.breadway.dev/Breadway/breadlock/src/branch/main/.forgejo/workflows/package.yml) -already `makepkg`s and PUTs the archive at Forgejo's registry. When the -signed repo exists, that job can also emit the sidecar and publish both -files to `dl.breadway.dev/arch`: - -```sh -PKG=$(find packaging/arch -name '*.pkg.tar.zst' | head -1) -printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import -gpg --batch --yes --local-user releases@breadway.dev --detach-sign "$PKG" -# upload "$PKG" and "${PKG}.sig" to dl.breadway.dev/arch/x86_64/ -# then repo-add -s as above -``` - -Keep publishing to Forgejo until installs have been switched. The ISO -section stays `SigLevel = Never` until the signed tree is live. +still `makepkg`s and PUTs the archive at Forgejo's registry. That path +stays; Never installs keep working. The signed tree is rebuilt by the bos +workflow above (registry fetch + sign + `repo-add -s`), not by writing +`/srv` from breadlock's container. ## After the signed repo exists +Only after `https://dl.breadway.dev/arch/x86_64/breadway.db.sig` HEADs 200 +and the verify commands above succeed: + 1. Import `KEYS.asc` into the ISO keyring (`pacman-key --add` + `--lsign-key`). 2. Point `[breadway]` `Server` at `https://dl.breadway.dev/arch/$arch`. 3. Only then flip that section to `SigLevel = Required`. Do not do those three steps against Forgejo's registry. See -`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. +`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. This branch does +**not** change either file. diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh new file mode 100755 index 0000000..08fbb53 --- /dev/null +++ b/scripts/ci-publish-signed-repo.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# Collect the current [breadway] ISO packages, detach-sign them with the +# BOS release key (releases@breadway.dev), and publish a signed pacman db +# under /srv/breadway-dl/arch/x86_64/ (https://dl.breadway.dev/arch/x86_64/). +# +# Does not change ISO SigLevel and does not write to the Forgejo Arch +# registry — existing package.yml / packaging/*.yml PUTs stay as they are. +# +# Required env: +# GPG_PRIVATE_KEY armoured secret key (same secret as release-iso.yml) +# Optional env: +# BREADWAY_DEST publish dir (default /srv/breadway-dl/arch/x86_64) +# BREADWAY_PKG_DIR extra directory of .pkg.tar.zst to prefer over the registry +# BREADWAY_REGISTRY Forgejo Arch registry base +# BREADWAY_SIGN_ONLY=1 skip collect; sign+index BREADWAY_REPO_DIR only +set -euo pipefail + +PACKAGES=( + breadlock + calamares + zen-browser-bin + bibata-cursor-theme-bin + zsh-theme-powerlevel10k + yay-bin +) + +ARCH="${BREADWAY_ARCH:-x86_64}" +REGISTRY="${BREADWAY_REGISTRY:-https://git.breadway.dev/api/packages/Breadway/arch/os}" +DEST="${BREADWAY_DEST:-/srv/breadway-dl/arch/${ARCH}}" +KEY_ID="${BREADWAY_KEY_ID:-releases@breadway.dev}" +DB_NAME="${BREADWAY_REGISTRY_DB:-Breadway.os.git.breadway.dev.db}" +REPO_DIR="${BREADWAY_REPO_DIR:-}" + +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" + +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need_key() { + if [[ -z "${GPG_PRIVATE_KEY:-}" ]]; then + die "GPG_PRIVATE_KEY is missing; refusing to publish an unsigned [breadway] repo." + fi +} + +urlencode() { + python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe="-._~"))' "$1" +} + +pkginfo_name() { + local pkg="$1" info + info="$(tar -xOf "$pkg" .PKGINFO 2>/dev/null || zstd -dc "$pkg" | tar -xO .PKGINFO)" + awk -F ' = ' '$1=="pkgname" {print $2; exit}' <<<"$info" +} + +import_key() { + export GNUPGHOME="${GNUPGHOME:-$(mktemp -d "${TMPDIR:-/tmp}/gnupg-breadway-repo.XXXXXX")}" + mkdir -m 700 -p "$GNUPGHOME" + printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import +} + +detach_sign_pkgs() { + local pkg + shopt -s nullglob + for pkg in *.pkg.tar.zst; do + gpg --batch --yes --local-user "$KEY_ID" --detach-sign "$pkg" + done + shopt -u nullglob +} + +repo_add_signed() { + local pkgs=() + shopt -s nullglob + pkgs=(*.pkg.tar.zst) + shopt -u nullglob + (( ${#pkgs[@]} > 0 )) || die "no .pkg.tar.zst files to index" + rm -f breadway.db breadway.db.tar.gz breadway.db.sig breadway.db.tar.gz.sig \ + breadway.files breadway.files.tar.gz breadway.files.sig breadway.files.tar.gz.sig + if repo-add --help 2>&1 | grep -q -- '--include-sigs'; then + repo-add -s -k "$KEY_ID" --include-sigs breadway.db.tar.gz "${pkgs[@]}" + else + repo-add -s -k "$KEY_ID" breadway.db.tar.gz "${pkgs[@]}" + fi + [[ -e breadway.db.tar.gz.sig || -e breadway.db.sig ]] \ + || die "repo-add -s did not write breadway.db*.sig" +} + +ensure_arch_tools() { + if ! command -v gpg >/dev/null 2>&1; then + command -v pacman >/dev/null 2>&1 || die "gpg not on PATH" + pacman -Sy --noconfirm --needed gnupg + fi + command -v repo-add >/dev/null 2>&1 || die "repo-add not on PATH" + command -v gpg >/dev/null 2>&1 || die "gpg not on PATH" +} + +sign_and_index() { + local dir="$1" + [[ -d "$dir" ]] || die "repo dir missing: $dir" + need_key + ensure_arch_tools + import_key + ( + cd "$dir" + detach_sign_pkgs + repo_add_signed + ) +} + +container_runtime() { + if command -v docker >/dev/null 2>&1; then + printf '%s\n' docker + elif command -v podman >/dev/null 2>&1; then + printf '%s\n' podman + else + return 1 + fi +} + +sign_and_index_anywhere() { + local dir="$1" + if command -v repo-add >/dev/null 2>&1 && command -v gpg >/dev/null 2>&1; then + sign_and_index "$dir" + return + fi + local rt + rt="$(container_runtime)" || die \ + "need host gpg+repo-add, or docker/podman to run archlinux:latest (no Forgejo container: — host must see /srv/breadway-dl)" + # Host job + bind-mount, same reason bakery writes /srv without container:. + "$rt" run --rm --network=host \ + -e GPG_PRIVATE_KEY \ + -e BREADWAY_SIGN_ONLY=1 \ + -e BREADWAY_REPO_DIR=/repo \ + -e BREADWAY_KEY_ID="$KEY_ID" \ + -v "$dir:/repo" \ + -v "$SCRIPT_PATH:/ci-publish-signed-repo.sh:ro" \ + archlinux:latest \ + bash /ci-publish-signed-repo.sh +} + +parse_registry_db() { + local db="$1" + python3 - "$db" "${PACKAGES[@]}" <<'PY' +import sys, tarfile + +db = sys.argv[1] +want = set(sys.argv[2:]) +found = {} +with tarfile.open(db, "r:*") as tf: + for member in tf.getmembers(): + if not member.name.endswith("/desc") or not member.isfile(): + continue + fh = tf.extractfile(member) + if fh is None: + continue + text = fh.read().decode() + fields = {} + key = None + buf = [] + def flush(): + if key is not None: + fields[key] = "\n".join(buf).strip() + for line in text.splitlines(): + if line.startswith("%") and line.endswith("%") and len(line) > 2: + flush() + key = line.strip("%") + buf = [] + else: + buf.append(line) + flush() + name = fields.get("NAME", "") + filename = fields.get("FILENAME", "") + if name in want and filename: + found[name] = filename + +missing = sorted(want - set(found)) +if missing: + sys.stderr.write("registry db missing packages: " + " ".join(missing) + "\n") + raise SystemExit(1) +for name in sys.argv[2:]: + print(f"{name}\t{found[name]}") +PY +} + +copy_local_overrides() { + local dir="$1" + [[ -n "$dir" && -d "$dir" ]] || return 0 + local pkg name + shopt -s nullglob + for pkg in "$dir"/*.pkg.tar.zst "$dir"/*/*.pkg.tar.zst; do + [[ -f "$pkg" ]] || continue + name="$(pkginfo_name "$pkg")" + [[ -n "$name" ]] || continue + local wanted=0 p + for p in "${PACKAGES[@]}"; do + if [[ "$p" == "$name" ]]; then + wanted=1 + break + fi + done + if (( wanted )); then + printf 'local override: %s -> %s\n' "$name" "$(basename "$pkg")" + cp -a "$pkg" "$STAGE/$(basename "$pkg")" + fi + done + shopt -u nullglob +} + +has_pkg_named() { + local name="$1" pkg got + shopt -s nullglob + for pkg in "$STAGE"/*.pkg.tar.zst; do + got="$(pkginfo_name "$pkg")" + if [[ "$got" == "$name" ]]; then + shopt -u nullglob + return 0 + fi + done + shopt -u nullglob + return 1 +} + +collect_from_registry() { + local work db name filename enc url + work="$(mktemp -d "${TMPDIR:-/tmp}/breadway-db.XXXXXX")" + db="$work/$DB_NAME" + curl -fL --retry 3 --retry-delay 2 -o "$db" "$REGISTRY/$ARCH/$DB_NAME" \ + || die "failed to fetch $REGISTRY/$ARCH/$DB_NAME" + while IFS=$'\t' read -r name filename; do + if has_pkg_named "$name"; then + printf 'using local %s, skip registry\n' "$name" + continue + fi + enc="$(urlencode "$filename")" + url="$REGISTRY/$ARCH/$enc" + printf 'fetch %s\n' "$filename" + curl -fL --retry 3 --retry-delay 2 -o "$STAGE/$filename" "$url" \ + || die "failed to fetch $url" + done < <(parse_registry_db "$db") + rm -rf "$work" +} + +publish_tree() { + local parent dest_name prev + parent="$(dirname "$DEST")" + dest_name="$(basename "$DEST")" + mkdir -p "$parent" + chmod a+rX "$STAGE" + find "$STAGE" -type f -exec chmod a+r {} + + prev="$parent/${dest_name}.prev" + rm -rf "$prev" + if [[ -e "$DEST" ]]; then + mv "$DEST" "$prev" + fi + mv "$STAGE" "$DEST" + rm -rf "$prev" + STAGE="" +} + +if [[ "${BREADWAY_SIGN_ONLY:-0}" == 1 ]]; then + [[ -n "$REPO_DIR" ]] || die "BREADWAY_SIGN_ONLY requires BREADWAY_REPO_DIR" + sign_and_index "$REPO_DIR" + exit 0 +fi + +need_key + +DEST_PARENT="$(dirname "$DEST")" +mkdir -p "$DEST_PARENT" || die "cannot create $DEST_PARENT (runner must write /srv/breadway-dl)" +STAGE="$(mktemp -d "$DEST_PARENT/.stage-XXXXXX")" +cleanup() { + if [[ -n "${STAGE:-}" && -d "${STAGE:-}" ]]; then + rm -rf "$STAGE" + fi + if [[ -n "${GNUPGHOME:-}" && "$GNUPGHOME" == *gnupg-breadway-repo* ]]; then + rm -rf "$GNUPGHOME" + fi +} +trap cleanup EXIT + +copy_local_overrides "${BREADWAY_PKG_DIR:-}" +collect_from_registry + +missing=() +for name in "${PACKAGES[@]}"; do + has_pkg_named "$name" || missing+=("$name") +done +if (( ${#missing[@]} > 0 )); then + die "missing packages after collect: ${missing[*]}" +fi + +sign_and_index_anywhere "$STAGE" + +# Do not publish helper junk if a container left any. +rm -f "$STAGE/.sign.sh" + +publish_tree + +printf 'published signed [breadway] repo -> %s\n' "$DEST" +ls -lh "$DEST" From 055d92cc5aa84be2eb51ac9804d688a46330a1df Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 01:31:29 +0800 Subject: [PATCH 24/30] iso: pin bakery versions to the published 0.6.1 index bakery 0.7.3, bread-theme 0.7.3, bar/box 0.3.2, crumbs 2.1.8, pad 0.5.2, paper 0.1.13, mon 0.1.4, search 0.3.2, clip 0.2.3, shot 0.1.3, settings 0.8.1, help 0.2.5. bread stays 0.8.0. HEAD-checked on dl.breadway.dev before commit. --- iso/bread-lockfile.toml | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index e1ef73a..6154893 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -48,23 +48,20 @@ optional_bins = [ ] # Package name → version. Must exist at dl.breadway.dev/// and -# should match the signed index so CI can verify sha256. Newer git tags -# that bakery release CI did not publish (breadpad 0.5.1, breadmon 0.1.3, -# breadclip 0.2.2, breadshot 0.1.2, breadhelp 0.2.4, breadcrumbs 2.1.7) -# stay off this list until those artifacts exist. +# should match the signed index so CI can verify sha256. # [[pin]] { package, version } is accepted as well and merged (conflict = bake error). [versions] -bakery = "0.7.2" +bakery = "0.7.3" bread = "0.8.0" -bread-theme = "0.7.2" -breadbar = "0.3.1" -breadbox = "0.3.1" -breadcrumbs = "2.1.6" -breadpad = "0.5.0" -breadpaper = "0.1.12" -breadmon = "0.1.2" -breadsearch = "0.3.1" -breadclip = "0.1.1" -breadshot = "0.1.1" -bos-settings = "0.8.0" -breadhelp = "0.2.3" +bread-theme = "0.7.3" +breadbar = "0.3.2" +breadbox = "0.3.2" +breadcrumbs = "2.1.8" +breadpad = "0.5.2" +breadpaper = "0.1.13" +breadmon = "0.1.4" +breadsearch = "0.3.2" +breadclip = "0.2.3" +breadshot = "0.1.3" +bos-settings = "0.8.1" +breadhelp = "0.2.5" From 64aa3611b44ff85df1b4e1e2d08c2ee66e53d2fd Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 05:35:56 +0800 Subject: [PATCH 25/30] ci: do not fail signed-repo publish on chmod of gpg signatures repo-add -s already wrote the signed db; chmod a+r on .sig files returned EPERM and rolled back the stage dir. --- scripts/ci-publish-signed-repo.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh index 08fbb53..65b235b 100755 --- a/scripts/ci-publish-signed-repo.sh +++ b/scripts/ci-publish-signed-repo.sh @@ -243,8 +243,10 @@ publish_tree() { parent="$(dirname "$DEST")" dest_name="$(basename "$DEST")" mkdir -p "$parent" - chmod a+rX "$STAGE" - find "$STAGE" -type f -exec chmod a+r {} + + chmod a+rX "$STAGE" || true + # gpg --detach-sign often writes 0600 files the runner cannot chmod; + # do not fail the publish after repo-add -s already succeeded. + find "$STAGE" -type f -exec chmod a+r {} + || true prev="$parent/${dest_name}.prev" rm -rf "$prev" if [[ -e "$DEST" ]]; then From b270d64adc64810d615f7b1bf45e5f98c81fccdb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 08:07:53 +0800 Subject: [PATCH 26/30] ci: sign [breadway] repo as the runner user Docker as root left 0600 .sig files the runner could not chmod (run 1050), so publish_tree never moved the tree into /srv/breadway-dl/arch/x86_64. Sign as the host uid so nginx can read the files and the next publish can replace them. --- docs/signed-repo.md | 8 +++----- scripts/ci-publish-signed-repo.sh | 7 +++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/signed-repo.md b/docs/signed-repo.md index 02b5340..224476e 100644 --- a/docs/signed-repo.md +++ b/docs/signed-repo.md @@ -74,7 +74,7 @@ Pacman fetches `
.db` + `
.db.sig` from `Server`. ## Dispatch the workflow Forgejo UI: **Actions → "Publish signed [breadway] repo" → Run workflow**. -Select this branch (`feature/signed-repo`) until it is on `main`. +Select `main`. API (`workflow_dispatch`): @@ -83,11 +83,9 @@ curl -fsS -X POST \ -H "Authorization: token ${RELEASE_TOKEN}" \ -H "Content-Type: application/json" \ "https://git.breadway.dev/api/v1/repos/Breadway/bos/actions/workflows/signed-repo.yml/dispatches" \ - -d '{"ref":"feature/signed-repo"}' + -d '{"ref":"main"}' ``` -After merge, use `"ref":"main"`. - It also runs after the in-repo AUR republish workflows complete (`calamares` / `bibata` / `powerlevel10k` / `yay-bin`). breadlock lives in another repo; that job can fire this one with `repository_dispatch` event @@ -151,5 +149,5 @@ and the verify commands above succeed: 3. Only then flip that section to `SigLevel = Required`. Do not do those three steps against Forgejo's registry. See -`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. This branch does +`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. This tree does **not** change either file. diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh index 65b235b..f063ce2 100755 --- a/scripts/ci-publish-signed-repo.sh +++ b/scripts/ci-publish-signed-repo.sh @@ -81,6 +81,8 @@ repo_add_signed() { fi [[ -e breadway.db.tar.gz.sig || -e breadway.db.sig ]] \ || die "repo-add -s did not write breadway.db*.sig" + # gpg writes 0600; nginx and the next publish need world-readable files. + find . -maxdepth 1 -type f -exec chmod a+r {} + || true } ensure_arch_tools() { @@ -125,7 +127,12 @@ sign_and_index_anywhere() { rt="$(container_runtime)" || die \ "need host gpg+repo-add, or docker/podman to run archlinux:latest (no Forgejo container: — host must see /srv/breadway-dl)" # Host job + bind-mount, same reason bakery writes /srv without container:. + # Run as the runner user: root-owned 0600 .sig files made chmod/nginx fail + # (run 1050) and would block the next `rm -rf` of a previous tree. "$rt" run --rm --network=host \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -e TMPDIR=/tmp \ -e GPG_PRIVATE_KEY \ -e BREADWAY_SIGN_ONLY=1 \ -e BREADWAY_REPO_DIR=/repo \ From df2e1310bbeb90605d6d7450fa540fa25f5dae54 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:32:30 +0800 Subject: [PATCH 27/30] iso: audit-sweep UID, bakery update, lockfile, welcome, autostart Resolve MAIN_USER after deleting liveuser so Snapper and skel target the installed account. Wrap bakery update with sudo -n/pkexec for /usr/local. Pin bakery and bread-theme 0.7.4; require bread-emit and bread-module-host. Make Calamares internet check non-blocking against breadway.dev. Autostart breadlock listen. Smoke avahi-daemon.socket to match post-install. --- iso/airootfs/etc/calamares/modules/welcome.conf | 16 +++++++++++++--- iso/airootfs/etc/calamares/post-install.sh | 10 ++++++++-- .../etc/skel/.config/hypr/autostart.json | 1 + iso/airootfs/etc/skel/.config/hypr/hyprland.lua | 7 ++++--- .../.config/hypr/scripts/system/autostart.lua | 1 + iso/airootfs/usr/local/bin/bos-update | 10 +++++++++- iso/bread-lockfile.toml | 16 ++++++---------- scripts/smoke-test.sh | 11 ++--------- 8 files changed, 44 insertions(+), 28 deletions(-) diff --git a/iso/airootfs/etc/calamares/modules/welcome.conf b/iso/airootfs/etc/calamares/modules/welcome.conf index 33bf7ad..c31ea71 100644 --- a/iso/airootfs/etc/calamares/modules/welcome.conf +++ b/iso/airootfs/etc/calamares/modules/welcome.conf @@ -3,9 +3,19 @@ showSupportUrl: false showKnownIssuesUrl: false showReleaseNotesUrl: false +# 3.4.2 schema: `check` is shown; only `required` blocks Next. Internet is +# informational so offline installs proceed. Do not probe archlinux.org. requirements: requiredStorage: 20 requiredRam: 2.0 - checkInternet: true - checkPower: true - internetCheckUrl: "https://archlinux.org" + internetCheckUrl: "https://breadway.dev" + check: + - storage + - ram + - power + - internet + - root + required: + - storage + - ram + - root diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 5817f2e..b8d4736 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -8,8 +8,6 @@ # Best-effort: do NOT use `set -e`; a single failure here must not abort the rest. set -uo pipefail -MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)" - # Whether Calamares encrypted the root partition (LUKS) — checked once here, # used below to conditionally wire mkinitcpio's encrypt hook and GRUB's # cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened @@ -33,6 +31,14 @@ rm -f /usr/local/bin/bos-live-setup /usr/local/bin/bos-launch-calamares rm -f /etc/sudoers.d/99-bos-live userdel -r liveuser 2>/dev/null || true +# Live ISO creates liveuser as UID 1000; Calamares then creates the real +# account as 1001. Capture AFTER userdel so Snapper ALLOW_USERS and skel +# copy the installed user, not the deleted live account. +MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)" +if [[ -z "$MAIN_USER" || "$MAIN_USER" == "liveuser" ]]; then + MAIN_USER="$(getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $1 != "liveuser" { print $1; exit }')" +fi + # unpackfs copies the entire live squashfs onto the target. Remove live-only # packages (Calamares + archiso boot chain + memtest/EFI-shell payloads) so # they do not stay on disk forever. pacman -Rs (not -Rns) keeps /etc configs diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json index ee66107..be2b4ac 100644 --- a/iso/airootfs/etc/skel/.config/hypr/autostart.json +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -7,6 +7,7 @@ { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true }, + { "command": "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", "label": "Lock command bus (breadlock listen)", "enabled": true }, { "command": "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", "label": "Launcher command bus (breadbox listen)", "enabled": true }, { "command": "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", "label": "Help command bus (breadhelp listen)", "enabled": true }, { "command": "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", "label": "Search command bus (breadsearch listen)", "enabled": true }, diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 565da5b..f03e5b5 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -137,9 +137,9 @@ hl.on("hyprland.start", function() -- Clipboard history is breadclipd, a bakery-managed systemd --user -- service (auto-started from /usr/lib/systemd/user — see -- build-local.sh's service bake) rather than an exec-once here. - -- Prefer bread-polkit when bakery has published it; otherwise the - -- ISO's polkit-gnome agent. command -v so a missing binary does not - -- leave the session without an auth agent. + -- Prefer bread-polkit if it is on PATH (not baked; lockfile does not + -- ship it). Otherwise the ISO's polkit-gnome agent. command -v so a + -- missing binary does not leave the session without an auth agent. "sh -c 'if command -v bread-polkit >/dev/null; then exec bread-polkit; else exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1; fi'", "awww-daemon", -- Set the default wallpaper once the daemon is up (retry until ready). @@ -194,6 +194,7 @@ hl.on("hyprland.start", function() "breadhelp --autostart", "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", + "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua index 46f4970..63e364f 100644 --- a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -22,6 +22,7 @@ local DEFAULT_EXTRA = { { command = "breadhelp --autostart", enabled = true }, { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, + { command = "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", enabled = true }, { command = "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", enabled = true }, { command = "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", enabled = true }, { command = "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", enabled = true }, diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 16b9da8..a34f70b 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -47,7 +47,15 @@ fi echo bold "==> Bread ecosystem (bakery update --all)" if command -v bakery >/dev/null; then - bakery update --all || echo "WARN: bakery update failed" + # /usr/local is root-owned. Never run bakery as the user against it; + # bakery itself also tries sudo -n then pkexec for privileged writes. + if sudo -n true >/dev/null 2>&1; then + sudo -n bakery update --all || echo "WARN: bakery update failed" + elif command -v pkexec >/dev/null; then + pkexec bakery update --all || echo "WARN: bakery update failed" + else + echo "WARN: bakery update needs sudo -n or pkexec for /usr/local" + fi else echo "bakery not found; skipping" fi diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml index 6154893..8e21593 100644 --- a/iso/bread-lockfile.toml +++ b/iso/bread-lockfile.toml @@ -3,8 +3,8 @@ # 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.8.0 has no -# bread-emit / bread-module-host). +# skipped with a warning when it does not. bread 0.8.0 ships bread-emit and +# bread-module-host, so those are required_bins. # # A flat `bins` list is still accepted and treated as required_bins. # @@ -23,6 +23,8 @@ required_bins = [ "bakery", "bread", "breadd", + "bread-emit", + "bread-module-host", "breadman", "breadbar", "breadbox", @@ -41,19 +43,13 @@ required_bins = [ "breadhelp", ] -# Bake if the verified index publishes them; do not fail the ISO if absent. -optional_bins = [ - "bread-emit", - "bread-module-host", -] - # Package name → version. Must exist at dl.breadway.dev/// and # should match the signed index so CI can verify sha256. # [[pin]] { package, version } is accepted as well and merged (conflict = bake error). [versions] -bakery = "0.7.3" +bakery = "0.7.4" bread = "0.8.0" -bread-theme = "0.7.3" +bread-theme = "0.7.4" breadbar = "0.3.2" breadbox = "0.3.2" breadcrumbs = "2.1.8" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ccdc571..28b5c28 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -40,22 +40,15 @@ check "grub-btrfs present" "pacman -Qq grub-btrfs" echo "== enabled system services ==" for unit in NetworkManager.service greetd.service bluetooth.service tlp.service \ - cups.socket avahi-daemon.service ufw.service systemd-timesyncd.service; do + cups.socket avahi-daemon.socket ufw.service systemd-timesyncd.service; do check "$unit enabled" "systemctl is-enabled $unit" done check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" echo "== bread ecosystem on PATH ==" -for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do +for bin in bakery bread breadd bread-emit bread-module-host 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" From b38bbbac1ad8bf33cb804091783859ae22bc7271 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:23:07 +0800 Subject: [PATCH 28/30] iso: add external-monitors bread module for zero-config docking --- .../bread/modules/external-monitors.lua | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua diff --git a/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua b/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua new file mode 100644 index 0000000..27a276c --- /dev/null +++ b/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua @@ -0,0 +1,228 @@ +-- external-monitors — behave like a normal laptop desktop +-- +-- Plug in any display (HDMI, DisplayPort, USB-C dock, a random TV) and +-- the session just works. No output names to edit. +-- +-- • the laptop panel stays at its preferred (native) mode +-- • each external uses its preferred mode and refresh +-- • new screens clone the laptop (set ARRANGE = "extend" to sit to the right) +-- • closing the lid does not sleep while an external is on +-- • unplug everything and the laptop is the only display again +-- +-- Drop-in: copy to ~/.config/bread/modules/ and `bread reload`. + +local M = bread.module({ + name = "external-monitors", + version = "1.0.0", + after = { "bread.monitors" }, +}) + +-- "mirror" = every external clones the laptop (presentations, TVs) +-- "extend" = extra desktop to the right +local ARRANGE = "mirror" +local SCALE = "auto" + +local INTERNAL_RE = "^eDP" +local INHIBITOR = "/tmp/bread-lid-inhibitor.pid" + +local function inhibit_lid() + if bread.fs.exists(INHIBITOR) then return end + bread.exec( + "bash -c 'systemd-inhibit --what=handle-lid-switch --who=bread " + .. "--why=external-display sleep infinity & echo $! > " + .. INHIBITOR + .. "'" + ) +end + +local function release_lid() + bread.exec( + "bash -c 'kill $(cat " .. INHIBITOR .. " 2>/dev/null) 2>/dev/null; rm -f " .. INHIBITOR .. "'" + ) +end + +local function is_internal(name) + return type(name) == "string" and name:match(INTERNAL_RE) ~= nil +end + +local function drm_status(name) + for card = 0, 5 do + local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/status", card, name)) + if raw then + return raw:match("^%s*(%S+)") + end + end + return nil +end + +local function drm_first_mode(name) + for card = 0, 5 do + local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/modes", card, name)) + if raw then + local w, h = raw:match("(%d+)x(%d+)") + if w then + return tonumber(w), tonumber(h) + end + end + end + return 1920, 1080 +end + +local function list_connectors() + local names = {} + local ok, out = bread.exec_capture("ls /sys/class/drm", { timeout_ms = 500 }) + if not ok or not out then + return names + end + for ent in out:gmatch("[^%s]+") do + local name = ent:match("^card%d+%-(.+)$") + if name and not name:match("^Writeback") then + names[#names + 1] = name + end + end + table.sort(names) + return names +end + +local function connected() + local internal, externals = nil, {} + for _, name in ipairs(list_connectors()) do + if drm_status(name) == "connected" then + if is_internal(name) then + internal = internal or name + else + externals[#externals + 1] = name + end + end + end + return internal or "eDP-1", externals +end + +-- BOS Hyprland talks Lua (`hl.monitor`). Stock Hyprland uses the +-- `monitor=` keyword. Try eval first, then keyword. +local function apply_monitor(opts) + local extra = "" + if opts.mirror and opts.mirror ~= "" then + extra = string.format(", mirror = %q", opts.mirror) + end + local expr = string.format( + "hl.monitor({ output = %q, mode = %q, position = %q, scale = %q%s })", + opts.output, + opts.mode or "preferred", + opts.position or "0x0", + opts.scale or SCALE, + extra + ) + local resp = bread.hyprland.eval(expr) + if type(resp) == "string" and resp:match("error") then + local spec = string.format( + "%s, %s, %s, %s", + opts.output, + opts.mode or "preferred", + opts.position or "0x0", + opts.scale or SCALE + ) + if opts.mirror and opts.mirror ~= "" then + spec = spec .. ", mirror, " .. opts.mirror + end + bread.hyprland.keyword("monitor", spec) + end +end + +local function apply(internal, externals) + apply_monitor({ + output = internal, + mode = "preferred", + position = "0x0", + scale = SCALE, + }) + + if ARRANGE == "mirror" then + for _, name in ipairs(externals) do + apply_monitor({ + output = name, + mode = "preferred", + position = "0x0", + scale = SCALE, + mirror = internal, + }) + end + return + end + + local x = select(1, drm_first_mode(internal)) or 1920 + for _, name in ipairs(externals) do + apply_monitor({ + output = name, + mode = "preferred", + position = x .. "x0", + scale = SCALE, + }) + local w = select(1, drm_first_mode(name)) or 1920 + x = x + w + end +end + +function M.on_load() + local last = nil + local applied = false + + local function evaluate() + local internal, externals = connected() + local sig = internal .. "|" .. table.concat(externals, ",") + if sig == last then + return + end + last = sig + + if #externals == 0 then + if applied then + apply_monitor({ + output = internal, + mode = "preferred", + position = "0x0", + scale = SCALE, + }) + release_lid() + applied = false + end + return + end + + apply(internal, externals) + inhibit_lid() + applied = true + bread.log("[external-monitors] " .. internal .. " + " .. table.concat(externals, ", ")) + end + + local settle = bread.debounce(1500, evaluate) + + bread.on("bread.hyprland.monitor.connected", function(event) + local name = event.data and event.data.name + if name and not is_internal(name) then + bread.notify("Display connected: " .. name, { urgency = "low" }) + end + settle() + end) + + bread.on("bread.hyprland.monitor.disconnected", function() + settle() + end) + + bread.on("bread.device.**", function(event) + local sub = event.data and event.data.subsystem + if sub == "drm" then + settle() + end + end) + + bread.hyprland.on_raw("configreloaded", function() + last = nil + evaluate() + end) + + bread.every(3000, evaluate) + settle() +end + +return M From 98cfe9d60bdf3fbf6ffaa2926fa1cd020e50257a Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 18:22:55 +0800 Subject: [PATCH 29/30] ISO: flip [breadway] to the signed dl.breadway.dev/arch repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed repo is live: https://dl.breadway.dev/arch/x86_64/ serves breadway.db + .files + every .pkg.tar.zst with a detached .sig from the BOS release key (56203B86A110695AE7F310934AF3323D678EB5E2 = KEYS.asc), rebuilt from the Forgejo Arch registry by signed-repo.yml + scripts/ci-publish-signed-repo.sh. Verified: db/pkg sigs all GPG-good against KEYS.asc. Executes the "After the signed repo exists" plan in docs/signed-repo.md: - `iso/pacman.conf` + `iso/airootfs/etc/pacman.conf`: section renamed `[Breadway.os.git.breadway.dev]` → `[breadway]` (matches breadway.db), `Server = https://dl.breadway.dev/arch/$arch`, `SigLevel = Required`. The old "Forgejo has no db sigs / KEYS.asc is not a repo key / do NOT flip" comments are gone — both are now false. - `iso/airootfs/etc/pacman.d/breadway-repo.asc`: the public key, baked into the image. - `build-local.sh`: trust the key in the build host's pacman keyring before mkarchiso (so pacstrap can verify [breadway] while assembling the airootfs); drop the now-obsolete Forgejo-registry URL rewrite. - `iso/airootfs/root/customize_airootfs.sh` (new): trust the key in the image keyring so the live medium — and, via calamares unpackfs, the installed target — verify [breadway]. (archiso warns this hook is deprecated; there is no replacement for "add a repo key to the image keyring" and BOS ships no pacman-init.service.) - `calamares/post-install.sh`: `pacman-key --add` + `--lsign-key` the BOS key in the target chroot as a fallback (unpackfs can skip /etc/pacman.d/gnupg). - README.md / DESIGN.md / docs/signed-repo.md updated. NOT yet done: build the ISO (`sudo ./build-local.sh`) and VM-verify `pacman -Sy` + a `[breadway]` install with no signature prompt, on both the live medium and a fresh install. The build-time keyring path (pacstrap -G vs host keyring vs customize_airootfs) may need a tweak once the real build runs. --- DESIGN.md | 2 +- README.md | 13 ++--- build-local.sh | 21 +++++--- docs/signed-repo.md | 58 +++++++++++++-------- iso/airootfs/etc/calamares/post-install.sh | 18 +++++-- iso/airootfs/etc/pacman.conf | 25 +++++---- iso/airootfs/etc/pacman.d/breadway-repo.asc | 15 ++++++ iso/airootfs/root/customize_airootfs.sh | 27 ++++++++++ iso/pacman.conf | 25 +++++---- 9 files changed, 137 insertions(+), 67 deletions(-) create mode 100644 iso/airootfs/etc/pacman.d/breadway-repo.asc create mode 100644 iso/airootfs/root/customize_airootfs.sh diff --git a/DESIGN.md b/DESIGN.md index 31b22f8..6a075a8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,7 +16,7 @@ taken as current: | 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. | +| `SigLevel = Required` on `[breadway]` | **Yes, as of the signed repo.** `[breadway]` points at `https://dl.breadway.dev/arch` where `scripts/ci-publish-signed-repo.sh` detach-signs every `.pkg.tar.zst` and the db with the BOS release key (`56203B86…`, `KEYS.asc`). That key is trusted in the pacman keyring at build time, on the live medium, and on the installed target. | --- diff --git a/README.md b/README.md index 1a833b6..22b0108 100644 --- a/README.md +++ b/README.md @@ -174,10 +174,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). 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` until a signed repo exists — see +The public half is committed at [`KEYS.asc`](KEYS.asc). The same key signs +the ISO checksums **and** the `[breadway]` pacman repo — every package and +the db at `https://dl.breadway.dev/arch` carry a `.sig` from it, and that +section is `SigLevel = Required` (see [docs/signed-repo.md](docs/signed-repo.md)). To verify a download: ```sh @@ -381,8 +381,9 @@ until `dl.breadway.dev/arch` exists). - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default btrfs subvolume layout the installer creates. Recovery is the GRUB snapshots submenu, not `snapper rollback` — [docs/hardware.md](docs/hardware.md). -- **`[breadway]` signatures**: `SigLevel = Never` until a signed repo is - stood up at `dl.breadway.dev/arch`. See [docs/signed-repo.md](docs/signed-repo.md). +- **`[breadway]` signatures**: `SigLevel = Required` — the signed repo at + `dl.breadway.dev/arch` is live (db + every package `.sig`ned with the BOS + release key). See [docs/signed-repo.md](docs/signed-repo.md). ## Recovery diff --git a/build-local.sh b/build-local.sh index e360d42..4b95661 100755 --- a/build-local.sh +++ b/build-local.sh @@ -25,14 +25,19 @@ OUT="${OUT:-$REPO/out}" STAGE=/tmp/bos-iso-stage rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE" -# Rewrite the [breadway] pacman repo URL to the fastest reachable address. -# CI_BUILD=1 — container runs on hestia with --network=host; localhost:3002 is direct -# default — building on hermes; git.breadway.dev is flaky from there, use Tailscale -# Only ever rewrites the staged copy, never the committed pacman.conf. -if [ "${CI_BUILD:-0}" = "1" ]; then - sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://localhost:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf" -else - sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://100.66.238.26:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf" +# [breadway] now points at the signed public repo https://dl.breadway.dev/arch +# (SigLevel = Required) — no Forgejo-registry URL rewrite needed anymore. +# +# Trust the [breadway] repo key in *this* build host's pacman keyring so +# `pacstrap` can verify [breadway] packages while assembling the airootfs. +# The same key is baked into the image at etc/pacman.d/breadway-repo.asc and +# re-trusted on the live medium / installed target (calamares/post-install.sh). +BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" +BREADWAY_KEY_SRC="$REPO/iso/airootfs/etc/pacman.d/breadway-repo.asc" +if ! pacman-key --list-keys "$BREADWAY_KEY_FPR" &>/dev/null; then + echo "=== trusting [breadway] repo key ($BREADWAY_KEY_FPR) in the host pacman keyring ===" + pacman-key --add "$BREADWAY_KEY_SRC" + pacman-key --lsign-key "$BREADWAY_KEY_FPR" fi if [ "${FAST_BUILD:-0}" = "1" ]; then diff --git a/docs/signed-repo.md b/docs/signed-repo.md index 224476e..b538e59 100644 --- a/docs/signed-repo.md +++ b/docs/signed-repo.md @@ -1,18 +1,20 @@ # Signed `[breadway]` repo -Today the ISO's `[Breadway.os.git.breadway.dev]` section is -`SigLevel = Never`. That is TLS-only integrity: packages come from Forgejo's -Arch registry, which does **not** serve pacman-compatible database -signatures. `KEYS.asc` signs **ISO `SHA256SUMS`** and, once published, the -`dl.breadway.dev/arch` database. It is **not** imported as a pacman repo key -on the ISO yet. Do not flip `SigLevel` to `Required` on that section until -a signed repo exists and has been verified; Required without signatures -breaks the ISO and every installed system. +**Status: live.** The ISO's `[breadway]` section is `SigLevel = Required` +and points at `https://dl.breadway.dev/arch/$arch`, where every +`.pkg.tar.zst` and the db carry a detached `.sig` from the BOS release key +(`56203B86…`, `KEYS.asc`, `releases@breadway.dev`). That key is trusted in +the pacman keyring at build time (`build-local.sh`), on the live medium +(`iso/airootfs/root/customize_airootfs.sh`), and on the installed target +(`iso/airootfs/etc/calamares/post-install.sh`). -The signed repo belongs at `https://dl.breadway.dev/arch`, not on Forgejo's -registry. Forgejo publishing stays as it is (`package.yml` / packaging -workflows PUT unsigned `.pkg.tar.zst` so existing Never installs keep -working). +Forgejo publishing is unchanged: `package.yml` / packaging workflows still +PUT unsigned `.pkg.tar.zst` to Forgejo's Arch registry. The signed tree at +`dl.breadway.dev/arch` is rebuilt from that registry by +`.forgejo/workflows/signed-repo.yml` + `scripts/ci-publish-signed-repo.sh`. + +The rest of this doc is the original stand-up / verification procedure, +kept for reference and for re-verifying after key rotation. ## Stand up `dl.breadway.dev/arch` @@ -139,15 +141,29 @@ stays; Never installs keep working. The signed tree is rebuilt by the bos workflow above (registry fetch + sign + `repo-add -s`), not by writing `/srv` from breadlock's container. -## After the signed repo exists +## The ISO flip (done) -Only after `https://dl.breadway.dev/arch/x86_64/breadway.db.sig` HEADs 200 -and the verify commands above succeed: +All three steps have landed: -1. Import `KEYS.asc` into the ISO keyring (`pacman-key --add` + `--lsign-key`). -2. Point `[breadway]` `Server` at `https://dl.breadway.dev/arch/$arch`. -3. Only then flip that section to `SigLevel = Required`. +1. **Key trusted.** The public key is committed at + `iso/airootfs/etc/pacman.d/breadway-repo.asc`. `build-local.sh` + `pacman-key --add` + `--lsign-key`s it into the build host keyring; + `customize_airootfs.sh` does the same in the airootfs; + `calamares/post-install.sh` re-does it in the target chroot. +2. **`Server`** in `iso/pacman.conf` and `iso/airootfs/etc/pacman.conf` + points at `https://dl.breadway.dev/arch/$arch`, section renamed to + `[breadway]` (matching `breadway.db`). +3. **`SigLevel = Required`** on that section. -Do not do those three steps against Forgejo's registry. See -`iso/pacman.conf` and `iso/airootfs/etc/pacman.conf`. This tree does -**not** change either file. +### Re-verify after any build + +In a VM booted from a fresh ISO: + +```sh +sudo pacman -Sy # must fetch breadway.db + .sig, no signature error +sudo pacman -Si breadlock # lists the [breadway] section +sudo pacman -S --noconfirm yay-bin # installs with no key prompt +``` + +Then run the installer and, on the installed system, `sudo pacman -Sy` +again — the target keyring must already trust `56203B86…`. diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 5817f2e..05b3883 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -67,15 +67,23 @@ 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 -# 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. +# fresh install can update out of the box. archlinux-keyring verifies official +# Arch packages; the BOS release key (56203B86…, shipped at +# /etc/pacman.d/breadway-repo.asc) verifies the signed [breadway] repo at +# dl.breadway.dev/arch — SigLevel = Required there, every package and the db +# carry a .sig from it. # --------------------------------------------------------------------------- +BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" if command -v pacman-key &>/dev/null; then pacman-key --init || echo "WARN: pacman-key --init failed" pacman-key --populate archlinux || echo "WARN: pacman-key --populate failed" + if [[ -f /etc/pacman.d/breadway-repo.asc ]]; then + pacman-key --add /etc/pacman.d/breadway-repo.asc \ + && pacman-key --lsign-key "$BREADWAY_KEY_FPR" \ + || echo "WARN: could not trust the [breadway] repo key — pacman -Sy will fail on [breadway]" + else + echo "WARN: /etc/pacman.d/breadway-repo.asc missing — [breadway] (SigLevel=Required) will not verify" + fi fi # --------------------------------------------------------------------------- diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 4e4435e..2f53c10 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -32,18 +32,17 @@ Include = /etc/pacman.d/mirrorlist # are NOT here; they are bakery-baked into /usr/local at ISO build time. # # 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; scripts/ci-publish-signed-repo.sh then +# collects them, detach-signs each .pkg.tar.zst with the BOS release key +# (releases@breadway.dev), runs `repo-add -s`, and publishes the signed db +# at https://dl.breadway.dev/arch/$arch (signed-repo.yml). # -# 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. +# SigLevel = Required: every package AND the db carry a .sig from key +# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as +# KEYS.asc / etc/pacman.d/breadway-repo.asc, imported into the pacman +# keyring at build time (build-local.sh), on the live medium, and on the +# installed target (calamares/post-install.sh). # ----------------------------------------------------------------------- -# The section name must match Forgejo's served db filename -# ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. -[Breadway.os.git.breadway.dev] -SigLevel = Never -Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch +[breadway] +SigLevel = Required +Server = https://dl.breadway.dev/arch/$arch diff --git a/iso/airootfs/etc/pacman.d/breadway-repo.asc b/iso/airootfs/etc/pacman.d/breadway-repo.asc new file mode 100644 index 0000000..fe380fd --- /dev/null +++ b/iso/airootfs/etc/pacman.d/breadway-repo.asc @@ -0,0 +1,15 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL +TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl +YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+ +iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL +CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD +ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs +K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9 +gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461 +4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq +E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU +cAo= +=Mrh1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/iso/airootfs/root/customize_airootfs.sh b/iso/airootfs/root/customize_airootfs.sh new file mode 100644 index 0000000..149c6c4 --- /dev/null +++ b/iso/airootfs/root/customize_airootfs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run by mkarchiso inside the airootfs chroot, after packages are installed +# and before the squashfs is built. (archiso prints a deprecation warning for +# this hook, but there is no non-deprecated replacement for "trust an extra +# pacman repo key in the image keyring", and BOS ships no pacman-init.service.) +# +# Purpose: trust the BOS release key (56203B86…) in the image's pacman +# keyring so the signed [breadway] repo (SigLevel = Required, +# https://dl.breadway.dev/arch) verifies both on the live medium and — via +# calamares' unpackfs, which copies this squashfs to the target — on the +# installed system. calamares/post-install.sh re-does this in the target +# chroot as a fallback (unpackfs can skip /etc/pacman.d/gnupg). +set -euo pipefail + +BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" +KEY_FILE="/etc/pacman.d/breadway-repo.asc" + +pacman-key --init +pacman-key --populate archlinux + +if [[ -f "$KEY_FILE" ]]; then + pacman-key --add "$KEY_FILE" + pacman-key --lsign-key "$BREADWAY_KEY_FPR" + echo "customize_airootfs: trusted [breadway] repo key $BREADWAY_KEY_FPR" +else + echo "customize_airootfs: WARNING $KEY_FILE missing; [breadway] will not verify" >&2 +fi diff --git a/iso/pacman.conf b/iso/pacman.conf index be3d52f..abb7e2f 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -49,18 +49,17 @@ Include = /etc/pacman.d/mirrorlist # are NOT here; they are bakery-baked into /usr/local at ISO build time. # # 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; scripts/ci-publish-signed-repo.sh then +# collects them, detach-signs each .pkg.tar.zst with the BOS release key +# (releases@breadway.dev), runs `repo-add -s`, and publishes the signed db +# at https://dl.breadway.dev/arch/$arch (signed-repo.yml). # -# 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. +# SigLevel = Required: every package AND the db carry a .sig from key +# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as +# KEYS.asc / airootfs/etc/pacman.d/breadway-repo.asc, imported into the +# pacman keyring at build time (build-local.sh), on the live medium, and +# on the installed target (calamares/post-install.sh). # ----------------------------------------------------------------------- -# The section name must match Forgejo's served db filename -# ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. -[Breadway.os.git.breadway.dev] -SigLevel = Never -Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch +[breadway] +SigLevel = Required +Server = https://dl.breadway.dev/arch/$arch From 716c77f93b685a5b273484628279cce6ead85d64 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 19:15:38 +0800 Subject: [PATCH 30/30] packaging: republish python-pywal to [breadway] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-pywal was dropped from Arch's [extra] repo (AUR-only now), so `pacstrap` can no longer resolve it and every ISO build fails with "target not found: python-pywal". The `wal` binary is load-bearing — bread-theme shells out to it to extract the colour palette from the user's wallpaper. Republish it the same way as calamares / bibata / powerlevel10k / yay-bin: - `packaging/python-pywal/PKGBUILD` — in-house copy of the AUR PKGBUILD (Morten Linderud's), modernised to `python -m build` / `installer` instead of the removed `setup.py install`, sha256-only sources like the sibling PKGBUILDs. Test-built locally: 28 unit tests pass, package ships `/usr/bin/wal`. - `.forgejo/workflows/python-pywal.yml` — builds + PUTs to the Forgejo Arch registry on a push to `packaging/python-pywal/**`. - `signed-repo.yml` gains it as a `workflow_run` trigger; `ci-publish-signed-repo.sh` gains it in `PACKAGES` so the signed dl.breadway.dev/arch db picks it up. - packages.x86_64 keeps the `python-pywal` line (now sourced from [breadway]) with a note. Ordering: `python-pywal.yml` must publish to the registry once before `signed-repo.yml` runs, or the collect step errors "registry db missing packages: python-pywal". --- .forgejo/workflows/python-pywal.yml | 39 +++++++++++++++++++++++++ .forgejo/workflows/signed-repo.yml | 1 + docs/signed-repo.md | 4 +-- iso/packages.x86_64 | 2 ++ packaging/arch/README.md | 2 +- packaging/python-pywal/PKGBUILD | 44 +++++++++++++++++++++++++++++ scripts/ci-publish-signed-repo.sh | 1 + 7 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/python-pywal.yml create mode 100644 packaging/python-pywal/PKGBUILD diff --git a/.forgejo/workflows/python-pywal.yml b/.forgejo/workflows/python-pywal.yml new file mode 100644 index 0000000..f5f7d61 --- /dev/null +++ b/.forgejo/workflows/python-pywal.yml @@ -0,0 +1,39 @@ +name: Build and publish python-pywal + +# python-pywal was dropped from Arch's [extra] repo (AUR-only now), but the ISO +# needs the `wal` binary (bread-theme extracts the wallpaper palette with it). +# BOS keeps an in-house PKGBUILD and publishes to the [breadway] repo — same +# pattern as calamares / bibata / powerlevel10k / yay-bin. +on: + push: + paths: + - 'packaging/python-pywal/**' + workflow_dispatch: + +jobs: + python-pywal: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git \ + python python-build python-installer python-wheel python-setuptools imagemagick + useradd -m builder + git config --global --add safe.directory '*' + # Clone the ref that triggered this run (not the default branch) — + # same as the other packaging workflows. + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + chown -R builder:builder /home/builder/src + su builder -c "cd /home/builder/src/packaging/python-pywal && makepkg -f --noconfirm" + PKG=$(find /home/builder/src/packaging/python-pywal -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/signed-repo.yml b/.forgejo/workflows/signed-repo.yml index 3ef8a65..caa5d15 100644 --- a/.forgejo/workflows/signed-repo.yml +++ b/.forgejo/workflows/signed-repo.yml @@ -21,6 +21,7 @@ on: - Build and publish bibata-cursor-theme - Build and publish powerlevel10k - Build and publish yay-bin + - Build and publish python-pywal types: [completed] concurrency: diff --git a/docs/signed-repo.md b/docs/signed-repo.md index 224476e..2655774 100644 --- a/docs/signed-repo.md +++ b/docs/signed-repo.md @@ -47,7 +47,7 @@ On disk: `/srv/breadway-dl/arch/x86_64/` (nginx already serves The job collects the current ISO `[breadway]` set from the Forgejo Arch registry (breadlock + calamares, zen-browser-bin, bibata-cursor-theme-bin, -zsh-theme-powerlevel10k, yay-bin). Leftover bakery-channel pacman packages +zsh-theme-powerlevel10k, yay-bin, python-pywal). Leftover bakery-channel pacman packages still sitting in that registry are **not** copied. Optional `BREADWAY_PKG_DIR` on the runner overrides individual files. @@ -87,7 +87,7 @@ curl -fsS -X POST \ ``` It also runs after the in-repo AUR republish workflows complete -(`calamares` / `bibata` / `powerlevel10k` / `yay-bin`). breadlock lives in +(`calamares` / `bibata` / `powerlevel10k` / `yay-bin` / `python-pywal`). breadlock lives in another repo; that job can fire this one with `repository_dispatch` event `publish-signed-repo` (or dispatch from the UI after a breadlock tag). diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index ff90edf..d30488a 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -225,6 +225,8 @@ slurp wl-clipboard playerctl # Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper). +# python-pywal was dropped from Arch [extra] (AUR-only now) — republished to +# [breadway], see packaging/python-pywal. awww python-pywal # Boot splash (BOS logo + spinner instead of kernel text). diff --git a/packaging/arch/README.md b/packaging/arch/README.md index ce80f7e..1116280 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -3,7 +3,7 @@ Arch packaging This directory only holds `PKGBUILD`s for third-party AUR packages BOS republishes to the `[breadway]` pacman repo (`calamares`, `bibata`, -`powerlevel10k`, `yay-bin`) — not the user's own code. See each +`powerlevel10k`, `yay-bin`, `python-pywal`) — not the user's own code. See each subdirectory's `.forgejo/workflows/.yml` (in this repo) for how each one publishes on a push to `packaging//**`. diff --git a/packaging/python-pywal/PKGBUILD b/packaging/python-pywal/PKGBUILD new file mode 100644 index 0000000..b6796f5 --- /dev/null +++ b/packaging/python-pywal/PKGBUILD @@ -0,0 +1,44 @@ +# BOS in-house rebuild of python-pywal. +# +# python-pywal was dropped from Arch's [extra] repo (it is now AUR-only), but +# BOS needs the `wal` binary: bread-theme shells out to it to extract a colour +# palette from the user's wallpaper. Republished to [breadway] so the ISO can +# pull it via pacman, same pattern as calamares / bibata / powerlevel10k / +# yay-bin. Source of truth: https://aur.archlinux.org/packages/python-pywal +# +# Maintainer: Breadway +# Upstream maintainer: Morten Linderud +# Contributor: Sean Haugh + +pkgname=python-pywal +pkgver=3.3.0 +pkgrel=11 +pkgdesc="Generate and change colorschemes on the fly" +arch=('any') +url="https://github.com/dylanaraps/pywal/" +license=('MIT') +depends=('python' 'imagemagick') +makedepends=('python-build' 'python-installer' 'python-wheel' 'python-setuptools') +optdepends=('feh: set wallpaper' + 'nitrogen: set wallpaper') +# BOS PKGBUILDs verify sources by sha256 only (no source PGP), matching +# calamares / powerlevel10k here. +source=("$pkgname-$pkgver.tar.gz::https://github.com/dylanaraps/pywal/archive/${pkgver}.tar.gz") +sha256sums=('fe8fc1c29d1cad1a1a8580293dcfe32e1fac259f9dbfd5c8877439fa5948d189') + +build() { + cd "pywal-${pkgver}" + # setup.py-only project: python-build injects the setuptools backend. + python -m build --wheel --no-isolation +} + +check() { + cd "pywal-${pkgver}" + python -m unittest discover -vs tests +} + +package() { + cd "pywal-${pkgver}" + python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh index f063ce2..6ded0e0 100755 --- a/scripts/ci-publish-signed-repo.sh +++ b/scripts/ci-publish-signed-repo.sh @@ -22,6 +22,7 @@ PACKAGES=( bibata-cursor-theme-bin zsh-theme-powerlevel10k yay-bin + python-pywal ) ARCH="${BREADWAY_ARCH:-x86_64}"