CI: stage bakery from signed stable index, drop bread-theme cargo build
The tagged ISO workflow fetched bos-settings/src/Cargo.toml from the dev branch (404 after the Tauri split) and cargo-built bread-theme. bread-theme 0.7.1 is already on the stable index. Stage required bins, units, breadhelp content, and desktop/license files from the minisign-verified index instead; optional bread-emit/module-host skip until bread publishes them. Fail the bake if a required bin is missing.
This commit is contained in:
parent
3ab97c1634
commit
a3ead6607a
16 changed files with 687 additions and 154 deletions
|
|
@ -1,19 +1,21 @@
|
||||||
name: Build and release ISO
|
name: Build and release ISO
|
||||||
|
|
||||||
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container),
|
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container).
|
||||||
# downloads all bakery ecosystem binaries from their GitHub releases, compiles
|
# Stages bakery desktop apps from the *minisign-verified* stable index at
|
||||||
# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release.
|
# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs
|
||||||
# A matching GitHub release is created that points to Forgejo for the download
|
# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub
|
||||||
|
# release is created best-effort and points at Forgejo for the download
|
||||||
# (GitHub releases cannot host files larger than 2 GB).
|
# (GitHub releases cannot host files larger than 2 GB).
|
||||||
#
|
#
|
||||||
# Required secrets:
|
# Required secrets:
|
||||||
# RELEASE_TOKEN — Forgejo API token with write:repository scope
|
# RELEASE_TOKEN — Forgejo API token with write:repository scope
|
||||||
# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml)
|
# MIRROR_TOKEN — GitHub personal access token with repo scope
|
||||||
# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing"
|
# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing"
|
||||||
# identity (releases@breadway.dev); public half is committed
|
# identity (releases@breadway.dev); public half is committed
|
||||||
# at KEYS.asc for verification. No passphrase (CI-only key,
|
# at KEYS.asc for verifying ISO SHA256SUMS only. That key
|
||||||
# access controlled via the Forgejo secret store, not a
|
# does not sign the [breadway] pacman repo. No passphrase
|
||||||
# passphrase nobody could type non-interactively anyway).
|
# (CI-only key, access controlled via the Forgejo secret
|
||||||
|
# store).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -28,6 +30,8 @@ jobs:
|
||||||
release-iso:
|
release-iso:
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
container:
|
container:
|
||||||
|
# Floating tag: this environment cannot pin a reproducible digest of
|
||||||
|
# archlinux:latest. Do not invent one.
|
||||||
image: archlinux:latest
|
image: archlinux:latest
|
||||||
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
|
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
|
||||||
# --network=host: gives localhost:3002 access to Forgejo (avoids the
|
# --network=host: gives localhost:3002 access to Forgejo (avoids the
|
||||||
|
|
@ -37,7 +41,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm archiso curl python git rust
|
pacman -Syu --noconfirm archiso curl python git minisign
|
||||||
|
|
||||||
- name: Determine tag and version
|
- name: Determine tag and version
|
||||||
id: vars
|
id: vars
|
||||||
|
|
@ -55,85 +59,17 @@ jobs:
|
||||||
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
|
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
|
||||||
|
|
||||||
- name: Download bakery ecosystem binaries
|
- name: Stage bakery ecosystem from signed stable index
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p /build-home/.local/bin \
|
cd /bos
|
||||||
/build-home/.local/state/bakery \
|
LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py
|
||||||
/build-home/.cache/bakery
|
|
||||||
|
|
||||||
# Fetch the canonical bakery index
|
- name: Verify staged bakery bake inputs
|
||||||
curl -fsSL "https://dl.breadway.dev/index.json" \
|
|
||||||
-o /build-home/.cache/bakery/index.json
|
|
||||||
|
|
||||||
# Download each binary from dl.breadway.dev (canonical source; github_url
|
|
||||||
# is not always published for dev/patch releases) and generate the
|
|
||||||
# installed.json that bakery expects in ~/.local/state.
|
|
||||||
python3 << 'PYEOF'
|
|
||||||
import json, urllib.request, os
|
|
||||||
|
|
||||||
with open('/build-home/.cache/bakery/index.json') as f:
|
|
||||||
idx = json.load(f)
|
|
||||||
|
|
||||||
BIN_DIR = '/build-home/.local/bin'
|
|
||||||
installed = {}
|
|
||||||
|
|
||||||
for pkg_name, pkg in idx['packages'].items():
|
|
||||||
bins = []
|
|
||||||
for b in pkg['binaries']:
|
|
||||||
dest_name = b['name'].removesuffix('-x86_64')
|
|
||||||
dest = os.path.join(BIN_DIR, dest_name)
|
|
||||||
url = b['dl_url']
|
|
||||||
print(f' {dest_name} <- {url}', flush=True)
|
|
||||||
urllib.request.urlretrieve(url, dest)
|
|
||||||
os.chmod(dest, 0o755)
|
|
||||||
bins.append(dest_name)
|
|
||||||
|
|
||||||
# installed.json services field is a flat list of unit-name strings
|
|
||||||
services = [
|
|
||||||
(s['unit'] if isinstance(s, dict) else s)
|
|
||||||
for s in pkg.get('services', [])
|
|
||||||
]
|
|
||||||
installed[pkg_name] = {
|
|
||||||
'name': pkg_name,
|
|
||||||
'version': pkg['version'],
|
|
||||||
'binaries': bins,
|
|
||||||
'services': services,
|
|
||||||
'installed_at': '2024-01-01T00:00:00+00:00',
|
|
||||||
}
|
|
||||||
|
|
||||||
with open('/build-home/.local/state/bakery/installed.json', 'w') as f:
|
|
||||||
json.dump({'packages': installed}, f, indent=2)
|
|
||||||
print('installed.json written', flush=True)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
- name: Build bread-theme from source
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# bread-theme is not in the bakery index; build it at the ref pinned
|
cd /bos
|
||||||
# in bos-settings' Cargo.toml so the CLI matches the library version
|
LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh
|
||||||
# the bos-settings package (and breadbar/breadbox/breadpad) were
|
|
||||||
# built against. bos-settings used to live at bos-settings/Cargo.toml
|
|
||||||
# inside this repo; it's since been split into its own repo
|
|
||||||
# (git.breadway.dev/Breadway/bos-settings) and migrated to Tauri,
|
|
||||||
# which moved the Rust manifest to bos-settings/src/Cargo.toml, so
|
|
||||||
# fetch it from there. Uses bos-settings' default branch (dev) — the
|
|
||||||
# branch its own CI actually publishes the `bos-settings` pacman
|
|
||||||
# package from.
|
|
||||||
REPO_OWNER="${GITHUB_REPOSITORY%%/*}"
|
|
||||||
curl -fsSL "https://git.breadway.dev/${REPO_OWNER}/bos-settings/raw/branch/dev/src/Cargo.toml" \
|
|
||||||
-o /tmp/bos-settings-Cargo.toml
|
|
||||||
# bread-theme is pinned by tag once a release ships the functions
|
|
||||||
# bos-settings needs, or by branch in the meantime — handle either.
|
|
||||||
THEME_REF=$(grep '^bread-theme' /tmp/bos-settings-Cargo.toml \
|
|
||||||
| grep -oP '(tag|branch)\s*=\s*"\K[^"]+')
|
|
||||||
echo "Building bread-theme @ $THEME_REF"
|
|
||||||
git clone --branch "$THEME_REF" --depth 1 \
|
|
||||||
https://github.com/Breadway/bread-ecosystem /bread-ecosystem
|
|
||||||
cd /bread-ecosystem
|
|
||||||
cargo build --release -p bread-theme
|
|
||||||
install -m 755 target/release/bread-theme /build-home/.local/bin/bread-theme
|
|
||||||
echo "bread-theme built OK"
|
|
||||||
|
|
||||||
- name: Build ISO
|
- name: Build ISO
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -247,9 +183,8 @@ jobs:
|
||||||
gh release create "${TAG}" \
|
gh release create "${TAG}" \
|
||||||
--repo "Breadway/bos" \
|
--repo "Breadway/bos" \
|
||||||
--title "BOS ${TAG}" \
|
--title "BOS ${TAG}" \
|
||||||
\
|
|
||||||
--notes-file /tmp/gh-release-notes.md \
|
--notes-file /tmp/gh-release-notes.md \
|
||||||
2>/dev/null || echo "GitHub release already exists — skipping"
|
|| echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)"
|
||||||
|
|
||||||
# `stable` is a marker branch only — CI fast-forwards it to whatever
|
# `stable` is a marker branch only — CI fast-forwards it to whatever
|
||||||
# commit the latest real (non-RC) release tag points at. Never merged
|
# commit the latest real (non-RC) release tag points at. Never merged
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -44,3 +44,8 @@ logs/
|
||||||
/Bread Background.png
|
/Bread Background.png
|
||||||
|
|
||||||
# Local hygiene notes (not for commit)
|
# Local hygiene notes (not for commit)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ taken as current:
|
||||||
| A/B root swapping | **Future.** Today: btrfs + snapper + **grub-btrfs**. GRUB pins `rootflags=subvol=@`, so `snapper rollback` is not the user-facing recovery path. |
|
| A/B root swapping | **Future.** Today: btrfs + snapper + **grub-btrfs**. GRUB pins `rootflags=subvol=@`, so `snapper rollback` is not the user-facing recovery path. |
|
||||||
| Work on `dev`; origin = GitHub | Single-trunk `main`; `stable` is a CI marker. `origin` = Forgejo, `github` = GitHub. |
|
| Work on `dev`; origin = GitHub | Single-trunk `main`; `stable` is a CI marker. `origin` = Forgejo, `github` = GitHub. |
|
||||||
| `[breadway]` provides bakery/breadbar/bos-settings | `[breadway]` is breadlock + AUR republishes. Desktop apps are bakery. **Not shipped:** breadcast, breadarr. |
|
| `[breadway]` provides bakery/breadbar/bos-settings | `[breadway]` is breadlock + AUR republishes. Desktop apps are bakery. **Not shipped:** breadcast, breadarr. |
|
||||||
|
| NVIDIA / A/B / Secure Boot / LUKS2 | NVIDIA proprietary is **unsupported**. A/B root swapping is **not implemented**. Secure Boot is **Setup Mode only** (self-signed `sbctl`). Disk encryption is **LUKS1** because GRUB cannot unlock LUKS2+Argon2id. |
|
||||||
|
| `SigLevel = Required` on `[breadway]` | **No.** Forgejo's Arch registry has no pacman-compatible db signatures. `SigLevel = Never` is TLS only; flipping Required without a signed db breaks installs. `KEYS.asc` signs ISO SHA256SUMS, not the pacman repo. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
58
README.md
58
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.
|
keybinds, snappy animations, blur, and pywal-driven colours on a black base.
|
||||||
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
|
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
|
||||||
(no network needed at install time): the `bread`/`breadd` automation daemon
|
(no network needed at install time): the `bread`/`breadd` automation daemon
|
||||||
plus `bread-emit` / `bread-module-host`, `breadbar` (status bar +
|
(`bread-emit` / `bread-module-host` when the stable bread release publishes
|
||||||
notifications), `breadbox` (launcher), `breadclip` (clipboard history),
|
them), `breadbar` (status bar + notifications), `breadbox` (launcher),
|
||||||
`breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman` (notes), `breadpaper`
|
`breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles),
|
||||||
(wallpaper + theme), `breadsearch` (system search), `breadmon` (monitor
|
`breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme),
|
||||||
layout TUI), `breadshot` (screenshots), `bread-theme` (the shared palette
|
`breadsearch` (system search), `breadmon` (monitor layout TUI),
|
||||||
engine), `breadhelp` (onboarding + cheatsheet), `bos-settings` (control
|
`breadshot` (screenshots), `bread-theme` (the shared palette engine),
|
||||||
panel), and the `bakery` package manager. Most of those apps are
|
`breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel),
|
||||||
zero-config on first boot; breadcrumbs networks are user-filled after
|
and the `bakery` package manager. Most of those apps are zero-config on
|
||||||
install. See [below](#the-bread-ecosystem).
|
first boot; breadcrumbs networks are user-filled after install. See
|
||||||
|
[below](#the-bread-ecosystem).
|
||||||
- **breadlock** (lock screen + greeter) is the one bread\* app that ships as
|
- **breadlock** (lock screen + greeter) is the one bread\* app that ships as
|
||||||
**pacman**, not bakery — it needs a root-owned PAM service.
|
**pacman**, not bakery — it needs a root-owned PAM service.
|
||||||
- **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery
|
- **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery
|
||||||
|
|
@ -41,23 +42,28 @@ wiring up dotfiles, no per-tool bakery installs.
|
||||||
`yay` ships for AUR access beyond bakery + `[breadway]`.
|
`yay` ships for AUR access beyond bakery + `[breadway]`.
|
||||||
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
|
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
|
||||||
with avahi mDNS discovery, TLP power management, fwupd firmware updates.
|
with avahi mDNS discovery, TLP power management, fwupd firmware updates.
|
||||||
|
Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is
|
||||||
|
unsupported out of the box (see Known limitations).
|
||||||
- **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
|
- **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
|
||||||
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
|
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
|
||||||
- **Security**: full-disk encryption (LUKS, via Calamares' built-in support —
|
A/B root swapping is **not** implemented. Recovery is a grub-btrfs reboot,
|
||||||
cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted
|
not `snapper rollback` (GRUB pins `rootflags=subvol=@`).
|
||||||
install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled
|
- **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock
|
||||||
automatically at install time when the firmware is in Setup Mode).
|
LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via
|
||||||
|
`sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the
|
||||||
|
firmware is already in Setup Mode.
|
||||||
|
|
||||||
## What ships vs what does not
|
## What ships vs what does not
|
||||||
|
|
||||||
| Channel | What |
|
| Channel | What |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| **Bakery, baked into skel** | `bakery`, `bread` / `breadd` / `bread-emit` / `bread-module-host`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) |
|
| **Bakery, required** | `bakery`, `bread` / `breadd`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `~/.local/share/breadhelp/`) |
|
||||||
|
| **Bakery, optional** | `bread-emit`, `bread-module-host` — baked when the verified stable index publishes them; skipped (not a failed bake) until bread ships them |
|
||||||
| **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) |
|
| **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) |
|
||||||
| **Not shipped** | `breadcast`, `breadarr` |
|
| **Not shipped** | `breadcast`, `breadarr` |
|
||||||
|
|
||||||
The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml).
|
The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml).
|
||||||
`build-local.sh` fails if any listed binary is missing on the builder.
|
`build-local.sh` fails if any **required** binary is missing on the builder.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
|
|
@ -68,7 +74,7 @@ repos and arrive via bakery.
|
||||||
```
|
```
|
||||||
bos/
|
bos/
|
||||||
├── iso/ # archiso profile
|
├── iso/ # archiso profile
|
||||||
│ ├── bread-lockfile.toml # bakery bins that MUST be baked
|
│ ├── bread-lockfile.toml # bakery bins (required + optional)
|
||||||
│ ├── profiledef.sh
|
│ ├── profiledef.sh
|
||||||
│ ├── packages.x86_64 # live + installed pacman set
|
│ ├── packages.x86_64 # live + installed pacman set
|
||||||
│ └── airootfs/ # files overlaid onto the image
|
│ └── airootfs/ # files overlaid onto the image
|
||||||
|
|
@ -81,7 +87,10 @@ bos/
|
||||||
│ ├── powerlevel10k/
|
│ ├── powerlevel10k/
|
||||||
│ └── yay-bin/
|
│ └── yay-bin/
|
||||||
├── dotfiles/ # STALE — not the live skel; see its README
|
├── dotfiles/ # STALE — not the live skel; see its README
|
||||||
├── scripts/smoke-test.sh
|
├── scripts/
|
||||||
|
│ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME
|
||||||
|
│ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso
|
||||||
|
│ └── smoke-test.sh
|
||||||
├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release
|
├── .forgejo/workflows/ # CI: AUR republish + tagged ISO release
|
||||||
├── build-local.sh # native ISO build for this machine
|
├── build-local.sh # native ISO build for this machine
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|
@ -120,10 +129,12 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs)
|
||||||
The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins
|
The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins
|
||||||
`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL
|
`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL
|
||||||
to the Tailscale-reachable Forgejo registry for the build, and **exits
|
to the Tailscale-reachable Forgejo registry for the build, and **exits
|
||||||
non-zero** if any lockfile binary (or breadhelp content) is missing.
|
non-zero** if any **required** lockfile binary (or breadhelp content) is
|
||||||
|
missing. Optional bins are skipped with a warning.
|
||||||
|
|
||||||
CI should populate the builder from the **stable** bakery index; local
|
CI stages the builder from the **minisign-verified** stable bakery index
|
||||||
builds still snapshot the builder. The lockfile is names only.
|
(`index.json` + `index.json.minisig`); local builds still snapshot the
|
||||||
|
builder. The lockfile is names only.
|
||||||
|
|
||||||
### Why some packages are in-house
|
### Why some packages are in-house
|
||||||
|
|
||||||
|
|
@ -144,7 +155,10 @@ dedicated release-signing key (not reused from anything else):
|
||||||
5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2
|
5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2
|
||||||
```
|
```
|
||||||
|
|
||||||
The public half is committed at [`KEYS.asc`](KEYS.asc). To verify a download:
|
The public half is committed at [`KEYS.asc`](KEYS.asc). That key signs
|
||||||
|
**ISO checksums only** — it does not sign the `[breadway]` pacman repo
|
||||||
|
(Forgejo's Arch registry has no pacman-compatible db signatures; that
|
||||||
|
section stays `SigLevel = Never`). To verify a download:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gpg --import KEYS.asc
|
gpg --import KEYS.asc
|
||||||
|
|
@ -221,7 +235,7 @@ directly.
|
||||||
|
|
||||||
| Tool | Role | Launch |
|
| Tool | Role | Launch |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. | runs at login (`breadd.service`) |
|
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. Both extra bins are **optional** on the ISO until a stable bread release publishes them. | runs at login (`breadd.service`) |
|
||||||
| `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login |
|
| `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login |
|
||||||
| `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` |
|
| `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` |
|
||||||
| `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock. **pacman**, not bakery. | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot |
|
| `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock. **pacman**, not bakery. | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot |
|
||||||
|
|
|
||||||
|
|
@ -50,29 +50,45 @@ grep airootfs_image_tool_options "$STAGE/profiledef.sh"
|
||||||
# offline. Copied at build time so the binaries never bloat the git repo.
|
# offline. Copied at build time so the binaries never bloat the git repo.
|
||||||
#
|
#
|
||||||
# CI should prefer the stable bakery index when populating the builder home.
|
# CI should prefer the stable bakery index when populating the builder home.
|
||||||
# Local builds still snapshot the builder. The lockfile is the name list;
|
# Local builds still snapshot the builder. required_bins fail the bake if
|
||||||
# missing bins fail the bake (a hollow ISO is worse than a failed build).
|
# missing; optional_bins are skipped with a warning (a hollow ISO is worse
|
||||||
|
# than a failed build). A flat `bins` list is treated as all-required.
|
||||||
LOCKFILE="$REPO/iso/bread-lockfile.toml"
|
LOCKFILE="$REPO/iso/bread-lockfile.toml"
|
||||||
if [[ ! -f "$LOCKFILE" ]]; then
|
if [[ ! -f "$LOCKFILE" ]]; then
|
||||||
echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2
|
echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
mapfile -t BREAD_BINS < <(python3 - "$LOCKFILE" <<'PY'
|
eval "$(python3 - "$LOCKFILE" <<'PY'
|
||||||
import sys, tomllib
|
import sys, tomllib
|
||||||
path = sys.argv[1]
|
path = sys.argv[1]
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
bins = data.get("bins") or data.get("binaries")
|
required = data.get("required_bins")
|
||||||
if not isinstance(bins, list) or not bins:
|
optional = data.get("optional_bins") or []
|
||||||
sys.exit(f"{path}: missing non-empty bins list")
|
if required is None:
|
||||||
for b in bins:
|
required = data.get("bins") or data.get("binaries")
|
||||||
|
if not isinstance(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 (".", ".."):
|
if not isinstance(b, str) or not b or "/" in b or b in (".", ".."):
|
||||||
sys.exit(f"{path}: invalid bin name {b!r}")
|
sys.exit(f"{path}: invalid {label} name {b!r}")
|
||||||
print(b)
|
if b in blocked:
|
||||||
|
sys.exit(f"{path}: {b} is not shipped on the ISO")
|
||||||
|
def emit(name, values):
|
||||||
|
print(f"{name}=(")
|
||||||
|
for v in values:
|
||||||
|
print(f" {v!r}")
|
||||||
|
print(")")
|
||||||
|
emit("REQUIRED_BINS", required)
|
||||||
|
emit("OPTIONAL_BINS", optional)
|
||||||
PY
|
PY
|
||||||
)
|
)"
|
||||||
if [[ ${#BREAD_BINS[@]} -eq 0 ]]; then
|
if [[ ${#REQUIRED_BINS[@]} -eq 0 ]]; then
|
||||||
echo "ERROR: $LOCKFILE produced an empty bins list" >&2
|
echo "ERROR: $LOCKFILE produced an empty required bins list" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -83,10 +99,10 @@ BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery"
|
||||||
BAKERY_SHARE="$LAPTOP_HOME/.local/share"
|
BAKERY_SHARE="$LAPTOP_HOME/.local/share"
|
||||||
SKEL="$STAGE/airootfs/etc/skel"
|
SKEL="$STAGE/airootfs/etc/skel"
|
||||||
echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ==="
|
echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ==="
|
||||||
echo "lockfile: $LOCKFILE (${#BREAD_BINS[@]} bins)"
|
echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)"
|
||||||
|
|
||||||
missing=()
|
missing=()
|
||||||
for b in "${BREAD_BINS[@]}"; do
|
for b in "${REQUIRED_BINS[@]}"; do
|
||||||
if [[ ! -x "$BAKERY_BIN/$b" ]]; then
|
if [[ ! -x "$BAKERY_BIN/$b" ]]; then
|
||||||
missing+=("$BAKERY_BIN/$b")
|
missing+=("$BAKERY_BIN/$b")
|
||||||
fi
|
fi
|
||||||
|
|
@ -99,6 +115,15 @@ if [[ ${#missing[@]} -gt 0 ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
BREAD_BINS=("${REQUIRED_BINS[@]}")
|
||||||
|
for b in "${OPTIONAL_BINS[@]}"; do
|
||||||
|
if [[ -x "$BAKERY_BIN/$b" ]]; then
|
||||||
|
BREAD_BINS+=("$b")
|
||||||
|
else
|
||||||
|
echo "WARN: optional lockfile bin missing, skipping: $BAKERY_BIN/$b" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
|
install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
|
||||||
for b in "${BREAD_BINS[@]}"; do
|
for b in "${BREAD_BINS[@]}"; do
|
||||||
install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b"
|
install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
# `dotfiles/` is not the live skel
|
# `dotfiles/` is not the live skel
|
||||||
|
|
||||||
These files are a leftover from an earlier design (Hyprland `.conf` binds,
|
These files are a leftover from an earlier design (Hyprland `.conf` binds).
|
||||||
including grimblast). They are **not** copied into the ISO or the installed
|
They are **not** copied into the ISO or the installed system.
|
||||||
system.
|
|
||||||
|
`dotfiles/hypr/keybinds.conf` still mentions `grimblast`. That is not the
|
||||||
|
screenshot tool BOS ships — live binds use **breadshot**
|
||||||
|
(`iso/airootfs/etc/skel/.config/hypr/binds.json`). Do not treat grimblast
|
||||||
|
as current.
|
||||||
|
|
||||||
Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel)
|
Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel)
|
||||||
(`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot,
|
(`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
# STALE — not the live Hyprland binds. Not copied into the ISO.
|
||||||
|
# Screenshots are breadshot (see iso/airootfs/etc/skel/.config/hypr/binds.json),
|
||||||
|
# not grimblast. Do not copy from this file.
|
||||||
|
|
||||||
$mod = SUPER
|
$mod = SUPER
|
||||||
|
|
||||||
# App launchers
|
# App launchers
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,11 @@ passwd -l root || true
|
||||||
# over to the target (unpackfs may skip it / perms differ), leaving the installed
|
# over to the target (unpackfs may skip it / perms differ), leaving the installed
|
||||||
# system unable to verify package signatures — the first `pacman -Syu` then dies
|
# system unable to verify package signatures — the first `pacman -Syu` then dies
|
||||||
# with "keyring is not writable / required key missing". Initialise it here so a
|
# with "keyring is not writable / required key missing". Initialise it here so a
|
||||||
# fresh install can update out of the box. archlinux-keyring is already present;
|
# fresh install can update out of the box. archlinux-keyring is already present
|
||||||
# [breadway] is SigLevel=Never so it needs no key.
|
# and is the only keyring populated — it verifies official Arch packages.
|
||||||
|
# [breadway] stays SigLevel=Never (Forgejo does not serve pacman-compatible
|
||||||
|
# db signatures). Do not import KEYS.asc here: that key signs ISO SHA256SUMS,
|
||||||
|
# not the pacman repo; treating it as a repo key would be a lie.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if command -v pacman-key &>/dev/null; then
|
if command -v pacman-key &>/dev/null; then
|
||||||
pacman-key --init || echo "WARN: pacman-key --init failed"
|
pacman-key --init || echo "WARN: pacman-key --init failed"
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist
|
||||||
# Packages are published to the Forgejo Arch registry (group "os") by the
|
# Packages are published to the Forgejo Arch registry (group "os") by the
|
||||||
# .forgejo/workflows/*.yml workflows in this repo (and breadlock's).
|
# .forgejo/workflows/*.yml workflows in this repo (and breadlock's).
|
||||||
#
|
#
|
||||||
# Forgejo signs the repo db with a key pacman can't look up, so TrustAll
|
# Forgejo's Arch package registry does not serve pacman-compatible db
|
||||||
# fails. SigLevel = Never skips verification (acceptable for this private
|
# signatures. SigLevel = Never is TLS-only integrity: the connection is
|
||||||
# repo over TLS). Future improvement: import Forgejo's signing key and
|
# HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM)
|
||||||
# switch to SigLevel = Required for full package verification.
|
# rides this repo. Do NOT flip to SigLevel = Required unless a signed db
|
||||||
|
# has been verified to work — Required without signatures breaks the ISO
|
||||||
|
# and every install that uses [breadway]. KEYS.asc is the ISO SHA256SUMS
|
||||||
|
# signing key, not a pacman repo key.
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# The section name must match Forgejo's served db filename
|
# The section name must match Forgejo's served db filename
|
||||||
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
|
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@
|
||||||
# bos-update — update all of BOS in one go.
|
# bos-update — update all of BOS in one go.
|
||||||
#
|
#
|
||||||
# BOS packages come from two channels, so a full update touches both:
|
# BOS packages come from two channels, so a full update touches both:
|
||||||
# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.).
|
# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR
|
||||||
# Every transaction is snapshotted by snap-pac, so you can roll
|
# republishes: calamares, zen-browser-bin, bibata, yay-bin,
|
||||||
# back from the GRUB "snapshots" submenu or BOS Settings.
|
# powerlevel10k). [breadway] does NOT provide bos-settings
|
||||||
|
# or other bakery desktop apps. Every transaction is
|
||||||
|
# snapshotted by snap-pac; recover via the GRUB "snapshots"
|
||||||
|
# submenu (grub-btrfs), not `snapper rollback`.
|
||||||
# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
|
# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
|
||||||
# reports as installed — bread, breadbar, breadbox, breadcrumbs,
|
# reports as installed — bakery, bread, breadbar, breadbox,
|
||||||
# breadpad, breadman, bread-theme, breadpaper, breadmon,
|
# breadcrumbs, breadpad, breadman, bread-theme, breadpaper,
|
||||||
# breadsearch, breadclip, breadshot, ...).
|
# breadmon, breadsearch, breadclip, breadshot, bos-settings,
|
||||||
|
# breadhelp, ...).
|
||||||
#
|
#
|
||||||
# Best-effort: a failure in one channel doesn't abort the other.
|
# Best-effort: a failure in one channel doesn't abort the other.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
# Bakery binaries that MUST be baked into the live/installed skel.
|
# Bakery binaries baked into the live/installed skel.
|
||||||
#
|
#
|
||||||
# build-local.sh derives BREAD_BINS from `bins` — this is the name list, not a
|
# build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing
|
||||||
# second hardcoded array. A missing binary fails the bake: a hollow ISO is
|
# *required* binary fails the bake: a hollow ISO is worse than a failed build.
|
||||||
# worse than a failed build.
|
# optional_bins are baked when the verified stable index publishes them, and
|
||||||
|
# skipped with a warning when it does not (today: bread 0.7.0 has no
|
||||||
|
# bread-emit / bread-module-host).
|
||||||
#
|
#
|
||||||
# CI should populate the builder from the stable bakery index
|
# A flat `bins` list is still accepted and treated as required_bins.
|
||||||
|
#
|
||||||
|
# CI populates the builder from the minisign-verified stable bakery index
|
||||||
# (https://dl.breadway.dev/index.json). Local builds still snapshot whatever
|
# (https://dl.breadway.dev/index.json). Local builds still snapshot whatever
|
||||||
# is installed on the builder; this file only names what must be present.
|
# is installed on the builder; this file only names what must / may be present.
|
||||||
#
|
#
|
||||||
# Not shipped (even if present on the builder): breadcast, breadarr.
|
# Not shipped (even if they appear in the index): breadcast, breadarr.
|
||||||
# breadlock is pacman (see packages.x86_64), not bakery.
|
# breadlock is pacman (see packages.x86_64), not bakery.
|
||||||
|
|
||||||
bins = [
|
required_bins = [
|
||||||
"bakery",
|
"bakery",
|
||||||
"bread",
|
"bread",
|
||||||
"breadd",
|
"breadd",
|
||||||
"bread-emit",
|
|
||||||
"bread-module-host",
|
|
||||||
"breadman",
|
"breadman",
|
||||||
"breadbar",
|
"breadbar",
|
||||||
"breadbox",
|
"breadbox",
|
||||||
|
|
@ -34,3 +36,9 @@ bins = [
|
||||||
"bos-settings",
|
"bos-settings",
|
||||||
"breadhelp",
|
"breadhelp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Bake if the verified index publishes them; do not fail the ISO if absent.
|
||||||
|
optional_bins = [
|
||||||
|
"bread-emit",
|
||||||
|
"bread-module-host",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,13 @@ Include = /etc/pacman.d/mirrorlist
|
||||||
# Packages are published to the Forgejo Arch registry (group "os") by the
|
# Packages are published to the Forgejo Arch registry (group "os") by the
|
||||||
# .forgejo/workflows/*.yml workflows in this repo (and breadlock's).
|
# .forgejo/workflows/*.yml workflows in this repo (and breadlock's).
|
||||||
#
|
#
|
||||||
# Forgejo signs the repo db with a key pacman can't look up, so TrustAll
|
# Forgejo's Arch package registry does not serve pacman-compatible db
|
||||||
# fails. SigLevel = Never skips verification (acceptable for this private
|
# signatures. SigLevel = Never is TLS-only integrity: the connection is
|
||||||
# repo over TLS). Future improvement: import Forgejo's signing key and
|
# HTTPS (or rewritten to hestia's localhost:3002 in CI). breadlock (PAM)
|
||||||
# switch to SigLevel = Required for full package verification.
|
# rides this repo. Do NOT flip to SigLevel = Required unless a signed db
|
||||||
|
# has been verified to work — Required without signatures breaks the ISO
|
||||||
|
# and every install that uses [breadway]. KEYS.asc is the ISO SHA256SUMS
|
||||||
|
# signing key, not a pacman repo key.
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# The section name must match Forgejo's served db filename
|
# The section name must match Forgejo's served db filename
|
||||||
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
|
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ one publishes on a push to `packaging/<name>/**`.
|
||||||
Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs,
|
Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs,
|
||||||
breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot,
|
breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot,
|
||||||
bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see
|
bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see
|
||||||
`iso/bread-lockfile.toml`, which `build-local.sh` uses as the name list
|
`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which
|
||||||
when baking this machine's bakery install into the ISO's `/etc/skel`.
|
`build-local.sh` uses as the name list when baking this machine's bakery
|
||||||
|
install into the ISO's `/etc/skel`.
|
||||||
`breadlock` is the sole deliberate exception (it needs a root-owned
|
`breadlock` is the sole deliberate exception (it needs a root-owned
|
||||||
`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no
|
`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no
|
||||||
privileged-install path for) and stays on pacman only; see
|
privileged-install path for) and stays on pacman only; see
|
||||||
|
|
|
||||||
387
scripts/ci-stage-bakery.py
Executable file
387
scripts/ci-stage-bakery.py
Executable file
|
|
@ -0,0 +1,387 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage bakery artifacts from the verified stable index into $LAPTOP_HOME.
|
||||||
|
|
||||||
|
Used by .forgejo/workflows/release-iso.yml so the ISO bake does not invent
|
||||||
|
binaries, fake installed.json, or cargo-build bread-theme. Never downloads
|
||||||
|
breadcast or breadarr.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
INDEX_URL = "https://dl.breadway.dev/index.json"
|
||||||
|
# Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs.
|
||||||
|
MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
|
||||||
|
BLOCKED = frozenset({"breadcast", "breadarr"})
|
||||||
|
ARCH_SUFFIXES = ("-x86_64", "-aarch64", "-arm64", "-armv7")
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def dest_name(name: str) -> str:
|
||||||
|
for suf in ARCH_SUFFIXES:
|
||||||
|
if name.endswith(suf):
|
||||||
|
return name[: -len(suf)]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def valid_name(name: str) -> bool:
|
||||||
|
return bool(name) and "/" not in name and name not in (".", "..")
|
||||||
|
|
||||||
|
|
||||||
|
def load_lockfile(path: Path) -> tuple[list[str], list[str]]:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
required = data.get("required_bins")
|
||||||
|
optional = data.get("optional_bins") or []
|
||||||
|
if required is None:
|
||||||
|
required = data.get("bins") or data.get("binaries")
|
||||||
|
if not isinstance(required, list) or not required:
|
||||||
|
die(f"{path}: missing non-empty required_bins (or bins) list")
|
||||||
|
if not isinstance(optional, list):
|
||||||
|
die(f"{path}: optional_bins must be a list")
|
||||||
|
for label, names in (("required_bins", required), ("optional_bins", optional)):
|
||||||
|
for b in names:
|
||||||
|
if not isinstance(b, str) or not valid_name(b):
|
||||||
|
die(f"{path}: invalid {label} name {b!r}")
|
||||||
|
if b in BLOCKED:
|
||||||
|
die(f"{path}: {b} is not shipped on the ISO")
|
||||||
|
overlap = set(required) & set(optional)
|
||||||
|
if overlap:
|
||||||
|
die(f"{path}: bins in both required and optional: {sorted(overlap)}")
|
||||||
|
return list(required), list(optional)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str, dest: Path) -> None:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, dest)
|
||||||
|
except (urllib.error.URLError, OSError) as e:
|
||||||
|
die(f"download failed: {url}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def require_sha256(value: object, what: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
die(f"{what}: index sha256 is required and must be non-empty")
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_sha256(path: Path, expected: str, what: str) -> None:
|
||||||
|
actual = sha256_file(path)
|
||||||
|
if actual != expected:
|
||||||
|
die(f"{what}: sha256 mismatch (expected {expected}, got {actual})")
|
||||||
|
|
||||||
|
|
||||||
|
def version_dir(first_dl_url: str) -> str:
|
||||||
|
parsed = urlparse(first_dl_url)
|
||||||
|
parent = parsed.path.rsplit("/", 1)[0]
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{parent}/"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_index(index_path: Path, sig_path: Path) -> None:
|
||||||
|
if shutil.which("minisign") is None:
|
||||||
|
die("minisign is not installed — refuse to trust an unsigned index")
|
||||||
|
cmd = [
|
||||||
|
"minisign",
|
||||||
|
"-V",
|
||||||
|
"-q",
|
||||||
|
"-m",
|
||||||
|
str(index_path),
|
||||||
|
"-x",
|
||||||
|
str(sig_path),
|
||||||
|
"-P",
|
||||||
|
MINISIGN_PUBKEY,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die("index.json minisign verification FAILED — refusing to proceed")
|
||||||
|
print("index.json minisign OK")
|
||||||
|
|
||||||
|
|
||||||
|
def bin_index(packages: dict) -> dict[str, tuple[str, dict, dict]]:
|
||||||
|
out: dict[str, tuple[str, dict, dict]] = {}
|
||||||
|
for pkg_name, pkg in packages.items():
|
||||||
|
if pkg_name in BLOCKED:
|
||||||
|
continue
|
||||||
|
for b in pkg.get("binaries") or []:
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
continue
|
||||||
|
raw = b.get("name")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
continue
|
||||||
|
dest = dest_name(raw)
|
||||||
|
if dest in BLOCKED or pkg_name in BLOCKED:
|
||||||
|
continue
|
||||||
|
if dest in out and out[dest][0] != pkg_name:
|
||||||
|
die(f"index publishes {dest} from both {out[dest][0]} and {pkg_name}")
|
||||||
|
out[dest] = (pkg_name, pkg, b)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def patch_exec_start(text: str, bin_dir: Path) -> str:
|
||||||
|
lines = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.lstrip().startswith("ExecStart="):
|
||||||
|
rest = line.split("=", 1)[1]
|
||||||
|
argv = rest.split()
|
||||||
|
if argv:
|
||||||
|
name = os.path.basename(argv[0])
|
||||||
|
new_path = bin_dir / name
|
||||||
|
if len(argv) == 1:
|
||||||
|
line = f"ExecStart={new_path}"
|
||||||
|
else:
|
||||||
|
line = f"ExecStart={new_path} {' '.join(argv[1:])}"
|
||||||
|
lines.append(line)
|
||||||
|
out = "\n".join(lines)
|
||||||
|
if text.endswith("\n"):
|
||||||
|
out += "\n"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def wanted_by(text: str) -> list[str]:
|
||||||
|
targets: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("WantedBy="):
|
||||||
|
targets.extend(line.split("=", 1)[1].split())
|
||||||
|
return targets or ["default.target"]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_safe_archive(path: Path) -> None:
|
||||||
|
with tarfile.open(path, "r:gz") as tf:
|
||||||
|
for info in tf.getmembers():
|
||||||
|
name = info.name
|
||||||
|
if info.issym() or info.islnk():
|
||||||
|
die(f"refusing archive with symlink entry {name!r}")
|
||||||
|
if name.startswith("/") or any(p in ("..", "") for p in Path(name).parts if p == ".."):
|
||||||
|
die(f"refusing archive with unsafe path {name!r}")
|
||||||
|
if Path(name).is_absolute() or ".." in Path(name).parts:
|
||||||
|
die(f"refusing archive with unsafe path {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def stage_file(url: str, dest: Path, sha256: str, what: str, mode: int | None = None) -> None:
|
||||||
|
fetch(url, dest)
|
||||||
|
verify_sha256(dest, sha256, what)
|
||||||
|
if mode is not None:
|
||||||
|
dest.chmod(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# CI logs mix stdout/stderr; keep them in source order.
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
sys.stderr.reconfigure(line_buffering=True)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
repo = Path(__file__).resolve().parents[1]
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--home",
|
||||||
|
default=os.environ.get("LAPTOP_HOME", "/build-home"),
|
||||||
|
help="builder home to populate (default: $LAPTOP_HOME or /build-home)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lockfile",
|
||||||
|
default=str(repo / "iso" / "bread-lockfile.toml"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--index-url", default=INDEX_URL)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
home = Path(args.home)
|
||||||
|
lockfile = Path(args.lockfile)
|
||||||
|
if not lockfile.is_file():
|
||||||
|
die(f"lockfile missing: {lockfile}")
|
||||||
|
|
||||||
|
required, optional = load_lockfile(lockfile)
|
||||||
|
print(f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional")
|
||||||
|
|
||||||
|
bin_dir = home / ".local" / "bin"
|
||||||
|
state_dir = home / ".local" / "state" / "bakery"
|
||||||
|
cache_dir = home / ".cache" / "bakery"
|
||||||
|
share_dir = home / ".local" / "share"
|
||||||
|
unit_dir = home / ".config" / "systemd" / "user"
|
||||||
|
for d in (bin_dir, state_dir, cache_dir, share_dir, unit_dir):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
index_path = cache_dir / "index.json"
|
||||||
|
sig_path = cache_dir / "index.json.minisig"
|
||||||
|
print(f"fetch {args.index_url}")
|
||||||
|
fetch(args.index_url, index_path)
|
||||||
|
print(f"fetch {args.index_url}.minisig")
|
||||||
|
fetch(args.index_url + ".minisig", sig_path)
|
||||||
|
verify_index(index_path, sig_path)
|
||||||
|
|
||||||
|
with index_path.open() as f:
|
||||||
|
idx = json.load(f)
|
||||||
|
packages = idx.get("packages")
|
||||||
|
if not isinstance(packages, dict):
|
||||||
|
die("index.json: missing packages object")
|
||||||
|
|
||||||
|
published = bin_index(packages)
|
||||||
|
selected: dict[str, dict] = {}
|
||||||
|
installed_bins: dict[str, list[str]] = {}
|
||||||
|
installed_sha: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def take_bin(name: str, *, required_bin: bool) -> bool:
|
||||||
|
hit = published.get(name)
|
||||||
|
if hit is None:
|
||||||
|
if required_bin:
|
||||||
|
die(f"required bin {name!r} is not in the verified stable index")
|
||||||
|
print(f"WARN: optional bin {name} not in index — skipping", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
pkg_name, pkg, binary = hit
|
||||||
|
if pkg_name in BLOCKED or name in BLOCKED:
|
||||||
|
die(f"refusing blocked package/bin {pkg_name}/{name}")
|
||||||
|
url = binary.get("dl_url")
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
die(f"{name}: missing dl_url")
|
||||||
|
digest = require_sha256(binary.get("sha256"), f"binary {name}")
|
||||||
|
dest = bin_dir / name
|
||||||
|
print(f" {name} <- {url}")
|
||||||
|
stage_file(url, dest, digest, f"binary {name}", mode=0o755)
|
||||||
|
selected[pkg_name] = pkg
|
||||||
|
installed_bins.setdefault(pkg_name, []).append(name)
|
||||||
|
installed_sha.setdefault(pkg_name, {})[name] = digest
|
||||||
|
return True
|
||||||
|
|
||||||
|
for name in required:
|
||||||
|
take_bin(name, required_bin=True)
|
||||||
|
for name in optional:
|
||||||
|
take_bin(name, required_bin=False)
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
die("no packages selected from lockfile ∩ index")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
|
installed: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for pkg_name, pkg in sorted(selected.items()):
|
||||||
|
bins = pkg.get("binaries") or []
|
||||||
|
first_url = None
|
||||||
|
for b in bins:
|
||||||
|
if isinstance(b, dict) and b.get("dl_url"):
|
||||||
|
first_url = b["dl_url"]
|
||||||
|
break
|
||||||
|
if not first_url:
|
||||||
|
die(f"{pkg_name}: no binary dl_url to derive version dir")
|
||||||
|
base = version_dir(first_url)
|
||||||
|
service_names: list[str] = []
|
||||||
|
|
||||||
|
for svc in pkg.get("services") or []:
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
die(f"{pkg_name}: service entry must be an object with unit + sha256")
|
||||||
|
unit = svc.get("unit")
|
||||||
|
if not isinstance(unit, str) or not valid_name(unit):
|
||||||
|
die(f"{pkg_name}: invalid service unit {unit!r}")
|
||||||
|
digest = require_sha256(svc.get("sha256"), f"{pkg_name} {unit}")
|
||||||
|
dest = unit_dir / unit
|
||||||
|
url = urljoin(base, unit)
|
||||||
|
print(f" {unit} <- {url}")
|
||||||
|
fetch(url, dest)
|
||||||
|
verify_sha256(dest, digest, f"unit {unit}")
|
||||||
|
dest.write_text(patch_exec_start(dest.read_text(), bin_dir))
|
||||||
|
dest.chmod(0o644)
|
||||||
|
if svc.get("enable"):
|
||||||
|
for target in wanted_by(dest.read_text()):
|
||||||
|
if not valid_name(target):
|
||||||
|
die(f"{unit}: invalid WantedBy {target!r}")
|
||||||
|
wants = unit_dir / f"{target}.wants"
|
||||||
|
wants.mkdir(parents=True, exist_ok=True)
|
||||||
|
link = wants / unit
|
||||||
|
if link.exists() or link.is_symlink():
|
||||||
|
link.unlink()
|
||||||
|
link.symlink_to(Path("..") / unit)
|
||||||
|
print(f" enabled {target}.wants/{unit}")
|
||||||
|
service_names.append(unit)
|
||||||
|
|
||||||
|
archive = pkg.get("data_archive")
|
||||||
|
if archive:
|
||||||
|
if not isinstance(archive, str) or not valid_name(archive):
|
||||||
|
die(f"{pkg_name}: invalid data_archive {archive!r}")
|
||||||
|
digest = require_sha256(pkg.get("data_archive_sha256"), f"{pkg_name} {archive}")
|
||||||
|
url = urljoin(base, archive)
|
||||||
|
data_dir = share_dir / pkg_name
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"bos-{pkg_name}-") as tmp:
|
||||||
|
tmp_path = Path(tmp) / archive
|
||||||
|
print(f" {archive} <- {url}")
|
||||||
|
stage_file(url, tmp_path, digest, f"{pkg_name} {archive}")
|
||||||
|
assert_safe_archive(tmp_path)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"tar",
|
||||||
|
"xzf",
|
||||||
|
str(tmp_path),
|
||||||
|
"--no-same-owner",
|
||||||
|
"--no-same-permissions",
|
||||||
|
"-C",
|
||||||
|
str(data_dir),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(f" extracted to {data_dir}")
|
||||||
|
|
||||||
|
desktop = pkg.get("desktop_file")
|
||||||
|
if desktop:
|
||||||
|
if not isinstance(desktop, str) or not valid_name(desktop):
|
||||||
|
die(f"{pkg_name}: invalid desktop_file {desktop!r}")
|
||||||
|
digest = require_sha256(pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}")
|
||||||
|
dest = share_dir / "applications" / f"{pkg_name}.desktop"
|
||||||
|
stage_file(urljoin(base, desktop), dest, digest, f"{pkg_name} {desktop}")
|
||||||
|
|
||||||
|
license_file = pkg.get("license_file")
|
||||||
|
if license_file:
|
||||||
|
if not isinstance(license_file, str) or not valid_name(license_file):
|
||||||
|
die(f"{pkg_name}: invalid license_file {license_file!r}")
|
||||||
|
digest = require_sha256(pkg.get("license_file_sha256"), f"{pkg_name} {license_file}")
|
||||||
|
dest = share_dir / "licenses" / pkg_name / "LICENSE"
|
||||||
|
stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}")
|
||||||
|
|
||||||
|
installed[pkg_name] = {
|
||||||
|
"name": pkg_name,
|
||||||
|
"version": pkg.get("version"),
|
||||||
|
"binaries": installed_bins.get(pkg_name, []),
|
||||||
|
"services": service_names,
|
||||||
|
"installed_at": now,
|
||||||
|
"track": "stable",
|
||||||
|
"binary_sha256": installed_sha.get(pkg_name, {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if "breadhelp" in installed:
|
||||||
|
content = share_dir / "breadhelp" / "content"
|
||||||
|
if not content.is_dir():
|
||||||
|
die(f"breadhelp data_archive did not produce {content}")
|
||||||
|
|
||||||
|
state_path = state_dir / "installed.json"
|
||||||
|
state_path.write_text(json.dumps({"track": "stable", "packages": installed}, indent=2) + "\n")
|
||||||
|
print(f"installed.json written ({len(installed)} packages): {', '.join(sorted(installed))}")
|
||||||
|
print(f"staged bins: {', '.join(sorted(p.name for p in bin_dir.iterdir() if p.is_file()))}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
128
scripts/ci-verify-bake.sh
Executable file
128
scripts/ci-verify-bake.sh
Executable file
|
|
@ -0,0 +1,128 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Read-only checks that a builder home (and optionally a staged skel) has
|
||||||
|
# everything build-local.sh needs before mkarchiso. Exit non-zero on failure.
|
||||||
|
#
|
||||||
|
# LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh
|
||||||
|
# SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
LOCKFILE="${LOCKFILE:-$REPO/iso/bread-lockfile.toml}"
|
||||||
|
LAPTOP_HOME="${LAPTOP_HOME:-/build-home}"
|
||||||
|
SKEL="${SKEL:-}"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
ok() { printf ' PASS %s\n' "$1"; pass=$((pass + 1)); }
|
||||||
|
bad() { printf ' FAIL %s\n' "$1" >&2; fail=$((fail + 1)); }
|
||||||
|
|
||||||
|
if [[ ! -f "$LOCKFILE" ]]; then
|
||||||
|
echo "ERROR: lockfile missing: $LOCKFILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$(python3 - "$LOCKFILE" <<'PY'
|
||||||
|
import sys, tomllib
|
||||||
|
path = sys.argv[1]
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
required = data.get("required_bins")
|
||||||
|
optional = data.get("optional_bins") or []
|
||||||
|
if required is None:
|
||||||
|
required = data.get("bins") or data.get("binaries") or []
|
||||||
|
def emit(name, values):
|
||||||
|
print(f"{name}=(")
|
||||||
|
for v in values:
|
||||||
|
print(f" {v!r}")
|
||||||
|
print(")")
|
||||||
|
emit("REQUIRED_BINS", required)
|
||||||
|
emit("OPTIONAL_BINS", optional)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "== lockfile $LOCKFILE =="
|
||||||
|
echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional"
|
||||||
|
echo "== builder home $LAPTOP_HOME =="
|
||||||
|
|
||||||
|
check_exec() {
|
||||||
|
local path="$1" label="$2"
|
||||||
|
if [[ -x "$path" && -f "$path" ]]; then
|
||||||
|
ok "$label executable: $path"
|
||||||
|
else
|
||||||
|
bad "$label missing or not executable: $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dir() {
|
||||||
|
local path="$1" label="$2"
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
ok "$label: $path"
|
||||||
|
else
|
||||||
|
bad "$label missing: $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_file() {
|
||||||
|
local path="$1" label="$2"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
ok "$label: $path"
|
||||||
|
else
|
||||||
|
bad "$label missing: $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for b in "${REQUIRED_BINS[@]}"; do
|
||||||
|
check_exec "$LAPTOP_HOME/.local/bin/$b" "required bin $b"
|
||||||
|
done
|
||||||
|
for b in "${OPTIONAL_BINS[@]}"; do
|
||||||
|
if [[ -x "$LAPTOP_HOME/.local/bin/$b" ]]; then
|
||||||
|
ok "optional bin $b present"
|
||||||
|
else
|
||||||
|
printf ' ---- optional bin %s not staged (ok until bread ships it)\n' "$b"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
check_dir "$LAPTOP_HOME/.local/share/breadhelp/content" "breadhelp content"
|
||||||
|
check_file "$LAPTOP_HOME/.cache/bakery/index.json" "bakery index cache"
|
||||||
|
check_file "$LAPTOP_HOME/.local/state/bakery/installed.json" "bakery installed.json"
|
||||||
|
|
||||||
|
mapfile -t UNITS < <(python3 - "$LAPTOP_HOME/.local/state/bakery/installed.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
pkgs = data.get("packages", data)
|
||||||
|
for pkg in pkgs.values():
|
||||||
|
for s in pkg.get("services", []):
|
||||||
|
print(s["unit"] if isinstance(s, dict) else s)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
if [[ ${#UNITS[@]} -eq 0 ]]; then
|
||||||
|
bad "installed.json lists no service units"
|
||||||
|
else
|
||||||
|
for unit in "${UNITS[@]}"; do
|
||||||
|
[[ -n "$unit" ]] || continue
|
||||||
|
check_file "$LAPTOP_HOME/.config/systemd/user/$unit" "unit $unit"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$SKEL" ]]; then
|
||||||
|
echo "== staged skel $SKEL =="
|
||||||
|
for b in "${REQUIRED_BINS[@]}"; do
|
||||||
|
check_exec "$SKEL/.local/bin/$b" "skel required bin $b"
|
||||||
|
done
|
||||||
|
check_dir "$SKEL/.local/share/breadhelp/content" "skel breadhelp content"
|
||||||
|
check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache"
|
||||||
|
for unit in "${UNITS[@]}"; do
|
||||||
|
[[ -n "$unit" ]] || continue
|
||||||
|
if [[ -f "$SKEL/.config/systemd/user/$unit" ]]; then
|
||||||
|
ok "skel unit $unit"
|
||||||
|
else
|
||||||
|
bad "skel unit missing: $SKEL/.config/systemd/user/$unit"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf 'Result: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
|
[[ "$fail" -eq 0 ]]
|
||||||
|
|
@ -46,9 +46,16 @@ done
|
||||||
check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]"
|
check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]"
|
||||||
|
|
||||||
echo "== bread ecosystem on PATH =="
|
echo "== bread ecosystem on PATH =="
|
||||||
for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do
|
for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do
|
||||||
check "$bin found" "command -v $bin"
|
check "$bin found" "command -v $bin"
|
||||||
done
|
done
|
||||||
|
for bin in bread-emit bread-module-host; do
|
||||||
|
if command -v "$bin" >/dev/null 2>&1; then
|
||||||
|
ok "$bin found"
|
||||||
|
else
|
||||||
|
note "$bin not on PATH (optional until stable bread ships it)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "== bos-settings =="
|
echo "== bos-settings =="
|
||||||
check "bos-settings installed" "command -v bos-settings"
|
check "bos-settings installed" "command -v bos-settings"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue