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.
This commit is contained in:
parent
3bd278c1b9
commit
96a2f685a2
10 changed files with 336 additions and 47 deletions
41
README.md
41
README.md
|
|
@ -43,11 +43,12 @@ wiring up dotfiles, no per-tool bakery installs.
|
||||||
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
|
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
|
||||||
with avahi mDNS discovery, TLP power management, fwupd firmware updates.
|
with avahi mDNS discovery, TLP power management, fwupd firmware updates.
|
||||||
Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is
|
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
|
- **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
|
||||||
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
|
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
|
||||||
A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot,
|
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
|
- **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock
|
||||||
LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via
|
LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via
|
||||||
`sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the
|
`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, …) |
|
| **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) |
|
||||||
| **Not shipped** | `breadcast`, `breadarr` |
|
| **Not shipped** | `breadcast`, `breadarr` |
|
||||||
|
|
||||||
The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml).
|
The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml)
|
||||||
`build-local.sh` fails if any **required** binary is missing on the builder.
|
(plus optional `[versions]` / `[[pin]]` so CI fetches
|
||||||
|
`https://dl.breadway.dev/<pkg>/<ver>/...`). `build-local.sh` fails if any
|
||||||
|
**required** binary is missing on the builder.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
|
|
@ -74,7 +77,7 @@ repos and arrive via bakery.
|
||||||
```
|
```
|
||||||
bos/
|
bos/
|
||||||
├── iso/ # archiso profile
|
├── iso/ # archiso profile
|
||||||
│ ├── bread-lockfile.toml # bakery bins (required + optional)
|
│ ├── bread-lockfile.toml # bakery bins + optional version pins
|
||||||
│ ├── profiledef.sh
|
│ ├── profiledef.sh
|
||||||
│ ├── packages.x86_64 # live + installed pacman set
|
│ ├── packages.x86_64 # live + installed pacman set
|
||||||
│ └── airootfs/ # files overlaid onto the image
|
│ └── airootfs/ # files overlaid onto the image
|
||||||
|
|
@ -91,6 +94,9 @@ bos/
|
||||||
│ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME
|
│ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME
|
||||||
│ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso
|
│ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso
|
||||||
│ └── smoke-test.sh
|
│ └── 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
|
├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release
|
||||||
├── build-local.sh # native ISO build for this machine
|
├── build-local.sh # native ISO build for this machine
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|
@ -133,8 +139,10 @@ non-zero** if any **required** lockfile binary (or breadhelp content) is
|
||||||
missing. Optional bins are skipped with a warning.
|
missing. Optional bins are skipped with a warning.
|
||||||
|
|
||||||
CI stages the builder from the **minisign-verified** stable bakery index
|
CI stages the builder from the **minisign-verified** stable bakery index
|
||||||
(`index.json` + `index.json.minisig`); local builds still snapshot the
|
(`index.json` + `index.json.minisig`) and prefers lockfile `[versions]`
|
||||||
builder. The lockfile is names only.
|
URLs (`https://dl.breadway.dev/<pkg>/<ver>/...`) 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
|
### 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
|
The public half is committed at [`KEYS.asc`](KEYS.asc). That key signs
|
||||||
**ISO checksums only** — it does not sign the `[breadway]` pacman repo
|
**ISO checksums only** — it does not sign the `[breadway]` pacman repo
|
||||||
(Forgejo's Arch registry has no pacman-compatible db signatures; that
|
(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
|
```sh
|
||||||
gpg --import KEYS.asc
|
gpg --import KEYS.asc
|
||||||
|
|
@ -303,9 +312,13 @@ cheatsheet in-session; first boot shows a short welcome (once).
|
||||||
|
|
||||||
## Known limitations
|
## 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.
|
- **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 is **unsupported** (no proprietary driver, no NVIDIA firmware). See
|
||||||
`nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install.
|
[docs/hardware.md](docs/hardware.md).
|
||||||
- **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use
|
- **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use
|
||||||
`virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is
|
`virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is
|
||||||
noticeably laggy.
|
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
|
BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted
|
||||||
install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id).
|
install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id).
|
||||||
- **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default
|
- **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
|
## 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
|
Do **not** run `snapper rollback` as the default recovery step. BOS GRUB
|
||||||
pins `rootflags=subvol=@`, so a snapper-swapped default subvolume is not
|
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
|
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
|
A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is
|
||||||
not shipped.
|
not shipped.
|
||||||
|
|
|
||||||
21
docs/hardware.md
Normal file
21
docs/hardware.md
Normal file
|
|
@ -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.
|
||||||
85
docs/signed-repo.md
Normal file
85
docs/signed-repo.md
Normal file
|
|
@ -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-<ver>-1-x86_64.pkg.tar.zst
|
||||||
|
breadlock-<ver>-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 `<section>.db` + `<section>.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-<ver>-1-x86_64.pkg.tar.zst
|
||||||
|
# → breadlock-<ver>-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`.
|
||||||
|
|
@ -5,7 +5,7 @@ ID_LIKE=arch
|
||||||
BUILD_ID=rolling
|
BUILD_ID=rolling
|
||||||
ANSI_COLOR="38;2;23;147;209"
|
ANSI_COLOR="38;2;23;147;209"
|
||||||
HOME_URL="https://breadway.dev"
|
HOME_URL="https://breadway.dev"
|
||||||
DOCUMENTATION_URL="https://wiki.archlinux.org/"
|
DOCUMENTATION_URL="https://git.breadway.dev/Breadway/bos"
|
||||||
SUPPORT_URL="https://bbs.archlinux.org/"
|
SUPPORT_URL="https://git.breadway.dev/Breadway/bos/issues"
|
||||||
BUG_REPORT_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"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
{ "command": "breadbar", "label": "Bar (breadbar)", "enabled": true },
|
{ "command": "breadbar", "label": "Bar (breadbar)", "enabled": true },
|
||||||
{ "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true },
|
{ "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true },
|
||||||
{ "command": "bos-netcheck", "label": "Network connectivity check", "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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,10 @@ hl.gesture({
|
||||||
-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's
|
-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's
|
||||||
-- timing/order-sensitive infrastructure, not something a settings UI should
|
-- timing/order-sensitive infrastructure, not something a settings UI should
|
||||||
-- expose for a user to disable or reorder. The extra, genuinely toggleable
|
-- expose for a user to disable or reorder. The extra, genuinely toggleable
|
||||||
-- apps (breadbar, hypridle, bos-netcheck, breadhelp) come from
|
-- apps (breadbar, hypridle, bos-netcheck, breadhelp, breadpaper/breadshot
|
||||||
-- autostart.json via scripts/system/autostart.lua, appended after.
|
-- 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.)
|
-- (bos-live-setup appends the live-installer launch below this on the ISO.)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
hl.on("hyprland.start", function()
|
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
|
-- autostart.json/its loader broke — fall back to the same apps BOS
|
||||||
-- has always started, so a bad JSON edit degrades to "normal
|
-- has always started, so a bad JSON edit degrades to "normal
|
||||||
-- desktop" rather than "no bar, no idle lock, no onboarding".
|
-- 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
|
end
|
||||||
for _, cmd in ipairs(extra) do
|
for _, cmd in ipairs(extra) do
|
||||||
hl.dispatch(hl.dsp.exec_cmd(cmd))
|
hl.dispatch(hl.dsp.exec_cmd(cmd))
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,15 @@
|
||||||
-- because the valid result happens to be empty.
|
-- because the valid result happens to be empty.
|
||||||
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
|
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 = {
|
local DEFAULT_EXTRA = {
|
||||||
{ command = "breadbar", enabled = true },
|
{ command = "breadbar", enabled = true },
|
||||||
{ command = "hypridle", enabled = true },
|
{ command = "hypridle", enabled = true },
|
||||||
{ command = "bos-netcheck", enabled = true },
|
{ command = "bos-netcheck", enabled = true },
|
||||||
{ command = "breadhelp --autostart", 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()
|
return function()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,24 @@ set -uo pipefail
|
||||||
|
|
||||||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
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)"
|
bold "==> System packages (pacman -Syu)"
|
||||||
if command -v pacman >/dev/null; then
|
if command -v pacman >/dev/null; then
|
||||||
sudo pacman -Syu || echo "WARN: pacman update failed"
|
sudo pacman -Syu || echo "WARN: pacman update failed"
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,12 @@
|
||||||
# A flat `bins` list is still accepted and treated as required_bins.
|
# A flat `bins` list is still accepted and treated as required_bins.
|
||||||
#
|
#
|
||||||
# CI populates the builder from the minisign-verified stable bakery index
|
# CI populates the builder from the minisign-verified stable bakery index
|
||||||
# (https://dl.breadway.dev/index.json). Local builds still snapshot whatever
|
# (https://dl.breadway.dev/index.json). Optional [versions] (or [[pin]]
|
||||||
# is installed on the builder; this file only names what must / may be present.
|
# 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/<pkg>/<ver>/...
|
||||||
|
# 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.
|
# Not shipped (even if they appear in the index): breadcast, breadarr.
|
||||||
# breadlock is pacman (see packages.x86_64), not bakery.
|
# breadlock is pacman (see packages.x86_64), not bakery.
|
||||||
|
|
@ -42,3 +46,22 @@ optional_bins = [
|
||||||
"bread-emit",
|
"bread-emit",
|
||||||
"bread-module-host",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from pathlib import Path
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
INDEX_URL = "https://dl.breadway.dev/index.json"
|
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.
|
# Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs.
|
||||||
MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
|
MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
|
||||||
BLOCKED = frozenset({"breadcast", "breadarr"})
|
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 (".", "..")
|
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:
|
with path.open("rb") as f:
|
||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
required = data.get("required_bins")
|
required = data.get("required_bins")
|
||||||
|
|
@ -66,15 +101,36 @@ def load_lockfile(path: Path) -> tuple[list[str], list[str]]:
|
||||||
overlap = set(required) & set(optional)
|
overlap = set(required) & set(optional)
|
||||||
if overlap:
|
if overlap:
|
||||||
die(f"{path}: bins in both required and optional: {sorted(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)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
urllib.request.urlretrieve(url, dest)
|
urllib.request.urlretrieve(url, dest)
|
||||||
|
return True
|
||||||
except (urllib.error.URLError, OSError) as e:
|
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:
|
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}")
|
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:
|
def stage_file(
|
||||||
fetch(url, dest)
|
url: str,
|
||||||
verify_sha256(dest, sha256, what)
|
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:
|
if mode is not None:
|
||||||
dest.chmod(mode)
|
dest.chmod(mode)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|
@ -216,8 +283,11 @@ def main() -> int:
|
||||||
if not lockfile.is_file():
|
if not lockfile.is_file():
|
||||||
die(f"lockfile missing: {lockfile}")
|
die(f"lockfile missing: {lockfile}")
|
||||||
|
|
||||||
required, optional = load_lockfile(lockfile)
|
required, optional, versions = load_lockfile(lockfile)
|
||||||
print(f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional")
|
print(
|
||||||
|
f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional"
|
||||||
|
+ (f", {len(versions)} pinned" if versions else "")
|
||||||
|
)
|
||||||
|
|
||||||
bin_dir = home / ".local" / "bin"
|
bin_dir = home / ".local" / "bin"
|
||||||
state_dir = home / ".local" / "state" / "bakery"
|
state_dir = home / ".local" / "state" / "bakery"
|
||||||
|
|
@ -245,6 +315,24 @@ def main() -> int:
|
||||||
selected: dict[str, dict] = {}
|
selected: dict[str, dict] = {}
|
||||||
installed_bins: dict[str, list[str]] = {}
|
installed_bins: dict[str, list[str]] = {}
|
||||||
installed_sha: dict[str, dict[str, 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:
|
def take_bin(name: str, *, required_bin: bool) -> bool:
|
||||||
hit = published.get(name)
|
hit = published.get(name)
|
||||||
|
|
@ -256,16 +344,30 @@ def main() -> int:
|
||||||
pkg_name, pkg, binary = hit
|
pkg_name, pkg, binary = hit
|
||||||
if pkg_name in BLOCKED or name in BLOCKED:
|
if pkg_name in BLOCKED or name in BLOCKED:
|
||||||
die(f"refusing blocked package/bin {pkg_name}/{name}")
|
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:
|
if not isinstance(url, str) or not url:
|
||||||
die(f"{name}: missing dl_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
|
dest = bin_dir / name
|
||||||
print(f" {name} <- {url}")
|
note = f" (pin {pkg_name}={pin})" if pin else ""
|
||||||
stage_file(url, dest, digest, f"binary {name}", mode=0o755)
|
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
|
selected[pkg_name] = pkg
|
||||||
installed_bins.setdefault(pkg_name, []).append(name)
|
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
|
return True
|
||||||
|
|
||||||
for name in required:
|
for name in required:
|
||||||
|
|
@ -281,14 +383,15 @@ def main() -> int:
|
||||||
|
|
||||||
for pkg_name, pkg in sorted(selected.items()):
|
for pkg_name, pkg in sorted(selected.items()):
|
||||||
bins = pkg.get("binaries") or []
|
bins = pkg.get("binaries") or []
|
||||||
first_url = None
|
first_url = fetched_url.get(pkg_name)
|
||||||
for b in bins:
|
if not first_url:
|
||||||
if isinstance(b, dict) and b.get("dl_url"):
|
for b in bins:
|
||||||
first_url = b["dl_url"]
|
if isinstance(b, dict) and b.get("dl_url"):
|
||||||
break
|
first_url = b["dl_url"]
|
||||||
|
break
|
||||||
if not first_url:
|
if not first_url:
|
||||||
die(f"{pkg_name}: no binary dl_url to derive version dir")
|
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] = []
|
service_names: list[str] = []
|
||||||
|
|
||||||
for svc in pkg.get("services") or []:
|
for svc in pkg.get("services") or []:
|
||||||
|
|
@ -297,12 +400,13 @@ def main() -> int:
|
||||||
unit = svc.get("unit")
|
unit = svc.get("unit")
|
||||||
if not isinstance(unit, str) or not valid_name(unit):
|
if not isinstance(unit, str) or not valid_name(unit):
|
||||||
die(f"{pkg_name}: invalid service unit {unit!r}")
|
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
|
dest = unit_dir / unit
|
||||||
url = urljoin(base, unit)
|
url = urljoin(base, unit)
|
||||||
print(f" {unit} <- {url}")
|
print(f" {unit} <- {url}")
|
||||||
fetch(url, dest)
|
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.write_text(patch_exec_start(dest.read_text(), bin_dir))
|
||||||
dest.chmod(0o644)
|
dest.chmod(0o644)
|
||||||
if svc.get("enable"):
|
if svc.get("enable"):
|
||||||
|
|
@ -322,7 +426,9 @@ def main() -> int:
|
||||||
if archive:
|
if archive:
|
||||||
if not isinstance(archive, str) or not valid_name(archive):
|
if not isinstance(archive, str) or not valid_name(archive):
|
||||||
die(f"{pkg_name}: invalid data_archive {archive!r}")
|
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)
|
url = urljoin(base, archive)
|
||||||
data_dir = share_dir / pkg_name
|
data_dir = share_dir / pkg_name
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -349,7 +455,9 @@ def main() -> int:
|
||||||
if desktop:
|
if desktop:
|
||||||
if not isinstance(desktop, str) or not valid_name(desktop):
|
if not isinstance(desktop, str) or not valid_name(desktop):
|
||||||
die(f"{pkg_name}: invalid desktop_file {desktop!r}")
|
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"
|
dest = share_dir / "applications" / f"{pkg_name}.desktop"
|
||||||
stage_file(urljoin(base, desktop), dest, digest, 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 license_file:
|
||||||
if not isinstance(license_file, str) or not valid_name(license_file):
|
if not isinstance(license_file, str) or not valid_name(license_file):
|
||||||
die(f"{pkg_name}: invalid license_file {license_file!r}")
|
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"
|
dest = share_dir / "licenses" / pkg_name / "LICENSE"
|
||||||
stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}")
|
stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}")
|
||||||
|
|
||||||
installed[pkg_name] = {
|
installed[pkg_name] = {
|
||||||
"name": pkg_name,
|
"name": pkg_name,
|
||||||
"version": pkg.get("version"),
|
"version": versions.get(pkg_name, pkg.get("version")),
|
||||||
"binaries": installed_bins.get(pkg_name, []),
|
"binaries": installed_bins.get(pkg_name, []),
|
||||||
"services": service_names,
|
"services": service_names,
|
||||||
"installed_at": now,
|
"installed_at": now,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue