Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# CI for Arch Silverblue. Mirrors the Makefile targets:
# * test -> `make test` (shellcheck + bats + systemd-analyze verify) — fast, required
# * build-iso -> `make build-iso` (privileged Docker + mkarchiso) — main/tags only
# * qemu -> `make test-qemu` (full boot in QEMU) — manual only
# * qemu -> `make test-qemu` / `make test-qemu-interactive` — manual only
# * release -> publish the built ISO + SHA256SUMS to a GitHub Release — v* tags only
#
# shellcheck/bats come from `nix shell` (as documented for local dev), so CI installs Nix and
# runs the same `make` targets a contributor runs — no separate tool list to drift.
Expand All @@ -13,6 +14,17 @@ on:
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
qemu_scenario:
description: "QEMU scenario(s) to run"
type: choice
options: [unattended, interactive, both]
default: unattended
qemu_bootloader:
description: "Bootloader to drive through the QEMU scenario(s)"
type: choice
options: [systemd-boot, grub]
default: systemd-boot

# One in-flight run per ref; cancel superseded ones (e.g. force-pushes to a PR).
concurrency:
Expand Down Expand Up @@ -68,5 +80,33 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends qemu-system-x86 ovmf
- name: make test-qemu
run: make test-qemu # builds the ISO first via its Makefile prerequisite
- name: Build ISO
run: make build-iso
- name: QEMU unattended (install/happy/rollback)
if: inputs.qemu_scenario == 'unattended' || inputs.qemu_scenario == 'both' || inputs.qemu_scenario == ''
run: bash tests/qemu/run.sh --bootloader "${{ inputs.qemu_bootloader || 'systemd-boot' }}"
- name: QEMU interactive installer
if: inputs.qemu_scenario == 'interactive' || inputs.qemu_scenario == 'both'
run: bash tests/qemu/run.sh --interactive --bootloader "${{ inputs.qemu_bootloader || 'systemd-boot' }}"

release:
name: publish release (tags)
runs-on: ubuntu-latest
needs: [test, build-iso]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: iso
path: iso/output
- name: Create release and upload ISO + SHA256SUMS
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \
gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes \
--title "Arch Silverblue $GITHUB_REF_NAME"
gh release upload "$GITHUB_REF_NAME" iso/output/*.iso iso/output/SHA256SUMS --clobber
17 changes: 12 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ full pipeline (`make ci`) additionally needs **Docker** and **QEMU**.
| `make test` | `lint` + `test-unit` + `verify-units` — the fast inner loop | nix, systemd-analyze |
| `make build-iso` | Build the bootable ISO via Docker (also writes `iso/output/SHA256SUMS`) | docker (`--privileged`) + network |
| `make test-qemu` | Boot the ISO in QEMU; run the happy-path + rollback tests | qemu (KVM or TCG) |
| `make test-qemu-interactive` | Drive the interactive installer in QEMU; boot + verify the result | qemu (KVM or TCG) |
| `make ci` | `test` + `build-iso` + `test-qemu` (full pipeline) | all of the above |

Run **`make test` before every change** — it is fast and needs no docker/qemu.
Expand All @@ -48,6 +49,7 @@ nix shell nixpkgs#bats --command bats tests/unit/test_rollback.bats # one file
bash tests/qemu/run.sh # integration (systemd-boot, default)
bash tests/qemu/run.sh --bootloader grub # integration against GRUB
bash tests/qemu/run.sh --net # update cycle over real pacman -Syu
bash tests/qemu/run.sh --interactive # interactive installer end-to-end
```

## Conventions
Expand All @@ -66,18 +68,23 @@ These are load-bearing — please follow them:
reads config from `SB_*` env vars, so tests can mock them. Prefer adding **pure, unit-testable
functions** over inlining effectful logic.
- **`SILVERBLUE-*` markers are a contract.** The uppercase progress markers (e.g.
`SILVERBLUE-MARKGOOD-OK`, `SILVERBLUE-ROLLBACK-ARMED`) are grepped literally by the QEMU
harness, and the build's `render()` deliberately leaves them untouched. Don't rename them.
`SILVERBLUE-MARKGOOD-OK`, `SILVERBLUE-ROLLBACK-ARMED`, `SILVERBLUE-INSTALL-PROMPT`) are
grepped literally by the QEMU harness, and the build's `render()` deliberately leaves them
untouched. Don't rename them. The interactive installer's **prompt order** is part of the
same contract: `phase_interactive_install()` in `tests/qemu/harness.py` answers the prompts
in the order `gather_answers()` (in `src/installer/silverblue-install`) asks them — change
one and you must change the other.
- **Keep `src/` generic.** The source tree keeps the upstream `silverblue` names; all
rebranding happens at build/install time from `config/distro.conf` (see DERIVING.md). Don't
hardcode brand-specific strings into `src/`.

## CI

Every push and pull request runs `make test`. Pushes to `main` and tags also build the ISO and
upload it with its `SHA256SUMS`. The QEMU integration job is manual (`workflow_dispatch`) because
GitHub runners have no KVM and the TCG fallback is slow. See
[`.github/workflows/ci.yml`](.github/workflows/ci.yml).
upload it with its `SHA256SUMS`; pushing a `v*` tag additionally publishes them to a **GitHub
Release**. The QEMU integration job is manual (`workflow_dispatch`, with a scenario picker for
the unattended and/or interactive suites) because GitHub runners have no KVM and the TCG
fallback is slow. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml).

## Pull requests

Expand Down
19 changes: 13 additions & 6 deletions DERIVING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ stock Arch Silverblue. Variables, grouped:

| Variable | Purpose | Required for a rename? |
| --- | --- | --- |
| `BIN_PREFIX` | the CLI becomes `${BIN_PREFIX}-update` (`/usr/bin/silverblue-update` by default) | yes |
| `BIN_PREFIX` | the CLIs become `${BIN_PREFIX}-update` and `${BIN_PREFIX}-install` (`/usr/bin/silverblue-update` / `-install` by default) | yes |
| `UNIT_PREFIX` | the units become `${UNIT_PREFIX}-mark-good.service` / `-rollback.service` / `-rollback.target` | yes |
| `LIB_DIR` | helper/library dir (`/usr/lib/silverblue`) | yes |
| `ESP_SUBDIR` | per-snapshot kernel dir on the ESP (`/efi/silverblue/<snap>`, systemd-boot) | cosmetic |
Expand All @@ -76,6 +76,9 @@ stock Arch Silverblue. Variables, grouped:
| `LOCALE` | `locale.gen` entry + `/etc/locale.conf` `LANG` |
| `KEYMAP` | vconsole keymap (`""` = leave default) |

These are what the unattended test install writes verbatim, and what the **interactive
installer** offers as prompt defaults (`BOOTLOADER` below likewise seeds its bootloader menu).

### Bootloader / filesystem

| Variable | Purpose |
Expand All @@ -99,20 +102,23 @@ stock Arch Silverblue. Variables, grouped:

| You set… | …and it lands in |
| --- | --- |
| `BIN_PREFIX` | `/usr/bin/<prefix>-update`; the baked engine defaults; `file_permissions` in the ISO profile |
| `DISTRO_ID` | the ISO file name (`<id>-YYYY.MM.DD-x86_64.iso`) and the Docker image tag |
| `BIN_PREFIX` | `/usr/bin/<prefix>-update` and `/usr/bin/<prefix>-install`; the baked engine defaults; `file_permissions` in the ISO profile |
| `UNIT_PREFIX` | the three unit files + the two `*-mark-good.sh`/`*-rollback.sh` scripts; the enable symlink; the watchdog drop-in |
| `LIB_DIR` | where the engine finds its helpers/scripts (`SB_LIB_DIR` baked in) |
| `DISTRO_NAME` | systemd-boot/GRUB entry titles; unit `Description=`; os-release `NAME`/`PRETTY_NAME` |
| `DISTRO_*` URLs/ids | `/etc/os-release` (rendered from [`config/os-release.in`](config/os-release.in)) and unit `Documentation=` |
| `HOSTNAME` / `TIMEZONE` / `LOCALE` / `KEYMAP` | the installed `/etc/hostname`, `/etc/localtime`, `/etc/locale.conf`, `/etc/vconsole.conf` |
| `FS_LABEL` / `ESP_LABEL` | the Btrfs/ESP labels and the GPT partition name |
| `PKGS_ISO` | `packages.x86_64` in the archiso profile |
| `PKGS_BASE` | the `pacstrap` command in the autoinstaller |
| `PKGS_BASE` | the `pacstrap` package list of both installers (the interactive one adds the user's microcode/firmware/network/sudo choices on top) |
| `EXTRA_REPOS` | the target's `/etc/pacman.conf` |

What deliberately stays generic: the whole `src/` tree on disk, the upstream unit tests,
`tools/verify-units.sh`, the uppercase `SILVERBLUE-*` progress markers (the QEMU harness greps
them), and the test-only `silverblue-autoinstall.sh` / synthetic `[silverblue-local]` repo.
The installer library and interactive frontend are installed **verbatim** (they read your
`distro.conf` at runtime); only the frontend's file *name* is derived from `BIN_PREFIX`.

## Branding

Expand All @@ -133,9 +139,10 @@ them), and the test-only `silverblue-autoinstall.sh` / synthetic `[silverblue-lo
$'[mydistro]\nSigLevel = Optional TrustAll\nServer = https://repo.mydistro.org/$arch'
)
```
**Caveat:** `EXTRA_REPOS` is only applied on the **networked** install path (`net=1`). The
offline/hermetic test path leaves the target with just the bundled `file://` repo so the
self-contained update test still works.
**Caveat:** `EXTRA_REPOS` is applied by the **interactive installer** (always networked) and
by the unattended test install's networked path (`net=1`). The offline/hermetic test path
leaves the target with just the bundled `file://` repo so the self-contained update test
still works.

## Build & verify

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ SHELL_FILES := \
src/bootloader/grub-helpers.sh \
src/init/silverblue-mark-good.sh \
src/init/silverblue-rollback.sh \
src/installer/install-lib.sh \
src/installer/silverblue-install \
iso/build.sh \
iso/airootfs/usr/local/bin/silverblue-autoinstall.sh \
tests/qemu/run.sh \
Expand All @@ -31,7 +33,7 @@ IMAGE := $(shell bash -c '. config/distro.conf 2>/dev/null && printf "%s-iso" "$
ISO_STAMP := iso/output/.built
ISO_INPUTS := iso/Dockerfile iso/build.sh $(shell find iso/airootfs src config -type f 2>/dev/null)

.PHONY: all lint test-unit verify-units build-iso rebuild-iso test-qemu test ci clean help
.PHONY: all lint test-unit verify-units build-iso rebuild-iso test-qemu test-qemu-interactive test ci clean help

help:
@echo "Targets:"
Expand All @@ -40,6 +42,7 @@ help:
@echo " verify-units systemd-analyze verify the Silverblue systemd units"
@echo " build-iso build the bootable ISO via Docker (needs --privileged + network)"
@echo " test-qemu boot the ISO in QEMU and run the happy-path + rollback tests"
@echo " test-qemu-interactive drive the interactive installer in QEMU and boot the result"
@echo " test lint + test-unit + verify-units (fast inner loop)"
@echo " ci test + build-iso + test-qemu (full pipeline)"
@echo " clean remove build/test artifacts"
Expand Down Expand Up @@ -75,6 +78,9 @@ rebuild-iso:
test-qemu: $(ISO_STAMP)
bash tests/qemu/run.sh

test-qemu-interactive: $(ISO_STAMP)
bash tests/qemu/run.sh --interactive

ci: test build-iso test-qemu

clean:
Expand Down
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,20 @@ src/
update-engine/silverblue-update # the atomic update CLI (Bash, shellcheck-clean)
bootloader/sdboot-helpers.sh # systemd-boot entry/copy/bless/prune helpers
bootloader/grub-helpers.sh # GRUB menuentry/grubenv helpers
installer/install-lib.sh # shared install steps (partition/pacstrap/bootloader)
installer/silverblue-install # minimal interactive installer (runs on the live ISO)
init/silverblue-mark-good.{service,sh} # post-boot health check + good-marking
init/silverblue-rollback.{target,service,sh} # OnFailure rollback
init/silverblue-watchdog.conf # RuntimeWatchdogSec drop-in (hang recovery)
iso/
Dockerfile # reproducible archiso build image
build.sh # assembles the releng profile + overlay, runs mkarchiso
airootfs/ # ISO overlay: autoinstaller, serial autologin, fw_cfg
airootfs/ # ISO overlay: test autoinstaller, serial autologin, fw_cfg
tests/
unit/ # bats tests + command mocks (no root/Btrfs needed)
qemu/run.sh, qemu/harness.py # boot the ISO in QEMU; happy-path + rollback tests
qemu/run.sh, qemu/harness.py # boot the ISO in QEMU; install/update/rollback tests
docs/update-flow.md # documented flow + ASCII diagram
docs/installing.md # end-user install guide (real hardware)
Makefile # lint / test-unit / build-iso / test-qemu
```

Expand Down Expand Up @@ -82,6 +85,16 @@ make test-qemu # boots the ISO, installs, runs the happy-path + rollback ass
rollback path (bad update → reverts to previous root) pass. It uses KVM when `/dev/kvm` is
writable and falls back to TCG (`-cpu qemu64`) otherwise.

## Installing on real hardware

Download the ISO from
[GitHub Releases](https://github.com/sinisterMage/Arch-silverblue/releases/latest) (published
automatically on version tags), boot it in UEFI mode, and run **`silverblue-install`** — a
minimal plain-prompt installer that asks for disk, hostname, timezone/locale/keymap,
bootloader, CPU microcode, `linux-firmware`, a network stack, a root password, and an optional
admin user, then requires typing `ERASE` before touching anything. See
[`docs/installing.md`](docs/installing.md) for the full guide.

## Usage on an installed system

`silverblue-update` must run as root on a Btrfs root with an ESP at `/efi`.
Expand Down Expand Up @@ -114,6 +127,13 @@ default `3`), and the injectable command paths `BTRFS`, `BOOTCTL`, `GRUB_EDITENV
real `pacman -Syu` over QEMU user-net. The integration test is validated against systemd-boot.
> The install phase pacstraps a base system, which requires network (QEMU user-net) — this is
> intrinsic to installing Arch. The *update* assertion is what runs offline.
- **Interactive-installer test** (`make test-qemu-interactive`) drives `silverblue-install`
itself over the serial console: the harness answers every prompt via the
`SILVERBLUE-INSTALL-PROMPT` markers, confirms with `ERASE`, then boots the installed system,
logs in with the password it set, and asserts the subvolume, hostname, mark-good, network
stack, admin user, and that no test-only artifacts (autologin, local test repo) leaked onto
the target. The interactive installer and the test autoinstaller share one implementation
(`src/installer/install-lib.sh`), so the unattended scenarios also cover the shared steps.

## Verification checklist

Expand All @@ -130,7 +150,11 @@ silverblue-update --rollback
## Design notes & limitations

- **systemd-boot is the primary, CI-validated path.** GRUB is fully implemented, covered by unit
tests, and the QEMU harness can now drive it end-to-end (`tests/qemu/run.sh --bootloader grub`).
tests, and the QEMU harness can drive it end-to-end (`tests/qemu/run.sh --bootloader grub`).
One GRUB limitation: a kernel that fails to *load* is not recovered unattended (stock GRUB has
no in-session fallback; the held menu offers the previous root one keypress away) — the GRUB
rollback test therefore exercises the health-check `OnFailure` path, while the systemd-boot
test exercises boot counting with a corrupt kernel.
- **GRUB cannot write Btrfs**, so `grubenv` is kept on the FAT ESP (`/efi/grub/grubenv`). This
avoids patching GRUB and keeps everything within stock Arch packages.
- **Boot counting cannot reboot a hang by itself** — `RuntimeWatchdogSec` (a hardware watchdog)
Expand All @@ -143,9 +167,10 @@ silverblue-update --rollback

### Out of scope

GUI/TUI, ZFS implementation (documented as future work in `docs/update-flow.md`), OTA/delta
updates, custom pacman wrappers or signing, PXE, Secure Boot, and any immutable-root
enforcement.
Any GUI (a minimal plain-prompt TUI installer *is* included — see
[`docs/installing.md`](docs/installing.md)), LUKS/disk encryption, swap setup, ZFS
implementation (documented as future work in `docs/update-flow.md`), OTA/delta updates, custom
pacman wrappers or signing, PXE, Secure Boot, and any immutable-root enforcement.

## Contributing

Expand Down
10 changes: 7 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ report we want.
`iso/airootfs/usr/local/bin/silverblue-autoinstall.sh` targets `/dev/vda`, wipes it
unconditionally, and provisions a **passwordless root** with serial-console autologin. It is
gated behind a QEMU `fw_cfg` blob (`ConditionPathExists=…/opt/silverblue/scenario/raw`), so it
never runs on a normal/interactive boot or on real hardware. Producing a hardened, interactive
installer (disk selection, user accounts, network, microcode) is out of scope for the current
image.
never runs on a normal/interactive boot or on real hardware.
- **The interactive installer (`silverblue-install`) is separate from the test appliance.**
It only runs when the user invokes it, requires typing `ERASE` before any destructive step,
requires a root password (no passwordless accounts on interactive targets), and never
installs the test-only artifacts (no autologin drop-in, no `[silverblue-local]` repo). It is
still a *minimal* installer: no LUKS, no Secure Boot — see
[docs/installing.md](docs/installing.md) for its scope.
- **The offline test repo trusts unsigned packages.** The synthetic `[silverblue-local]` repo
baked into the ISO for the hermetic update test uses `SigLevel = Optional TrustAll`. Derivatives
that ship their own repositories should **sign them** and avoid `TrustAll` (see
Expand Down
Loading
Loading