From 1ec9fd81e5c74acfc99c45dc7f7e31cb63de41ce Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sun, 4 Oct 2026 15:50:48 -0500 Subject: [PATCH] Pin moved 1.8 tag and guard the baked baseline against the published channel The radiantwave 1.8 tag was force-moved from 34c72b5 to a5a6b5b while flake.lock still pinned the old commit, so the ISO baked a baseline that was not the closure RadiantWave CI published to Attic. With version 1.8 overridden this could still compile RadiantWave from source instead of substituting it. - Update flake.lock so radiantwave?ref=refs/tags/1.8 resolves to a5a6b5b. - Expose channelVersion/channelAtticUrl/channelManifestDir/baselineOutPaths from flake.nix for CI verification. - Prod workflows: fail fast unless channel.appVersion equals the channel manifest version, baselineOutPaths. equals the manifest storePath, and that store path exists in the channel's Attic cache. --- .gitea/workflows/build-prod-android.yml | 41 ++++++++++++++++++++++ .gitea/workflows/build-prod-commercial.yml | 41 ++++++++++++++++++++++ .gitea/workflows/build-prod-kiosk.yml | 41 ++++++++++++++++++++++ .gitea/workflows/build-prod-rpi.yml | 41 ++++++++++++++++++++++ AGENTS.md | 8 +++-- flake.lock | 8 ++--- flake.nix | 17 ++++++++- 7 files changed, 189 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/build-prod-android.yml b/.gitea/workflows/build-prod-android.yml index 56a83c2..61f9905 100644 --- a/.gitea/workflows/build-prod-android.yml +++ b/.gitea/workflows/build-prod-android.yml @@ -28,6 +28,47 @@ jobs: exit 1 fi + # Fail fast unless the closure baked into the ISO is exactly what + # RadiantWave CI published to Attic. Otherwise the kiosk compiles + # RadiantWave from source (slow) and the live updater immediately swaps + # to the announced closure on first boot. See `channel` in flake.nix. + - name: Verify baked baseline matches the published channel + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + VERSION=$(nix eval --impure --raw .#channelVersion) + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/manifest.json" + BASELINE=$(nix eval --impure --raw .#baselineOutPaths.x86_64-linux) + + MANIFEST=$(curl -fsSL "$MANIFEST_URL") + PUB_VERSION=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).version') + PUB_PATH=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).storePath') + + echo "channel version : $VERSION" + echo "manifest version: $PUB_VERSION" + echo "baseline path : $BASELINE" + echo "manifest path : $PUB_PATH" + + if [ "$VERSION" != "$PUB_VERSION" ]; then + echo "ERROR: channel.appVersion '$VERSION' != published manifest version '$PUB_VERSION'." + echo " Bump channel.appVersion (and the pinned radiantwave input) to match the release." + exit 1 + fi + + if [ "$BASELINE" != "$PUB_PATH" ]; then + echo "ERROR: baseline '$BASELINE' is not the published closure '$PUB_PATH'." + echo " Run 'nix flake update radiantwave' so the pinned input resolves to the release the manifest was built from." + exit 1 + fi + + HASH=$(basename "$BASELINE" | cut -d- -f1) + if ! curl -fsS -o /dev/null "$ATTIC/${HASH}.narinfo"; then + echo "ERROR: $BASELINE is not in $ATTIC; the ISO would compile RadiantWave from source." + exit 1 + fi + - name: Prepare output directory run: mkdir -p /var/lib/gitea-runner/radiantwave diff --git a/.gitea/workflows/build-prod-commercial.yml b/.gitea/workflows/build-prod-commercial.yml index 13b0eef..dd0335a 100644 --- a/.gitea/workflows/build-prod-commercial.yml +++ b/.gitea/workflows/build-prod-commercial.yml @@ -28,6 +28,47 @@ jobs: exit 1 fi + # Fail fast unless the closure baked into the ISO is exactly what + # RadiantWave CI published to Attic. Otherwise the kiosk compiles + # RadiantWave from source (slow) and the live updater immediately swaps + # to the announced closure on first boot. See `channel` in flake.nix. + - name: Verify baked baseline matches the published channel + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + VERSION=$(nix eval --impure --raw .#channelVersion) + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/manifest.json" + BASELINE=$(nix eval --impure --raw .#baselineOutPaths.x86_64-linux) + + MANIFEST=$(curl -fsSL "$MANIFEST_URL") + PUB_VERSION=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).version') + PUB_PATH=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).storePath') + + echo "channel version : $VERSION" + echo "manifest version: $PUB_VERSION" + echo "baseline path : $BASELINE" + echo "manifest path : $PUB_PATH" + + if [ "$VERSION" != "$PUB_VERSION" ]; then + echo "ERROR: channel.appVersion '$VERSION' != published manifest version '$PUB_VERSION'." + echo " Bump channel.appVersion (and the pinned radiantwave input) to match the release." + exit 1 + fi + + if [ "$BASELINE" != "$PUB_PATH" ]; then + echo "ERROR: baseline '$BASELINE' is not the published closure '$PUB_PATH'." + echo " Run 'nix flake update radiantwave' so the pinned input resolves to the release the manifest was built from." + exit 1 + fi + + HASH=$(basename "$BASELINE" | cut -d- -f1) + if ! curl -fsS -o /dev/null "$ATTIC/${HASH}.narinfo"; then + echo "ERROR: $BASELINE is not in $ATTIC; the ISO would compile RadiantWave from source." + exit 1 + fi + - name: Prepare output directory run: mkdir -p /var/lib/gitea-runner/radiantwave diff --git a/.gitea/workflows/build-prod-kiosk.yml b/.gitea/workflows/build-prod-kiosk.yml index 008f296..c473da9 100644 --- a/.gitea/workflows/build-prod-kiosk.yml +++ b/.gitea/workflows/build-prod-kiosk.yml @@ -28,6 +28,47 @@ jobs: exit 1 fi + # Fail fast unless the closure baked into the ISO is exactly what + # RadiantWave CI published to Attic. Otherwise the kiosk compiles + # RadiantWave from source (slow) and the live updater immediately swaps + # to the announced closure on first boot. See `channel` in flake.nix. + - name: Verify baked baseline matches the published channel + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + VERSION=$(nix eval --impure --raw .#channelVersion) + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/manifest.json" + BASELINE=$(nix eval --impure --raw .#baselineOutPaths.x86_64-linux) + + MANIFEST=$(curl -fsSL "$MANIFEST_URL") + PUB_VERSION=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).version') + PUB_PATH=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).storePath') + + echo "channel version : $VERSION" + echo "manifest version: $PUB_VERSION" + echo "baseline path : $BASELINE" + echo "manifest path : $PUB_PATH" + + if [ "$VERSION" != "$PUB_VERSION" ]; then + echo "ERROR: channel.appVersion '$VERSION' != published manifest version '$PUB_VERSION'." + echo " Bump channel.appVersion (and the pinned radiantwave input) to match the release." + exit 1 + fi + + if [ "$BASELINE" != "$PUB_PATH" ]; then + echo "ERROR: baseline '$BASELINE' is not the published closure '$PUB_PATH'." + echo " Run 'nix flake update radiantwave' so the pinned input resolves to the release the manifest was built from." + exit 1 + fi + + HASH=$(basename "$BASELINE" | cut -d- -f1) + if ! curl -fsS -o /dev/null "$ATTIC/${HASH}.narinfo"; then + echo "ERROR: $BASELINE is not in $ATTIC; the ISO would compile RadiantWave from source." + exit 1 + fi + - name: Prepare output directory run: mkdir -p /var/lib/gitea-runner/radiantwave diff --git a/.gitea/workflows/build-prod-rpi.yml b/.gitea/workflows/build-prod-rpi.yml index ec9509f..7c3fcd9 100644 --- a/.gitea/workflows/build-prod-rpi.yml +++ b/.gitea/workflows/build-prod-rpi.yml @@ -25,6 +25,47 @@ jobs: exit 1 fi + # Fail fast unless the closure baked into the image is exactly what + # RadiantWave CI published to Attic (aarch64). Otherwise the Pi compiles + # RadiantWave from source (slow) unless RadiantWave CI has published both + # the aarch64 closure and aarch64-manifest.json. See `channel` in flake.nix. + - name: Verify baked baseline matches the published channel + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + VERSION=$(nix eval --impure --raw .#channelVersion) + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/aarch64-manifest.json" + BASELINE=$(nix eval --impure --raw .#baselineOutPaths.aarch64-linux) + + MANIFEST=$(curl -fsSL "$MANIFEST_URL") + PUB_VERSION=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).version') + PUB_PATH=$(MANIFEST="$MANIFEST" nix eval --impure --raw --expr '(builtins.fromJSON (builtins.getEnv "MANIFEST")).storePath') + + echo "channel version : $VERSION" + echo "manifest version: $PUB_VERSION" + echo "baseline path : $BASELINE" + echo "manifest path : $PUB_PATH" + + if [ "$VERSION" != "$PUB_VERSION" ]; then + echo "ERROR: channel.appVersion '$VERSION' != published manifest version '$PUB_VERSION'." + echo " Bump channel.appVersion (and the pinned radiantwave input) to match the release." + exit 1 + fi + + if [ "$BASELINE" != "$PUB_PATH" ]; then + echo "ERROR: baseline '$BASELINE' is not the published closure '$PUB_PATH'." + echo " Run 'nix flake update radiantwave' so the pinned input resolves to the release the manifest was built from." + exit 1 + fi + + HASH=$(basename "$BASELINE" | cut -d- -f1) + if ! curl -fsS -o /dev/null "$ATTIC/${HASH}.narinfo"; then + echo "ERROR: $BASELINE is not in $ATTIC; the image would compile RadiantWave from source." + exit 1 + fi + - name: Prepare output directory run: mkdir -p /var/lib/gitea-runner/radiantwave diff --git a/AGENTS.md b/AGENTS.md index 691c991..18f1e45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ Two branches — `main` (prod) and `dev`. There are **no `-dev` configs**: each Everything downstream (the configs, ISOs, updater module, autoUpgrade refs) reads from `channel`, so a `dev`→`main` merge carries your changes automatically — no copying between sibling configs. To flip a channel, change only those four `channel` values. When merging, reconcile just this one block. -`channel.appVersion` is applied to the baked app via `appPackage = system: channel.appInput.packages.${system}.default.override { version = channel.appVersion; }`. RadiantWave's own flake only injects its version from the ambient `APP_VERSION` env var on impure evals, so pure evals (local `nix build`, `build-rpi.sh`) fall back to `0.1.0`. Declaring the version here keeps local and CI builds correct without `--impure`, and the derivation matches the Attic closure when RadiantWave CI built it with the same version string. Bump `appVersion` whenever the pinned input changes (release tag on `main`, short commit hash on `dev`). +`channel.appVersion` is applied to the baked app via `appPackage = system: channel.appInput.packages.${system}.default.override { version = channel.appVersion; }`. RadiantWave's own flake only injects its version from the ambient `APP_VERSION` env var on impure evals, so pure evals (local `nix build`, `build-rpi.sh`) fall back to `0.1.0`. Declaring the version here keeps local and CI builds correct without `--impure`. Bump `appVersion` whenever the pinned input changes (release tag on `main`, short commit hash on `dev`). + +The baked baseline must be **byte-identical to the closure RadiantWave CI publishes to Attic** — that is what lets the ISO/`sdImage` substitute RadiantWave instead of compiling it from source, and makes the first live update a no-op. The prod workflows assert this in "Verify baked baseline matches the published channel": it compares `channelVersion` to the manifest's `version`, `baselineOutPaths.` to the manifest's `storePath`, and confirms that store path exists in the channel's Attic cache. If it fails, the usual cause is a **moved release tag**: `radiantwave` is pinned by tag ref in `flake.lock`, so re-tagging `1.8` (as happened when it moved from `34c72b5` to `a5a6b5b`) leaves the lock on the old commit until you run `nix flake update radiantwave`. The RadiantWave flake also includes `.gitea/` in the app source, so its CI workflow changes alter the derivation hash. Both gitea repos are public: all git access is plain HTTPS (`git+https://...`), no SSH keys or access tokens on the kiosks. Don't reintroduce `git+ssh` URLs in flake/upgrade refs. @@ -88,7 +90,7 @@ Regenerate when major hardware changes, not on a schedule — reports are snapsh ## CI (Gitea) -`.gitea/workflows/build-*.yml` — all triggered by `workflow_dispatch` on the `build-server` runner. They only **build and publish** what is committed. `flake.lock` is updated manually: commit an updated lock (e.g. after `nix flake update radiantwave-dev`) and dispatching the workflow is the release trigger. +`.gitea/workflows/build-*.yml` — all triggered by `workflow_dispatch` on the `build-server` runner. They only **build and publish** what is committed. `flake.lock` is updated manually: commit an updated lock (e.g. after `nix flake update radiantwave-dev`) and dispatching the workflow is the release trigger. The prod workflows run a "Verify baked baseline matches the published channel" preflight that fails the build if the baked closure diverges from the channel manifest or is missing from Attic (see the `channel` block and `baselineOutPaths` in `flake.nix`). ## Important gotchas @@ -100,7 +102,7 @@ Regenerate when major hardware changes, not on a schedule — reports are snapsh - **Live app updater**: each config's `radiantwaveUpdaterModule` points at a per-arch manifest — x86 uses `/manifest.json`, RPi uses `/aarch64-manifest.json` (via the `manifestFile` arg). A 2-min timer polls it and fetches the announced closure from the channel's Attic repo. RPi live updates require RadiantWave CI to publish both an aarch64 closure and `aarch64-manifest.json`; until then the RPi stays on the baked baseline. - **No binary cache**: kiosks fetch from cache.nixos.org only and compile RadiantWave itself from source during upgrades. Attic integration was planned (see `PLAN-update-system.md`) but never wired; the old netrc/JWT setup has been removed. - **Secrets**: none in-tree anymore (gitea repos are public). If either repo goes private, re-add auth via `nix.settings.access-tokens` in `configuration.nix`. -- **Flake inputs**: `radiantwave` is pinned to tag `1.8` (`?ref=refs/tags/1.8`); `radiantwave-dev` tracks `?ref=dev`. The `channel` block picks which one the configs consume. Bump the pinned tag and `channel.appVersion` together. +- **Flake inputs**: `radiantwave` is pinned to tag `1.8` (`?ref=refs/tags/1.8`); `radiantwave-dev` tracks `?ref=dev`. The `channel` block picks which one the configs consume. Bump the pinned tag and `channel.appVersion` together. **Tag refs are live**: if RadiantWave force-moves a tag, the lock keeps the old commit until `nix flake update radiantwave`, so the baked baseline silently diverges from the published channel (the CI preflight catches this). - **SSH keys on machines are inbound-only** (`authorizedKeys` for admin access); outbound git is HTTPS. The root key in `zero-touch-installer.nix` must stay in sync with `myKey` in `configuration.nix`. ## File map diff --git a/flake.lock b/flake.lock index 316d65b..3365813 100644 --- a/flake.lock +++ b/flake.lock @@ -220,11 +220,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1790803867, - "narHash": "sha256-HRGQQlmn/kCsZIq1wBMFaW6q1xfSvwPhUS5CpG1mm+Q=", + "lastModified": 1791050336, + "narHash": "sha256-sb/cDFS55qodbU6GEydhyRyW8VBTGg110ejSXVNd2OY=", "ref": "refs/tags/1.8", - "rev": "34c72b51ddc3da8d46d994e787ffd1f8e8dda77c", - "revCount": 85, + "rev": "a5a6b5bef07fffb5ec9597e577e6fb8a4b2a0108", + "revCount": 87, "type": "git", "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" }, diff --git a/flake.nix b/flake.nix index defc28e..ecacefa 100644 --- a/flake.nix +++ b/flake.nix @@ -83,6 +83,21 @@ # instead of silently shipping dev builds as prod ISOs. channelName = if channel.appInput == radiantwave then "radiantwave" else "radiantwave-dev"; + # Channel metadata exposed for CI preflight (see .gitea/workflows/build-*). + # `channelVersion` must equal the version RadiantWave CI publishes in the + # channel manifest; `baselineOutPaths` must equal the manifest's + # `storePath`. When they do, the ISO bakes exactly the closure already in + # Attic, so the app is substituted (never compiled locally) and the first + # live update is a no-op. A mismatch means either the pinned input is + # stale (as when the release tag moves) or channel.appVersion drifted. + channelVersion = channel.appVersion; + channelAtticUrl = "https://attic.radiantwavetech.com/${channel.atticRepo}"; + channelManifestDir = "https://files.radiantwavetech.com/${channel.atticRepo}"; + baselineOutPaths = { + x86_64-linux = (appPackage "x86_64-linux").outPath; + aarch64-linux = (appPackage "aarch64-linux").outPath; + }; + # The App package, pinned to the channel's declared version. RadiantWave's # own flake only injects its version from the ambient APP_VERSION env var # on impure evals; pure evals (local `nix build`, build-rpi.sh) fall back @@ -224,7 +239,7 @@ }; in { - inherit channelName; + inherit channelName channelVersion channelAtticUrl channelManifestDir baselineOutPaths; packages.x86_64-linux.radiantwavekiosk = self.nixosConfigurations.radiantwavekiosk.config.system.build.toplevel; packages.x86_64-linux.radiantwavekiosk-commercial = self.nixosConfigurations.radiantwavekiosk-commercial.config.system.build.toplevel;