From 8796feddd95f444a1a36ee3d74564ba50b386691 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sat, 3 Oct 2026 11:59:29 -0500 Subject: [PATCH 01/10] Declare app version per channel and fix Android live updates - Add channel.appVersion and an appPackage override so pure builds bake the pinned version instead of RadiantWave's 0.1.0 default. - Pin the radiantwave input to tag 1.8 and allow unfree in the RPi builder to match x86. - radiantwaveUpdaterModule gains a manifestFile arg; Raspberry Pi polls aarch64-manifest.json. - Launch the rotated/Android kiosk through /var/lib/radiantwave/current so the live updater actually applies. --- AGENTS.md | 21 +++++++++------- configuration.nix | 4 +-- flake.lock | 12 ++++----- flake.nix | 61 +++++++++++++++++++++++++++++++++------------ rotated_display.nix | 13 +++++++--- 5 files changed, 75 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 589965f..691c991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,12 @@ Hosted on Gitea: `https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig` Two branches — `main` (prod) and `dev`. There are **no `-dev` configs**: each hardware target has exactly one config, and the branch selects its channel via the single `channel` block at the top of `flake.nix`: -- `main`: `channel.appInput = radiantwave` (main), `atticRepo = "radiantwave"`, `gitRef = ""` (default branch). -- `dev`: `channel.appInput = radiantwave-dev`, `atticRepo = "radiantwave-dev"`, `gitRef = "?ref=dev"`. +- `main`: `channel.appInput = radiantwave` (pinned to tag `1.8`), `atticRepo = "radiantwave"`, `gitRef = ""` (default branch), `appVersion = "1.8"`. +- `dev`: `channel.appInput = radiantwave-dev`, `atticRepo = "radiantwave-dev"`, `gitRef = "?ref=dev"`, `appVersion = `. -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 three `channel` values. When merging, reconcile just this one block. +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`). 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. @@ -42,10 +44,10 @@ One config per hardware target. Dev/prod is the branch, not the config — see ` | Name | Target hardware | Disk device | App input | |---|---|---|---| -| `radiantwavekiosk` | Intel NUC 14 (NVMe) | `/dev/nvme0n1` | `radiantwave` (main) | -| `radiantwavekiosk-commercial` | Commercial HW (SATA) | `/dev/sda` | `radiantwave` (main) | -| `radiantwavekiosk-android` | Intel NUC 14, rotated display | `/dev/nvme0n1` | `radiantwave` (main) | -| `radiantwavekiosk-rpi` | Raspberry Pi 5 (SD image) | — | `radiantwave` (main) | +| `radiantwavekiosk` | Intel NUC 14 (NVMe) | `/dev/nvme0n1` | `radiantwave` (tag 1.8) | +| `radiantwavekiosk-commercial` | Commercial HW (SATA) | `/dev/sda` | `radiantwave` (tag 1.8) | +| `radiantwavekiosk-android` | Intel NUC 14, rotated display | `/dev/nvme0n1` | `radiantwave` (tag 1.8) | +| `radiantwavekiosk-rpi` | Raspberry Pi 5 (SD image) | — | `radiantwave` (tag 1.8) | On the `dev` branch, the same four configs use `radiantwave-dev` and track `?ref=dev`. @@ -92,12 +94,13 @@ Regenerate when major hardware changes, not on a schedule — reports are snapsh - **Disk device differs by variant**: `disk-config.nix` uses `/dev/nvme0n1`, `commercial-disk-config.nix` uses `/dev/sda`. `mkIso` pairs each ISO with the correct layout automatically — keep new ISOs paired correctly. - **Changing a config name** requires updating that config's `upgradeFlakeRef` in `flake.nix` (it embeds the name). -- **The `channel` block is the dev/prod toggle** in `flake.nix` — `appInput`, `atticRepo`, `gitRef`. When merging `dev`→`main`, keep this block's values for the target branch; only this block differs between branches. +- **The `channel` block is the dev/prod toggle** in `flake.nix` — `appInput`, `atticRepo`, `gitRef`, `appVersion`. When merging `dev`→`main`, keep this block's values for the target branch; only this block differs between branches. - **`rotated_display.nix` uses `mkForce`** on `loginShellInit` — it replaces the entire kiosk startup script from `configuration.nix`; don't edit both. - **Auto-update**: `system.autoUpgrade` runs every 10 min (`*:00/10`) with `--refresh`, operation `switch`, no auto-reboot. A `radiantwave-backup` rsync runs before each attempt. The health-check/rollback service is written but commented out in `configuration.nix`. +- **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` tracks main, `radiantwave-dev` tracks `?ref=dev`. The `channel` block picks which one the configs consume. Updating them independently is intentional. +- **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. - **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/configuration.nix b/configuration.nix index ca58231..58678a8 100644 --- a/configuration.nix +++ b/configuration.nix @@ -1,7 +1,7 @@ -{ config, pkgs, radiantwave, upgradeFlakeRef, radiantwavePlymouth, ... }: +{ config, pkgs, radiantwaveApp, upgradeFlakeRef, radiantwavePlymouth, ... }: let - radiantApp = radiantwave.packages.${pkgs.system}.default; + radiantApp = radiantwaveApp; # When the live-updater module is enabled, RadiantWave launches through the # mutable /var/lib/radiantwave/current # pointer so it can be swapped without a full nixos-rebuild. Otherwise it diff --git a/flake.lock b/flake.lock index 8cbaeca..316d65b 100644 --- a/flake.lock +++ b/flake.lock @@ -220,16 +220,16 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1787675420, - "narHash": "sha256-2Hq9IcgdDXQc2uQ4yC/5NZVjcZaFCcVS78vj+ee+y/Q=", - "ref": "main", - "rev": "43cd93bbd7dde69c55c00a28c1691e1a192eda39", - "revCount": 47, + "lastModified": 1790803867, + "narHash": "sha256-HRGQQlmn/kCsZIq1wBMFaW6q1xfSvwPhUS5CpG1mm+Q=", + "ref": "refs/tags/1.8", + "rev": "34c72b51ddc3da8d46d994e787ffd1f8e8dda77c", + "revCount": 85, "type": "git", "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" }, "original": { - "ref": "main", + "ref": "refs/tags/1.8", "type": "git", "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" } diff --git a/flake.nix b/flake.nix index 72b05d3..0607e82 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ disko.url = "github:nix-community/disko"; disko.inputs.nixpkgs.follows = "nixpkgs"; nixos-facter-modules.url = "github:numtide/nixos-facter-modules"; - radiantwave.url = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git?ref=main"; + radiantwave.url = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git?ref=refs/tags/1.8"; radiantwave-dev.url = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git?ref=dev"; nixos-raspberrypi.url = "github:nvmd/nixos-raspberrypi/main"; }; @@ -62,28 +62,49 @@ # this channel, so a `dev`-to-`main` merge carries your changes with it # automatically. There are no more `-dev` sibling configs to copy. # - # main: appInput = radiantwave (main), atticRepo = "radiantwave", - # gitRef = "" (default branch) - # dev: appInput = radiantwave-dev, atticRepo = "radiantwave-dev", - # gitRef = "?ref=dev" + # main: appInput = radiantwave (tag 1.8), atticRepo = "radiantwave", + # gitRef = "", appVersion = "1.8" + # dev: appInput = radiantwave-dev, atticRepo = "radiantwave-dev", + # gitRef = "?ref=dev", appVersion = # - # To flip branches, change ONLY these three values (then push/merge). + # To flip branches, change ONLY these four values (then push/merge). channel = { appInput = radiantwave-dev; # dev atticRepo = "radiantwave-dev"; # dev gitRef = "?ref=dev"; # dev + appVersion = "2917d33"; # dev + }; + + # Exposed so CI (see .gitea/workflows/build-prod-*.yml) and humans can + # verify which channel this checkout resolves to. On `main` this must be + # "radiantwave"; on `dev` it must be "radiantwave-dev". The prod + # workflows fail fast if this isn't the production channel, so a merge + # that brings a dev-pointing channel block into `main` breaks the build + # instead of silently shipping dev builds as prod ISOs. + channelName = if channel.appInput == radiantwave then "radiantwave" else "radiantwave-dev"; + + # 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 + # to its 0.1.0 default. Overriding the version here makes the baked + # baseline correct regardless of eval purity, and matches the Attic + # closure when RadiantWave CI built it with the same version string. + appPackage = system: channel.appInput.packages.${system}.default.override { + version = channel.appVersion; }; # Live app-update side channel. The manifest is served by the # fileserver; closures are fetched (never built) from the channel's # Attic cache. `repo` selects the channel — "radiantwave" (main) or - # "radiantwave-dev" (dev). All variants follow this same update - # procedure; only the channel differs. - radiantwaveUpdaterModule = { repo, baseline }: { + # "radiantwave-dev" (dev) — and `manifestFile` selects the per-arch + # manifest (default "manifest.json"; Raspberry Pi uses + # "aarch64-manifest.json"). All variants follow this same update + # procedure; only the channel/arch differ. + radiantwaveUpdaterModule = { repo, baseline, manifestFile ? "manifest.json" }: { services.radiantwaveUpdater = { enable = true; baselinePackage = baseline; - manifestUrl = "https://files.radiantwavetech.com/${repo}/manifest.json"; + manifestUrl = "https://files.radiantwavetech.com/${repo}/${manifestFile}"; atticUrl = "https://attic.radiantwavetech.com/${repo}"; }; }; @@ -100,8 +121,8 @@ inherit system; specialArgs = { - inherit radiantwave; - inherit upgradeFlakeRef; + inherit radiantwave upgradeFlakeRef; + radiantwaveApp = appPackage system; radiantwavePlymouth = mkRadiantwavePlymouth; }; @@ -137,10 +158,12 @@ specialArgs = { inherit radiantwave upgradeFlakeRef; inherit nixos-raspberrypi; + radiantwaveApp = appPackage "aarch64-linux"; radiantwavePlymouth = mkRadiantwavePlymouth; }; modules = [ + { nixpkgs.config.allowUnfree = true; } ({ ... }: { imports = with nixos-raspberrypi.nixosModules; [ sd-image @@ -201,6 +224,8 @@ }; in { + inherit channelName; + 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; packages.x86_64-linux.radiantwavekiosk-android = self.nixosConfigurations.radiantwavekiosk-android.config.system.build.toplevel; @@ -222,7 +247,7 @@ diskModule = ./disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.x86_64-linux.default; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = appPackage "x86_64-linux"; }) ]; }; radiantwavekiosk-commercial = mkKioskSystem { @@ -232,7 +257,7 @@ diskModule = ./commercial-disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-commercial"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.x86_64-linux.default; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = appPackage "x86_64-linux"; }) ]; }; # Rotated screen for android-style displays. @@ -244,7 +269,7 @@ upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-android"; extraModules = [ ./rotated_display.nix - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.x86_64-linux.default; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = appPackage "x86_64-linux"; }) ]; }; # Raspberry Pi 5. @@ -253,7 +278,11 @@ hostName = "radiantwavekiosk-rpi"; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-rpi"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.aarch64-linux.default; }) + (radiantwaveUpdaterModule { + repo = channel.atticRepo; + baseline = appPackage "aarch64-linux"; + manifestFile = "aarch64-manifest.json"; + }) ]; }; diff --git a/rotated_display.nix b/rotated_display.nix index b9c696f..65af47c 100644 --- a/rotated_display.nix +++ b/rotated_display.nix @@ -1,6 +1,13 @@ -{ pkgs, radiantwave, ...}: +{ config, pkgs, radiantwaveApp, ...}: let - radiantApp = radiantwave.packages.${pkgs.system}.default; + radiantApp = radiantwaveApp; + # Match configuration.nix: when the live updater is enabled, launch through + # the mutable /var/lib/radiantwave/current pointer so Android kiosks receive + # app updates instead of always running the baked baseline. + launchBin = + if config.services.radiantwaveUpdater.enable or false + then "/var/lib/radiantwave/current/bin/radiantwave" + else "${radiantApp}/bin/radiantwave"; kioskscript = pkgs.writeShellScript "kiosk-start" '' # Wait for the Wayland display to be ready for i in {1..10}; do @@ -13,7 +20,7 @@ let OUTPUT=$(${pkgs.wlr-randr}/bin/wlr-randr | grep -m1 '^\S' | awk '{print $1}') echo "Rotating output: $OUTPUT" >> /tmp/kiosk.log ${pkgs.wlr-randr}/bin/wlr-randr --output "$OUTPUT" --transform 90 >> /tmp/kiosk.log 2>&1 || echo "wlr-randr failed" >> /tmp/kiosk.log - exec ${radiantApp}/bin/radiantwave -d -s + exec ${launchBin} -d -s ''; in { From e50391ba9eb572cbfcaac9061099249841c1430c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sun, 4 Oct 2026 18:04:35 -0500 Subject: [PATCH 02/10] ci: unify all kiosk build jobs into one branch-aware workflow Single .gitea/workflows/build.yml shared by both branches; the checked-out ref selects the channel, so no per-branch copies. - dev push -> builds all development images (kiosk, commercial, android, rpi) - new tag -> builds all production images; manual dispatch also supported - Raspberry Pi sdImage built on orion-lxc via aarch64 emulation - production-only baseline preflight (skipped on the dev channel) - artifact names standardized to radiantwave--- --- .gitea/workflows/build-dev.yml | 91 ------------- .gitea/workflows/build-prod-android.yml | 42 ------ .gitea/workflows/build-prod-commercial.yml | 42 ------ .gitea/workflows/build-prod-kiosk.yml | 42 ------ .gitea/workflows/build-prod-rpi.yml | 39 ------ .gitea/workflows/build.yml | 150 +++++++++++++++++++++ 6 files changed, 150 insertions(+), 256 deletions(-) delete mode 100644 .gitea/workflows/build-dev.yml delete mode 100644 .gitea/workflows/build-prod-android.yml delete mode 100644 .gitea/workflows/build-prod-commercial.yml delete mode 100644 .gitea/workflows/build-prod-kiosk.yml delete mode 100644 .gitea/workflows/build-prod-rpi.yml create mode 100644 .gitea/workflows/build.yml diff --git a/.gitea/workflows/build-dev.yml b/.gitea/workflows/build-dev.yml deleted file mode 100644 index 80337a3..0000000 --- a/.gitea/workflows/build-dev.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Build Dev ISOs -on: - workflow_dispatch: - push: - branches: - - dev -env: - NIX_CONFIG: | - extra-substituters = https://attic.radiantwavetech.com/radiantwave-dev - extra-trusted-public-keys = radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= -jobs: - build-dev-kiosk: - runs-on: orion-lxc - steps: - - name: Clone Repository - run: git clone --depth 1 --no-single-branch ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - name: Checkout dev branch - run: git checkout dev - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - name: Build dev kiosk ISO - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.kiosk-iso.config.system.build.isoImage - - name: Rename dev kiosk ISO - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-kiosk-dev-${ISO_DATE}.iso" - cp -L result/iso/*.iso "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - name: Copy dev kiosk ISO to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - name: Cleanup - run: rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" - - build-dev-android: - runs-on: orion-lxc - steps: - - name: Clone Repository - run: git clone --depth 1 --no-single-branch ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - name: Checkout dev branch - run: git checkout dev - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - name: Build dev android ISO - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.android-iso.config.system.build.isoImage - - name: Rename dev android ISO - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-android-dev-${ISO_DATE}.iso" - cp -L result/iso/*.iso "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - name: Copy dev android ISO to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - name: Cleanup - run: rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" - - build-dev-commercial: - runs-on: orion-lxc - steps: - - name: Clone Repository - run: git clone --depth 1 --no-single-branch ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - name: Checkout dev branch - run: git checkout dev - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - name: Build dev commercial ISO - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.commercial-iso.config.system.build.isoImage - - name: Rename dev commercial ISO - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-commercial-dev-${ISO_DATE}.iso" - cp -L result/iso/*.iso "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - name: Copy dev commercial ISO to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - name: Cleanup - run: rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" diff --git a/.gitea/workflows/build-prod-android.yml b/.gitea/workflows/build-prod-android.yml deleted file mode 100644 index ad6a3fd..0000000 --- a/.gitea/workflows/build-prod-android.yml +++ /dev/null @@ -1,42 +0,0 @@ -on: - workflow_dispatch: - push: - branches: - - main - -env: - NIX_CONFIG: | - extra-substituters = https://attic.radiantwavetech.com/radiantwave - extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= - -jobs: - build-android: - runs-on: orion-lxc - - steps: - - name: Checkout - run: git clone --depth 1 ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - - name: Build android kiosk ISO - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.android-iso.config.system.build.isoImage - - - name: Rename android kiosk ISO - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-android-iso-${ISO_DATE}.iso" - cp -L result/iso/*.iso "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "ANDROID_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - - name: Copy android kiosk ISO to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${ANDROID_ISO_FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - - name: Cleanup android kiosk ISO - run: rm -f "/var/lib/gitea-runner/radiantwave/${ANDROID_ISO_FILENAME}" diff --git a/.gitea/workflows/build-prod-commercial.yml b/.gitea/workflows/build-prod-commercial.yml deleted file mode 100644 index 9c16fb6..0000000 --- a/.gitea/workflows/build-prod-commercial.yml +++ /dev/null @@ -1,42 +0,0 @@ -on: - workflow_dispatch: - push: - branches: - - main - -env: - NIX_CONFIG: | - extra-substituters = https://attic.radiantwavetech.com/radiantwave - extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= - -jobs: - build-commercial: - runs-on: orion-lxc - - steps: - - name: Checkout - run: git clone --depth 1 ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - - name: Build commercial kiosk ISO - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.commercial-iso.config.system.build.isoImage - - - name: Rename commercial kiosk ISO - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-commercial-iso-${ISO_DATE}.iso" - cp -L result/iso/*.iso "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "COMMERCIAL_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - - name: Copy commercial kiosk ISO to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${COMMERCIAL_ISO_FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - - name: Cleanup commercial kiosk ISO - run: rm -f "/var/lib/gitea-runner/radiantwave/${COMMERCIAL_ISO_FILENAME}" diff --git a/.gitea/workflows/build-prod-kiosk.yml b/.gitea/workflows/build-prod-kiosk.yml deleted file mode 100644 index f2df5a2..0000000 --- a/.gitea/workflows/build-prod-kiosk.yml +++ /dev/null @@ -1,42 +0,0 @@ -on: - workflow_dispatch: - push: - branches: - - main - -env: - NIX_CONFIG: | - extra-substituters = https://attic.radiantwavetech.com/radiantwave - extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= - -jobs: - build-kiosk: - runs-on: orion-lxc - - steps: - - name: Checkout - run: git clone --depth 1 ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - - name: Build kiosk ISO - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.kiosk-iso.config.system.build.isoImage - - - name: Rename kiosk ISO - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-home-${ISO_DATE}.iso" - cp -L result/iso/*.iso "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "KIOSK_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - - name: Copy kiosk ISO to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${KIOSK_ISO_FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - - name: Cleanup kiosk ISO - run: rm -f "/var/lib/gitea-runner/radiantwave/${KIOSK_ISO_FILENAME}" diff --git a/.gitea/workflows/build-prod-rpi.yml b/.gitea/workflows/build-prod-rpi.yml deleted file mode 100644 index 5ca3a89..0000000 --- a/.gitea/workflows/build-prod-rpi.yml +++ /dev/null @@ -1,39 +0,0 @@ -on: - workflow_dispatch: - -env: - NIX_CONFIG: | - extra-substituters = https://attic.radiantwavetech.com/radiantwave https://nixos-raspberrypi.cachix.org - extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= - -jobs: - build-rpi: - runs-on: orion-lxc - - steps: - - name: Checkout - run: git clone --depth 1 ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - - name: Prepare output directory - run: mkdir -p /var/lib/gitea-runner/radiantwave - - - name: Build Raspberry Pi image - run: | - export NIXPKGS_ALLOW_UNFREE=1 - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.radiantwavekiosk-rpi.config.system.build.sdImage - - - name: Rename Raspberry Pi image - run: | - ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-rpi-${ISO_DATE}.img.zst" - cp -L result/sd-image/*.img.zst "/var/lib/gitea-runner/radiantwave/${FILENAME}" - echo "RPI_IMAGE_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - - name: Copy Raspberry Pi image to fileserver - run: | - scp "/var/lib/gitea-runner/radiantwave/${RPI_IMAGE_FILENAME}" \ - 157.245.124.103:/mnt/infrastructure_data/files/images/ - - - name: Cleanup Raspberry Pi image - run: rm -f "/var/lib/gitea-runner/radiantwave/${RPI_IMAGE_FILENAME}" diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..b4550b0 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,150 @@ +# One build workflow for every branch — do not add per-branch copies. +# +# The branch/ref selects the channel, nothing else does: the flake's `channel` +# block picks the app input, Attic repo, and upgrade ref, so the same config +# names build dev images on `dev` and prod images on `main`. This file itself +# is identical on both branches. +# +# Trigger model: +# * push to `dev` -> build every development image +# * new git tag -> build every production image (tags live on main) +# * manual dispatch -> build the channel of the selected branch +# +# Hardware variants come from an include-matrix: +# ISO variants -> .config.system.build.isoImage (storeContents pulls in the closure) +# Raspberry Pi -> .config.system.build.sdImage +# `orion-lxc` emulates aarch64-linux, so the Pi build runs alongside the rest. +name: Build Kiosk Images + +on: + workflow_dispatch: + push: + branches: + - dev + tags: + - "*" + +env: + # Both channel caches plus the Raspberry Pi cachix, so a build can substitute + # from whichever channel the checked-out ref selects. + NIX_CONFIG: | + extra-substituters = https://attic.radiantwavetech.com/radiantwave https://attic.radiantwavetech.com/radiantwave-dev https://nixos-raspberrypi.cachix.org + extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= + +jobs: + build: + name: ${{ matrix.label }} + runs-on: orion-lxc + strategy: + fail-fast: false + matrix: + include: + - label: kiosk + system: kiosk-iso + target: isoImage + outdir: iso + ext: iso + arch: x86_64-linux + manifest: manifest.json + - label: commercial + system: commercial-iso + target: isoImage + outdir: iso + ext: iso + arch: x86_64-linux + manifest: manifest.json + - label: android + system: android-iso + target: isoImage + outdir: iso + ext: iso + arch: x86_64-linux + manifest: manifest.json + - label: rpi + system: radiantwavekiosk-rpi + target: sdImage + outdir: sd-image + ext: img.zst + arch: aarch64-linux + manifest: aarch64-manifest.json + + steps: + - name: Clone repository + run: | + rm -rf ./* ./.[!.]* 2>/dev/null || true + ref="${GITHUB_REF#refs/heads/}" + ref="${ref#refs/tags/}" + git clone --depth 1 --branch "$ref" \ + ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . + git log --oneline -1 + + - name: Determine channel + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + CHANNEL=$(nix eval --impure --raw .#channelName) + if [ "$CHANNEL" = "radiantwave" ]; then LABEL=prod; else LABEL=dev; fi + echo "CHANNEL=$CHANNEL" >> "$GITHUB_ENV" + echo "CHANNEL_LABEL=$LABEL" >> "$GITHUB_ENV" + echo "Building the '$CHANNEL' channel ($LABEL)" + + # Production only: fail fast unless the baked closure is exactly what + # RadiantWave CI published to Attic. The dev channel churns on every + # dispatch, so this is skipped there. + - name: Verify baked baseline matches the published channel + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + if [ "$CHANNEL" != "radiantwave" ]; then + echo "channel '$CHANNEL' is not production; skipping baseline preflight" + exit 0 + fi + + VERSION=$(nix eval --impure --raw .#channelVersion) + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/${{ matrix.manifest }}" + BASELINE=$(nix eval --impure --raw .#baselineOutPaths.${{ matrix.arch }}) + + 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'." + 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 released commit." + 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 + + - name: Build ${{ matrix.label }} + run: | + export NIXPKGS_ALLOW_UNFREE=1 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + nix build -L --impure \ + ".#nixosConfigurations.${{ matrix.system }}.config.system.build.${{ matrix.target }}" + + - name: Publish ${{ matrix.label }} + run: | + ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) + FILENAME="radiantwave-${{ matrix.label }}-${CHANNEL_LABEL}-${ISO_DATE}.${{ matrix.ext }}" + cp -L result/${{ matrix.outdir }}/*.${{ matrix.ext }} \ + "/var/lib/gitea-runner/radiantwave/${FILENAME}" + scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ + 157.245.124.103:/mnt/infrastructure_data/files/images/ + rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" From 1e996d4c05d8b6ac9885111cd07fcff0b11615aa Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sun, 4 Oct 2026 18:12:47 -0500 Subject: [PATCH 03/10] Add tailscale --- configuration.nix | 80 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/configuration.nix b/configuration.nix index 58678a8..d429dd8 100644 --- a/configuration.nix +++ b/configuration.nix @@ -11,6 +11,9 @@ let then "/var/lib/radiantwave/current/bin/radiantwave" else "/run/current-system/sw/bin/radiantwave"; myKey = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4qWNZnYU5JaqR9c2qh6oFZktzVNBOx42PfDaGofCL6 cnclack@pm.me macbook" ]; + + # Headscale preauth key. Paste/replace here. + headscaleAuthKey = "hskey-auth-zZdWiKTwDfTT-tgRKiAHyXabykoARDDyNfYihgVrJQ6y6zK7lwV0FW6xwHTdyWoqa_ZpCf80vgAJC"; in { ############################################################ @@ -85,6 +88,81 @@ in networking.networkmanager.enable = true; + ############################################################ + # Tailscale (self-hosted Headscale control plane) + ############################################################ + # The node joins the RadiantWave Headscale server at + # hs.radiantwavetech.com using the *hardware serial* as its Tailscale + # node name (not networking.hostName), so a kiosk is always reachable at + # even though every variant shares one hostName per branch. + # + # The preauth key is set in `headscaleAuthKey` in the let block at the + # top of this file. NOTE: it ends up in the world-readable /nix/store, so + # keep this file out of public repos while a live key is in it. + services.tailscale = { + enable = true; + useRoutingFeatures = "client"; + }; + + # Reach the kiosk's services (SSH, etc.) over the tailnet without extra + # per-port rules. Membership is gated by the preauth key. + networking.firewall.trustedInterfaces = [ "tailscale0" ]; + + systemd.services.radiantwave-tailscale-auth = { + description = "Join the RadiantWave tailnet using the hardware serial as node name"; + after = [ "tailscaled.service" "network-online.target" ]; + wants = [ "tailscaled.service" "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.tailscale pkgs.coreutils pkgs.gnused pkgs.gnugrep ]; + script = '' + set -eu + + # Hardware serial. x86 exposes it via DMI; Raspberry Pi exposes it in + # the device tree (null-terminated), mirrored under /proc/device-tree. + serial="" + for f in \ + /sys/class/dmi/id/product_serial \ + /sys/class/dmi/id/board_serial \ + /sys/firmware/devicetree/base/serial-number \ + /proc/device-tree/serial-number + do + if [ -r "$f" ]; then + serial="$(tr -d '\000' < "$f" | tr -d '\n\r')" + [ -n "$serial" ] && break + fi + done + + # Tailscale node names are RFC 1035 labels: lowercase, [a-z0-9-], <=63. + serial="$(printf '%s' "$serial" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' \ + | sed -e 's/-\{1,\}/-/g' -e 's/^-//' -e 's/-$//' \ + | cut -c1-63)" + [ -n "$serial" ] || serial="radiantwave-unknown" + + echo "radiantwave-tailscale-auth: joining as '$serial'" + for _ in $(seq 1 30); do + if tailscale up \ + --auth-key=${pkgs.lib.escapeShellArg headscaleAuthKey} \ + --login-server=https://hs.radiantwavetech.com \ + --hostname="$serial" \ + --accept-dns=false + then + echo "radiantwave-tailscale-auth: connected" + exit 0 + fi + sleep 2 + done + + echo "radiantwave-tailscale-auth: could not join tailnet after retries" >&2 + exit 1 + ''; + }; + system.autoUpgrade = { enable = true; flake = upgradeFlakeRef; @@ -263,4 +341,4 @@ in echo "Backup completed at $(date)" > "$BACKUP_DIR/.backup-timestamp" ''; }; -} +} \ No newline at end of file From 44d402b1792879fba3fe13122372fbe4ca474411 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sun, 4 Oct 2026 18:16:12 -0500 Subject: [PATCH 04/10] ci: define kiosk last so it builds first Gitea schedules matrix jobs in reverse definition order on the single orion-lxc runner, so kiosk (previously defined first) built last. Reverse the include list so kiosk builds first; order is rpi, android, commercial, kiosk in the file. --- .gitea/workflows/build.yml | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b4550b0..d6ac0e8 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -39,8 +39,18 @@ jobs: fail-fast: false matrix: include: - - label: kiosk - system: kiosk-iso + # NOTE: Gitea schedules matrix jobs in reverse definition order on + # the single orion-lxc runner, so the list is reversed on purpose: + # rpi -> android -> commercial -> kiosk means kiosk builds FIRST. + - label: rpi + system: radiantwavekiosk-rpi + target: sdImage + outdir: sd-image + ext: img.zst + arch: aarch64-linux + manifest: aarch64-manifest.json + - label: android + system: android-iso target: isoImage outdir: iso ext: iso @@ -53,20 +63,13 @@ jobs: ext: iso arch: x86_64-linux manifest: manifest.json - - label: android - system: android-iso + - label: kiosk + system: kiosk-iso target: isoImage outdir: iso ext: iso arch: x86_64-linux manifest: manifest.json - - label: rpi - system: radiantwavekiosk-rpi - target: sdImage - outdir: sd-image - ext: img.zst - arch: aarch64-linux - manifest: aarch64-manifest.json steps: - name: Clone repository From eafa4b6e264eeef2c703a2cfe8a57a89f88e9c6f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sun, 4 Oct 2026 18:34:31 -0500 Subject: [PATCH 05/10] Split raspberry pi build into a separate workflow --- .gitea/workflows/build-rpi.yml | 128 +++++++++++++++++++++++++++++++++ .gitea/workflows/build.yml | 23 ++---- 2 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 .gitea/workflows/build-rpi.yml diff --git a/.gitea/workflows/build-rpi.yml b/.gitea/workflows/build-rpi.yml new file mode 100644 index 0000000..61c5c7d --- /dev/null +++ b/.gitea/workflows/build-rpi.yml @@ -0,0 +1,128 @@ +# One build workflow per hardware family, identical on every branch — do not +# add per-branch copies. build-x86.yaml is the same file with a different matrix. +# +# The branch/ref selects the channel, nothing else does: the flake's `channel` +# block picks the app input, Attic repo, and upgrade ref, so the same config +# names build dev images on `dev` and prod images on `main`. +# +# Trigger model: +# * push to `dev` -> build every development image +# * new git tag -> build every production image (tags live on main) +# * manual dispatch -> build the channel of the selected branch +# +# This file builds the Raspberry Pi variant: +# Raspberry Pi -> .config.system.build.sdImage +# `orion-lxc` emulates aarch64-linux, so this runs on the same runner. +name: Build Kiosk Image (Raspberry Pi) + +on: + workflow_dispatch: + push: + branches: + - dev + tags: + - "*" + +env: + # Both channel caches plus the Raspberry Pi cachix, so a build can substitute + # from whichever channel the checked-out ref selects. + NIX_CONFIG: | + extra-substituters = https://attic.radiantwavetech.com/radiantwave https://attic.radiantwavetech.com/radiantwave-dev https://nixos-raspberrypi.cachix.org + extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= + +jobs: + build: + name: ${{ matrix.label }} + runs-on: orion-lxc + strategy: + fail-fast: false + matrix: + include: + - label: rpi + system: radiantwavekiosk-rpi + target: sdImage + outdir: sd-image + ext: img.zst + arch: aarch64-linux + manifest: aarch64-manifest.json + + steps: + - name: Clone repository + run: | + rm -rf ./* ./.[!.]* 2>/dev/null || true + ref="${GITHUB_REF#refs/heads/}" + ref="${ref#refs/tags/}" + git clone --depth 1 --branch "$ref" \ + ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . + git log --oneline -1 + + - name: Determine channel + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + CHANNEL=$(nix eval --impure --raw .#channelName) + if [ "$CHANNEL" = "radiantwave" ]; then LABEL=prod; else LABEL=dev; fi + echo "CHANNEL=$CHANNEL" >> "$GITHUB_ENV" + echo "CHANNEL_LABEL=$LABEL" >> "$GITHUB_ENV" + echo "Building the '$CHANNEL' channel ($LABEL)" + + # Production only: fail fast unless the baked closure is exactly what + # RadiantWave CI published to Attic. The dev channel churns on every + # dispatch, so this is skipped there. + - name: Verify baked baseline matches the published channel + run: | + set -euo pipefail + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + if [ "$CHANNEL" != "radiantwave" ]; then + echo "channel '$CHANNEL' is not production; skipping baseline preflight" + exit 0 + fi + + VERSION=$(nix eval --impure --raw .#channelVersion) + ATTIC=$(nix eval --impure --raw .#channelAtticUrl) + MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/${{ matrix.manifest }}" + BASELINE=$(nix eval --impure --raw .#baselineOutPaths.${{ matrix.arch }}) + + 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'." + 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 released commit." + 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 + + - name: Build ${{ matrix.label }} + run: | + export NIXPKGS_ALLOW_UNFREE=1 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + nix build -L --impure \ + ".#nixosConfigurations.${{ matrix.system }}.config.system.build.${{ matrix.target }}" + + - name: Publish ${{ matrix.label }} + run: | + ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) + FILENAME="radiantwave-${{ matrix.label }}-${CHANNEL_LABEL}-${ISO_DATE}.${{ matrix.ext }}" + cp -L result/${{ matrix.outdir }}/*.${{ matrix.ext }} \ + "/var/lib/gitea-runner/radiantwave/${FILENAME}" + scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ + 157.245.124.103:/mnt/infrastructure_data/files/images/ + rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ No newline at end of file diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index d6ac0e8..a6099bf 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,20 +1,18 @@ -# One build workflow for every branch — do not add per-branch copies. +# One build workflow per hardware family, identical on every branch — do not +# add per-branch copies. build-rpi.yaml is the same file with a different matrix. # # The branch/ref selects the channel, nothing else does: the flake's `channel` # block picks the app input, Attic repo, and upgrade ref, so the same config -# names build dev images on `dev` and prod images on `main`. This file itself -# is identical on both branches. +# names build dev images on `dev` and prod images on `main`. # # Trigger model: # * push to `dev` -> build every development image # * new git tag -> build every production image (tags live on main) # * manual dispatch -> build the channel of the selected branch # -# Hardware variants come from an include-matrix: +# This file builds the x86_64 ISO variants: # ISO variants -> .config.system.build.isoImage (storeContents pulls in the closure) -# Raspberry Pi -> .config.system.build.sdImage -# `orion-lxc` emulates aarch64-linux, so the Pi build runs alongside the rest. -name: Build Kiosk Images +name: Build Kiosk Images (x86) on: workflow_dispatch: @@ -41,14 +39,7 @@ jobs: include: # NOTE: Gitea schedules matrix jobs in reverse definition order on # the single orion-lxc runner, so the list is reversed on purpose: - # rpi -> android -> commercial -> kiosk means kiosk builds FIRST. - - label: rpi - system: radiantwavekiosk-rpi - target: sdImage - outdir: sd-image - ext: img.zst - arch: aarch64-linux - manifest: aarch64-manifest.json + # android -> commercial -> kiosk means kiosk builds FIRST. - label: android system: android-iso target: isoImage @@ -150,4 +141,4 @@ jobs: "/var/lib/gitea-runner/radiantwave/${FILENAME}" scp "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ 157.245.124.103:/mnt/infrastructure_data/files/images/ - rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" + rm -f "/var/lib/gitea-runner/radiantwave/${FILENAME}" \ No newline at end of file From 31bc806d01d867c9d5e73be558b3a41cc226dd11 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sun, 4 Oct 2026 20:12:25 -0500 Subject: [PATCH 06/10] Update preauthkey and join script --- configuration.nix | 65 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/configuration.nix b/configuration.nix index d429dd8..62a9249 100644 --- a/configuration.nix +++ b/configuration.nix @@ -13,7 +13,16 @@ let myKey = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4qWNZnYU5JaqR9c2qh6oFZktzVNBOx42PfDaGofCL6 cnclack@pm.me macbook" ]; # Headscale preauth key. Paste/replace here. - headscaleAuthKey = "hskey-auth-zZdWiKTwDfTT-tgRKiAHyXabykoARDDyNfYihgVrJQ6y6zK7lwV0FW6xwHTdyWoqa_ZpCf80vgAJC"; + # + # This MUST be a *reusable* key with a long expiry (1 year is good), created + # with: + # headscale preauthkeys create --user --reusable --expiration 8760h + # + # A single-use key only ever enrolls one node, which would mean a fresh key + # plus a rebuild for every kiosk that joins the fleet. A reusable key lets any + # number of kiosks enroll unattended, and the timer below retries the join so + # a node that misses its window at boot heals on its own. + headscaleAuthKey = "hskey-auth-UfU_eS_sdOu6-CjrqrVdDJFRFsPnaTtK5sFuCiVlffJAS7uQ1wX1_65MNk205WGsF4clOiBD3ZOV-"; in { ############################################################ @@ -115,7 +124,9 @@ in wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; - RemainAfterExit = true; + # Deliberately no RemainAfterExit: the unit has to fall back to inactive + # so the timer below can trigger it again. The script is idempotent, so + # re-running it on an already-joined kiosk is a cheap no-op. }; path = [ pkgs.tailscale pkgs.coreutils pkgs.gnused pkgs.gnugrep ]; script = '' @@ -144,7 +155,17 @@ in | cut -c1-63)" [ -n "$serial" ] || serial="radiantwave-unknown" - echo "radiantwave-tailscale-auth: joining as '$serial'" + # Already joined? Bail out early. This is what makes the periodic + # re-invocation from the timer below cheap and safe to run forever. + # The JSON is pretty-printed with a space, so allow optional whitespace. + state="$(tailscale status --json 2>/dev/null \ + | sed -n 's/.*"BackendState": *"\([A-Za-z]*\)".*/\1/p')" + if [ "$state" = "Running" ]; then + echo "radiantwave-tailscale-auth: already joined as '$serial'" + exit 0 + fi + + echo "radiantwave-tailscale-auth: joining as '$serial' (state: ''${state:-unknown})" for _ in $(seq 1 30); do if tailscale up \ --auth-key=${pkgs.lib.escapeShellArg headscaleAuthKey} \ @@ -158,11 +179,45 @@ in sleep 2 done - echo "radiantwave-tailscale-auth: could not join tailnet after retries" >&2 - exit 1 + # Best-effort by design: do NOT exit non-zero here. + # + # This unit is part of the new generation that switch-to-configuration + # starts, and *any* failing unit among those aborts the switch with + # status 4/NOPERMISSION. While the preauth key was expired that is exactly + # what happened: this unit failed, which failed `switch-to-configuration`, + # which failed nixos-upgrade.service -- so the tailscale key silently + # stopped *all* system upgrades on the fleet every 10 minutes. + # + # The retry timer below is what actually recovers the join, so a failed + # attempt here must never take down the system switch with it. + echo "radiantwave-tailscale-auth: could not join tailnet after retries;" >&2 + echo "radiantwave-tailscale-auth: leaving the join to the retry timer" >&2 + exit 0 ''; }; + # The join only needs to happen once per node, but it fails for reasons + # outside our control: the network may not be usable yet at boot, tailscaled + # may be restarted later, or the preauth key may be rotated out from under + # the fleet. A timer re-runs the (idempotent) join so a kiosk recovers on its + # own instead of needing a manual `systemctl restart`. + # + # This replaces the old behavior where the one-shot unit failed once at boot + # and stayed failed forever (Restart=no, NRestarts=0). + systemd.timers.radiantwave-tailscale-auth = { + description = "Periodically ensure the kiosk is joined to the tailnet"; + wantedBy = [ "timers.target" ]; + timerConfig = { + # The join is attempted at boot via multi-user.target above; this is the + # first retry in case that one lost the race with the network. + OnBootSec = "5min"; + # Measured from when the unit last went inactive, so a slow join doesn't + # immediately retrigger. + OnUnitInactiveSec = "10min"; + Unit = "radiantwave-tailscale-auth.service"; + }; + }; + system.autoUpgrade = { enable = true; flake = upgradeFlakeRef; From 7aa88ee17438d7e2354bff81909f7c0d1b6fdb1f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Mon, 5 Oct 2026 21:50:15 -0500 Subject: [PATCH 07/10] Update radiantwave-updater script to auto-restart tty1 --- radiantwave-updater.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/radiantwave-updater.nix b/radiantwave-updater.nix index a020efb..4729744 100644 --- a/radiantwave-updater.nix +++ b/radiantwave-updater.nix @@ -81,6 +81,7 @@ in ${pkgs.coreutils}/bin/ln -sfn "$NEW_PATH" /var/lib/radiantwave/current.tmp ${pkgs.coreutils}/bin/mv -T /var/lib/radiantwave/current.tmp /var/lib/radiantwave/current echo "radiantwave-updater: swapped ''${CURRENT} -> ''${NEW_PATH}" + loginctl terminate-user kiosk ''; }; From a33a586485db5ea733084cbbd595c13ac000be97 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 7 Oct 2026 10:26:17 -0500 Subject: [PATCH 08/10] Bake the channel manifest's closure instead of a pinned app version Production builds failed CI's baseline preflight because flake.nix pinned the app to tag 1.8 (channel.appVersion = "1.8") while the published manifest had moved on to 1.9; dev was likewise pinned to a stale commit. Stop pinning the app anywhere. flake.nix now fetches the channel's published manifest at eval time (builtins.fetchurl) and bakes its storePath verbatim, so every image carries exactly the closure RadiantWave published to Attic and never compiles the app from source. - flake.nix: drop the radiantwave/radiantwave-dev inputs, appInput, appVersion, channelVersion and baselineOutPaths; add channelAtticUrl, channelManifestDir and publishedApp; builders consume the published path. - configuration.nix: wrap the published store path in a symlinkJoin so it can live in environment.systemPackages; add --impure to autoUpgrade flags and set tarball-ttl = 0 so eval reads the current manifest. - radiantwave-updater.nix: baselinePackage (package) -> baselinePath (str), seeded from the exact manifest path. - CI (build.yml, build-rpi.yml): replace the version-equality preflight with a fail-fast check that the announced closure is in the channel's Attic; set tarball-ttl = 0. - build-rpi.sh: pass --impure. - AGENTS.md: document the manifest-driven baseline. --- .gitea/workflows/build-rpi.yml | 50 +++++------- .gitea/workflows/build.yml | 50 +++++------- AGENTS.md | 48 +++++------ build-rpi.sh | 2 + configuration.nix | 19 ++++- flake.lock | 142 +-------------------------------- flake.nix | 103 ++++++++++++------------ radiantwave-updater.nix | 13 ++- 8 files changed, 140 insertions(+), 287 deletions(-) diff --git a/.gitea/workflows/build-rpi.yml b/.gitea/workflows/build-rpi.yml index 61c5c7d..928d767 100644 --- a/.gitea/workflows/build-rpi.yml +++ b/.gitea/workflows/build-rpi.yml @@ -2,8 +2,9 @@ # add per-branch copies. build-x86.yaml is the same file with a different matrix. # # The branch/ref selects the channel, nothing else does: the flake's `channel` -# block picks the app input, Attic repo, and upgrade ref, so the same config -# names build dev images on `dev` and prod images on `main`. +# block picks the channel manifest whose closure is baked as the app, the Attic +# repo, and the upgrade ref, so the same config names build dev images on `dev` +# and prod images on `main`. # # Trigger model: # * push to `dev` -> build every development image @@ -25,10 +26,13 @@ on: env: # Both channel caches plus the Raspberry Pi cachix, so a build can substitute - # from whichever channel the checked-out ref selects. + # from whichever channel the checked-out ref selects. tarball-ttl=0 keeps the + # eval-time manifest fetch (builtins.fetchurl in flake.nix) from reusing a + # stale copy cached by an earlier build on this persistent runner. NIX_CONFIG: | extra-substituters = https://attic.radiantwavetech.com/radiantwave https://attic.radiantwavetech.com/radiantwave-dev https://nixos-raspberrypi.cachix.org extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= + tarball-ttl = 0 jobs: build: @@ -65,45 +69,29 @@ jobs: echo "CHANNEL_LABEL=$LABEL" >> "$GITHUB_ENV" echo "Building the '$CHANNEL' channel ($LABEL)" - # Production only: fail fast unless the baked closure is exactly what - # RadiantWave CI published to Attic. The dev channel churns on every - # dispatch, so this is skipped there. - - name: Verify baked baseline matches the published channel + # Fail fast if the closure the channel manifest announces is not in the + # channel's Attic cache. We bake that exact storePath, so without it the + # image would try to compile RadiantWave from source (which never works: + # this repo has no app source). Runs for both channels; RadiantWave CI + # publishes the manifest only after the matching Attic push succeeds. + - name: Verify published app closure is in the channel cache run: | set -euo pipefail . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - if [ "$CHANNEL" != "radiantwave" ]; then - echo "channel '$CHANNEL' is not production; skipping baseline preflight" - exit 0 - fi - - VERSION=$(nix eval --impure --raw .#channelVersion) ATTIC=$(nix eval --impure --raw .#channelAtticUrl) MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/${{ matrix.manifest }}" - BASELINE=$(nix eval --impure --raw .#baselineOutPaths.${{ matrix.arch }}) - 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" + echo "channel : $CHANNEL" + echo "attic : $ATTIC" + echo "manifest: $MANIFEST_URL" + echo "closure : $PUB_PATH" - if [ "$VERSION" != "$PUB_VERSION" ]; then - echo "ERROR: channel.appVersion '$VERSION' != published manifest version '$PUB_VERSION'." - 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 released commit." - exit 1 - fi - HASH=$(basename "$BASELINE" | cut -d- -f1) + HASH=$(basename "$PUB_PATH" | 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." + echo "ERROR: $PUB_PATH is not in $ATTIC; the image would compile RadiantWave from source." exit 1 fi diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index a6099bf..7864ea6 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -2,8 +2,9 @@ # add per-branch copies. build-rpi.yaml is the same file with a different matrix. # # The branch/ref selects the channel, nothing else does: the flake's `channel` -# block picks the app input, Attic repo, and upgrade ref, so the same config -# names build dev images on `dev` and prod images on `main`. +# block picks the channel manifest whose closure is baked as the app, the Attic +# repo, and the upgrade ref, so the same config names build dev images on `dev` +# and prod images on `main`. # # Trigger model: # * push to `dev` -> build every development image @@ -24,10 +25,13 @@ on: env: # Both channel caches plus the Raspberry Pi cachix, so a build can substitute - # from whichever channel the checked-out ref selects. + # from whichever channel the checked-out ref selects. tarball-ttl=0 keeps the + # eval-time manifest fetch (builtins.fetchurl in flake.nix) from reusing a + # stale copy cached by an earlier build on this persistent runner. NIX_CONFIG: | extra-substituters = https://attic.radiantwavetech.com/radiantwave https://attic.radiantwavetech.com/radiantwave-dev https://nixos-raspberrypi.cachix.org extra-trusted-public-keys = radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= + tarball-ttl = 0 jobs: build: @@ -81,45 +85,29 @@ jobs: echo "CHANNEL_LABEL=$LABEL" >> "$GITHUB_ENV" echo "Building the '$CHANNEL' channel ($LABEL)" - # Production only: fail fast unless the baked closure is exactly what - # RadiantWave CI published to Attic. The dev channel churns on every - # dispatch, so this is skipped there. - - name: Verify baked baseline matches the published channel + # Fail fast if the closure the channel manifest announces is not in the + # channel's Attic cache. We bake that exact storePath, so without it the + # image would try to compile RadiantWave from source (which never works: + # this repo has no app source). Runs for both channels; RadiantWave CI + # publishes the manifest only after the matching Attic push succeeds. + - name: Verify published app closure is in the channel cache run: | set -euo pipefail . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - if [ "$CHANNEL" != "radiantwave" ]; then - echo "channel '$CHANNEL' is not production; skipping baseline preflight" - exit 0 - fi - - VERSION=$(nix eval --impure --raw .#channelVersion) ATTIC=$(nix eval --impure --raw .#channelAtticUrl) MANIFEST_URL="$(nix eval --impure --raw .#channelManifestDir)/${{ matrix.manifest }}" - BASELINE=$(nix eval --impure --raw .#baselineOutPaths.${{ matrix.arch }}) - 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" + echo "channel : $CHANNEL" + echo "attic : $ATTIC" + echo "manifest: $MANIFEST_URL" + echo "closure : $PUB_PATH" - if [ "$VERSION" != "$PUB_VERSION" ]; then - echo "ERROR: channel.appVersion '$VERSION' != published manifest version '$PUB_VERSION'." - 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 released commit." - exit 1 - fi - HASH=$(basename "$BASELINE" | cut -d- -f1) + HASH=$(basename "$PUB_PATH" | 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." + echo "ERROR: $PUB_PATH is not in $ATTIC; the image would compile RadiantWave from source." exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 691c991..14e68ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## What this repo is -NixOS flake that defines kiosk systems running the RadiantWave app. Not application code — this is the OS image and deployment configuration. The actual app lives in the `radiantwave` flake inputs (from gitea.radiantwavetech.com). +NixOS flake that defines kiosk systems running the RadiantWave app. Not application code — this is the OS image and deployment configuration. The app itself is not pinned or built here: each channel's **published manifest** (served from `files.radiantwavetech.com`, pushed by RadiantWave CI to Attic) is the single source of truth for the baked closure. Hosted on Gitea: `https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig` @@ -10,31 +10,33 @@ Hosted on Gitea: `https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig` Two branches — `main` (prod) and `dev`. There are **no `-dev` configs**: each hardware target has exactly one config, and the branch selects its channel via the single `channel` block at the top of `flake.nix`: -- `main`: `channel.appInput = radiantwave` (pinned to tag `1.8`), `atticRepo = "radiantwave"`, `gitRef = ""` (default branch), `appVersion = "1.8"`. -- `dev`: `channel.appInput = radiantwave-dev`, `atticRepo = "radiantwave-dev"`, `gitRef = "?ref=dev"`, `appVersion = `. +- `main`: `atticRepo = "radiantwave"`, `gitRef = ""` (default branch). +- `dev`: `atticRepo = "radiantwave-dev"`, `gitRef = "?ref=dev"`. -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. +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 two `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`). +**No app version is pinned anywhere.** At eval time `flake.nix` fetches the channel's published manifest (`https://files.radiantwavetech.com//`) with `builtins.fetchurl` and bakes its `storePath` verbatim as the app closure. Nix substitutes that exact closure from the channel's Attic cache, so images always match what RadiantWave published and never compile the app from source. Because that fetch is impure (no hash), **every eval that reaches the app needs `--impure`**: CI, local `build-rpi.sh`, and the kiosk's `system.autoUpgrade` (which passes `--impure`). `tarball-ttl = 0` is set in CI and on the kiosks so a rebuild never reuses a stale manifest. 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. ## Key commands ```bash -# Build a system closure (any config name from flake.nix) -nix build .#nixosConfigurations..config.system.build.toplevel +# Build a system closure (any config name from flake.nix). +# --impure is required: the app closure comes from the channel manifest, +# fetched at eval time (see the prod/dev split above). +nix build --impure .#nixosConfigurations..config.system.build.toplevel # Build a bootable ISO -nix build .#nixosConfigurations..config.system.build.isoImage +nix build --impure .#nixosConfigurations..config.system.build.isoImage # Deploy to a remote kiosk over SSH -nixos-rebuild switch --flake .# --target-host root@ +nixos-rebuild switch --impure --flake .# --target-host root@ # Push build result to Attic cache (not currently wired into any config) attic push kiosk ./result -# Evaluate available config names +# Evaluate available config names (pure-safe: does not force the app fetch) nix eval .#nixosConfigurations --apply builtins.attrNames ``` @@ -42,14 +44,14 @@ nix eval .#nixosConfigurations --apply builtins.attrNames One config per hardware target. Dev/prod is the branch, not the config — see `channel` in `flake.nix`: -| Name | Target hardware | Disk device | App input | +| Name | Target hardware | Disk device | App closure | |---|---|---|---| -| `radiantwavekiosk` | Intel NUC 14 (NVMe) | `/dev/nvme0n1` | `radiantwave` (tag 1.8) | -| `radiantwavekiosk-commercial` | Commercial HW (SATA) | `/dev/sda` | `radiantwave` (tag 1.8) | -| `radiantwavekiosk-android` | Intel NUC 14, rotated display | `/dev/nvme0n1` | `radiantwave` (tag 1.8) | -| `radiantwavekiosk-rpi` | Raspberry Pi 5 (SD image) | — | `radiantwave` (tag 1.8) | +| `radiantwavekiosk` | Intel NUC 14 (NVMe) | `/dev/nvme0n1` | channel manifest `manifest.json` | +| `radiantwavekiosk-commercial` | Commercial HW (SATA) | `/dev/sda` | channel manifest `manifest.json` | +| `radiantwavekiosk-android` | Intel NUC 14, rotated display | `/dev/nvme0n1` | channel manifest `manifest.json` | +| `radiantwavekiosk-rpi` | Raspberry Pi 5 (SD image) | — | channel manifest `aarch64-manifest.json` | -On the `dev` branch, the same four configs use `radiantwave-dev` and track `?ref=dev`. +On the `dev` branch, the same four configs read the `radiantwave-dev` manifests (`?ref=dev`). Every variant sets its own `networking.hostName` (same as the config name) and `system.configurationRevision`, so `tools/find_systems.zsh` can identify fleet members. @@ -63,12 +65,12 @@ Each ISO installs exactly one closure using that closure's disk layout (`mkIso` | `commercial-iso` | `radiantwavekiosk-commercial` | SATA (`commercial-disk-config.nix`) | | `android-iso` | `radiantwavekiosk-android` | NVMe | -These same names build **dev-flavored** images when the flake is checked out on the `dev` branch (the channel block selects the dev app, attic repo, and upgrade ref), so there are no separate `-dev` ISOs. +These same names build **dev-flavored** images when the flake is checked out on the `dev` branch (the channel block selects the dev manifest, attic repo, and upgrade ref), so there are no separate `-dev` ISOs. The installer lives once in `zero-touch-installer.nix` and is parameterized by `targetConfig`. Adding a new variant = add the `nixosConfigurations.*` entry, a matching `packages.x86_64-linux.*` line, and an ISO if needed. Raspberry Pi systems are built as SD images, not ISOs: -`nix build .#nixosConfigurations.radiantwavekiosk-rpi.config.system.build.sdImage` (see `build-rpi.sh`). +`nix build --impure .#nixosConfigurations.radiantwavekiosk-rpi.config.system.build.sdImage` (see `build-rpi.sh`). ## Hardware facter reports @@ -88,19 +90,19 @@ 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` and `build-rpi.yml` — triggered on pushes to `dev`, on new tags (production), and by `workflow_dispatch`, all on the `orion-lxc` runner. They build and publish exactly the closure the channel manifest announces, so a release is published by RadiantWave CI alone; there is no `flake.lock` bump to make here. Both workflows set `tarball-ttl = 0` and fail fast if the announced closure is not in the channel's Attic cache. ## Important gotchas - **Disk device differs by variant**: `disk-config.nix` uses `/dev/nvme0n1`, `commercial-disk-config.nix` uses `/dev/sda`. `mkIso` pairs each ISO with the correct layout automatically — keep new ISOs paired correctly. - **Changing a config name** requires updating that config's `upgradeFlakeRef` in `flake.nix` (it embeds the name). -- **The `channel` block is the dev/prod toggle** in `flake.nix` — `appInput`, `atticRepo`, `gitRef`, `appVersion`. When merging `dev`→`main`, keep this block's values for the target branch; only this block differs between branches. +- **The `channel` block is the dev/prod toggle** in `flake.nix` — `atticRepo` and `gitRef`. When merging `dev`→`main`, keep this block's values for the target branch; only this block differs between branches. - **`rotated_display.nix` uses `mkForce`** on `loginShellInit` — it replaces the entire kiosk startup script from `configuration.nix`; don't edit both. -- **Auto-update**: `system.autoUpgrade` runs every 10 min (`*:00/10`) with `--refresh`, operation `switch`, no auto-reboot. A `radiantwave-backup` rsync runs before each attempt. The health-check/rollback service is written but commented out in `configuration.nix`. +- **Auto-update**: `system.autoUpgrade` runs every 10 min (`*:00/10`) with `--refresh --impure`, operation `switch`, no auto-reboot. A `radiantwave-backup` rsync runs before each attempt. The health-check/rollback service is written but commented out in `configuration.nix`. - **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. +- **App closure comes from Attic**: both channels (`radiantwave`, `radiantwave-dev`) are configured as trusted substituters in `configuration.nix`, so the baked app and its updates are substituted, never compiled, on the kiosks. nixpkgs deps still come from cache.nixos.org. - **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. +- **App inputs**: there are none. Do not re-add a pinned `radiantwave` flake input or an `appVersion`; that is exactly what drifted from the published releases. The manifest is the source of truth. - **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/build-rpi.sh b/build-rpi.sh index 0b803ed..cfe1342 100755 --- a/build-rpi.sh +++ b/build-rpi.sh @@ -26,6 +26,7 @@ podman run --rm \ mkdir -p /etc/nix echo "experimental-features = nix-command flakes" > /etc/nix/nix.conf echo "accept-flake-config = true" >> /etc/nix/nix.conf + echo "tarball-ttl = 0" >> /etc/nix/nix.conf echo "extra-substituters = https://cache.nixos.org https://nixos-raspberrypi.cachix.org https://attic.radiantwavetech.com/radiantwave https://attic.radiantwavetech.com/radiantwave-dev" >> /etc/nix/nix.conf echo "extra-trusted-public-keys = nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI= radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA= radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0=" >> /etc/nix/nix.conf echo "max-jobs = 4" >> /etc/nix/nix.conf @@ -33,6 +34,7 @@ podman run --rm \ echo "==> Building sdImage (first run slow, cached after)..." nix --accept-flake-config build \ .#nixosConfigurations.'${CONFIG}'.config.system.build.sdImage \ + --impure \ --fallback \ --print-build-logs diff --git a/configuration.nix b/configuration.nix index 62a9249..f230103 100644 --- a/configuration.nix +++ b/configuration.nix @@ -2,6 +2,14 @@ let radiantApp = radiantwaveApp; + # The published app arrives as a bare store-path string (from the channel + # manifest, see flake.nix). Wrap it in a derivation built with this system's + # pkgs so it can live in environment.systemPackages; the wrapper is a symlink + # farm and the real closure is substituted from the channel's Attic cache. + radiantAppPackage = pkgs.symlinkJoin { + name = "radiantwave-published"; + paths = [ radiantApp ]; + }; # When the live-updater module is enabled, RadiantWave launches through the # mutable /var/lib/radiantwave/current # pointer so it can be swapped without a full nixos-rebuild. Otherwise it @@ -32,7 +40,7 @@ in # Included packages environment.systemPackages = [ pkgs.cage pkgs.rsync pkgs.dbus pkgs.mesa pkgs.git pkgs.tailscale pkgs.sqlite pkgs.wireplumber - radiantApp + radiantAppPackage ]; # This populates the EFI partition and tells the NUC how to start NixOS. @@ -222,7 +230,9 @@ in enable = true; flake = upgradeFlakeRef; dates = "*:00/10"; - flags = [ "--refresh" "-L" ]; + # --impure: the flake reads the channel's published manifest at eval time + # (builtins.fetchurl) to pick the app store path for the next generation. + flags = [ "--refresh" "--impure" "-L" ]; operation = "switch"; }; @@ -249,6 +259,11 @@ in # NixOS settings. nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.accept-flake-config = true; + # The app baked into each generation comes from the channel's published + # manifest, fetched at eval time with `builtins.fetchurl` (see flake.nix). + # Disable the tarball cache so a rebuild during autoUpgrade always reads the + # current manifest instead of a copy up to an hour old. + nix.settings.tarball-ttl = 0; nix.settings.extra-substituters = [ "https://attic.radiantwavetech.com/radiantwave" "https://attic.radiantwavetech.com/radiantwave-dev" diff --git a/flake.lock b/flake.lock index 316d65b..8300831 100644 --- a/flake.lock +++ b/flake.lock @@ -51,42 +51,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_2": { - "inputs": { - "systems": "systems_2" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixos-facter-modules": { "locked": { "lastModified": 1773858690, @@ -182,116 +146,12 @@ "type": "github" } }, - "nixpkgs_3": { - "locked": { - "lastModified": 1785967620, - "narHash": "sha256-IItrdb7Puk05RqOBWZYFC5X6Wl1sJmCfh5MWVHw5iMM=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b7c2ada94fe99c15b0dbcf4d11fd7850b957a436", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_4": { - "locked": { - "lastModified": 1785967620, - "narHash": "sha256-IItrdb7Puk05RqOBWZYFC5X6Wl1sJmCfh5MWVHw5iMM=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b7c2ada94fe99c15b0dbcf4d11fd7850b957a436", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "radiantwave": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_3" - }, - "locked": { - "lastModified": 1790803867, - "narHash": "sha256-HRGQQlmn/kCsZIq1wBMFaW6q1xfSvwPhUS5CpG1mm+Q=", - "ref": "refs/tags/1.8", - "rev": "34c72b51ddc3da8d46d994e787ffd1f8e8dda77c", - "revCount": 85, - "type": "git", - "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" - }, - "original": { - "ref": "refs/tags/1.8", - "type": "git", - "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" - } - }, - "radiantwave-dev": { - "inputs": { - "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs_4" - }, - "locked": { - "lastModified": 1787943320, - "narHash": "sha256-zoHqtwO0DV3z/N311IxiMLrbyQHrw7HIoL1FQW5pGDk=", - "ref": "dev", - "rev": "2917d33866f052eca1e9daea175d95382a9087f2", - "revCount": 50, - "type": "git", - "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" - }, - "original": { - "ref": "dev", - "type": "git", - "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" - } - }, "root": { "inputs": { "disko": "disko", "nixos-facter-modules": "nixos-facter-modules", "nixos-raspberrypi": "nixos-raspberrypi", - "nixpkgs": "nixpkgs_2", - "radiantwave": "radiantwave", - "radiantwave-dev": "radiantwave-dev" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs": "nixpkgs_2" } } }, diff --git a/flake.nix b/flake.nix index 0607e82..1219660 100644 --- a/flake.nix +++ b/flake.nix @@ -4,8 +4,6 @@ disko.url = "github:nix-community/disko"; disko.inputs.nixpkgs.follows = "nixpkgs"; nixos-facter-modules.url = "github:numtide/nixos-facter-modules"; - radiantwave.url = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git?ref=refs/tags/1.8"; - radiantwave-dev.url = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git?ref=dev"; nixos-raspberrypi.url = "github:nvmd/nixos-raspberrypi/main"; }; @@ -15,8 +13,6 @@ nixpkgs, disko, nixos-facter-modules, - radiantwave, - radiantwave-dev, nixos-raspberrypi, ... }@inputs: @@ -62,36 +58,46 @@ # this channel, so a `dev`-to-`main` merge carries your changes with it # automatically. There are no more `-dev` sibling configs to copy. # - # main: appInput = radiantwave (tag 1.8), atticRepo = "radiantwave", - # gitRef = "", appVersion = "1.8" - # dev: appInput = radiantwave-dev, atticRepo = "radiantwave-dev", - # gitRef = "?ref=dev", appVersion = + # main: atticRepo = "radiantwave", gitRef = "" + # dev: atticRepo = "radiantwave-dev", gitRef = "?ref=dev" # - # To flip branches, change ONLY these four values (then push/merge). + # To flip branches, change ONLY these two values (then push/merge). + # + # Note there is deliberately NO app version here. The RadiantWave app is + # not built or pinned by this repo; each channel's published manifest is + # the single source of truth (see `publishedApp` below). channel = { - appInput = radiantwave-dev; # dev atticRepo = "radiantwave-dev"; # dev gitRef = "?ref=dev"; # dev - appVersion = "2917d33"; # dev }; - # Exposed so CI (see .gitea/workflows/build-prod-*.yml) and humans can - # verify which channel this checkout resolves to. On `main` this must be - # "radiantwave"; on `dev` it must be "radiantwave-dev". The prod - # workflows fail fast if this isn't the production channel, so a merge - # that brings a dev-pointing channel block into `main` breaks the build - # instead of silently shipping dev builds as prod ISOs. - channelName = if channel.appInput == radiantwave then "radiantwave" else "radiantwave-dev"; + # Exposed so CI (see .gitea/workflows/build.yml) and humans can verify + # which channel this checkout resolves to. On `main` this is + # "radiantwave"; on `dev` it is "radiantwave-dev". + channelName = channel.atticRepo; - # 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 - # to its 0.1.0 default. Overriding the version here makes the baked - # baseline correct regardless of eval purity, and matches the Attic - # closure when RadiantWave CI built it with the same version string. - appPackage = system: channel.appInput.packages.${system}.default.override { - version = channel.appVersion; - }; + # Channel metadata shared by the configs and the CI preflight. + channelAtticUrl = "https://attic.radiantwavetech.com/${channel.atticRepo}"; + channelManifestDir = "https://files.radiantwavetech.com/${channel.atticRepo}"; + + # The baked RadiantWave app is whatever the channel's published manifest + # currently announces. RadiantWave CI builds the app, pushes the closure + # to the channel's Attic cache, and writes {version, storePath} to the + # manifest. We fetch that manifest at eval time and use its storePath + # verbatim, so an image always carries exactly the published closure and + # never compiles RadiantWave from source. This is an *impure* fetch (no + # hash), so every eval that reaches the app needs `--impure`: CI, the + # kiosk's system.autoUpgrade, and local `build-rpi.sh` all pass it. + manifestFor = manifestFile: + builtins.fromJSON (builtins.readFile (builtins.fetchurl { + url = "${channelManifestDir}/${manifestFile}"; + })); + + # The published closure as a plain store-path string. configuration.nix + # wraps it in a symlinkJoin so it can live in environment.systemPackages + # (a bare string there would be coerced with builtins.storePath, which + # requires the closure to already be local at eval time). + publishedApp = manifestFile: (manifestFor manifestFile).storePath; # Live app-update side channel. The manifest is served by the # fileserver; closures are fetched (never built) from the channel's @@ -103,7 +109,7 @@ radiantwaveUpdaterModule = { repo, baseline, manifestFile ? "manifest.json" }: { services.radiantwaveUpdater = { enable = true; - baselinePackage = baseline; + baselinePath = baseline; manifestUrl = "https://files.radiantwavetech.com/${repo}/${manifestFile}"; atticUrl = "https://attic.radiantwavetech.com/${repo}"; }; @@ -111,7 +117,6 @@ mkKioskSystem = { system, - radiantwave, upgradeFlakeRef, hostName, diskModule, @@ -121,8 +126,8 @@ inherit system; specialArgs = { - inherit radiantwave upgradeFlakeRef; - radiantwaveApp = appPackage system; + inherit upgradeFlakeRef; + radiantwaveApp = publishedApp "manifest.json"; radiantwavePlymouth = mkRadiantwavePlymouth; }; @@ -144,21 +149,19 @@ # facter-.json, and select per variant here, e.g.: # { facter.reportPath = ./facter-commercial.json; } { facter.reportPath = ./facter.json; } - radiantwave.nixosModules.default ] ++ extraModules; }; mkRpiKioskSystem = { - radiantwave, upgradeFlakeRef, hostName, extraModules ? [], }: nixos-raspberrypi.lib.nixosSystem { specialArgs = { - inherit radiantwave upgradeFlakeRef; + inherit upgradeFlakeRef; inherit nixos-raspberrypi; - radiantwaveApp = appPackage "aarch64-linux"; + radiantwaveApp = publishedApp "aarch64-manifest.json"; radiantwavePlymouth = mkRadiantwavePlymouth; }; @@ -174,7 +177,6 @@ }) ./configuration.nix ./radiantwave-updater.nix - radiantwave.nixosModules.default { networking.hostName = hostName; system.configurationRevision = revision; @@ -224,7 +226,7 @@ }; in { - inherit channelName; + inherit channelName channelAtticUrl channelManifestDir; 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; @@ -234,53 +236,50 @@ # Kiosk systems — one per hardware target, no dev/prod siblings. # # Every config consumes the channel selected at the top of this file - # (see `channel`): the RadiantWave app input, the Attic repo/manifest - # it fetches live updates from, and the KioskConfig git ref it - # auto-upgrades from. On `main` the channel is prod; on `dev` it is the - # dev channel. Because there is exactly one config per hardware type, - # changes made on `dev` reach prod via a plain merge — no copying. + # (see `channel`): the published manifest whose closure is baked as the + # app, the Attic repo/manifest it fetches live updates from, and the + # KioskConfig git ref it auto-upgrades from. On `main` the channel is + # prod; on `dev` it is the dev channel. Because there is exactly one + # config per hardware type, changes made on `dev` reach prod via a plain + # merge — no copying. nixosConfigurations = { radiantwavekiosk = mkKioskSystem { system = "x86_64-linux"; - radiantwave = channel.appInput; hostName = "radiantwavekiosk"; diskModule = ./disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = appPackage "x86_64-linux"; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedApp "manifest.json"; }) ]; }; radiantwavekiosk-commercial = mkKioskSystem { system = "x86_64-linux"; - radiantwave = channel.appInput; hostName = "radiantwavekiosk-commercial"; diskModule = ./commercial-disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-commercial"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = appPackage "x86_64-linux"; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedApp "manifest.json"; }) ]; }; # Rotated screen for android-style displays. radiantwavekiosk-android = mkKioskSystem { system = "x86_64-linux"; - radiantwave = channel.appInput; hostName = "radiantwavekiosk-android"; diskModule = ./disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-android"; extraModules = [ ./rotated_display.nix - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = appPackage "x86_64-linux"; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedApp "manifest.json"; }) ]; }; # Raspberry Pi 5. radiantwavekiosk-rpi = mkRpiKioskSystem { - radiantwave = channel.appInput; hostName = "radiantwavekiosk-rpi"; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-rpi"; extraModules = [ (radiantwaveUpdaterModule { repo = channel.atticRepo; - baseline = appPackage "aarch64-linux"; + baseline = publishedApp "aarch64-manifest.json"; manifestFile = "aarch64-manifest.json"; }) ]; @@ -292,8 +291,8 @@ # SATA with ./commercial-disk-config.nix — don't cross them. # The same names build dev-flavored images when the flake is checked # out on the `dev` branch (the channel block above selects the dev - # app, attic repo, and upgrade ref), so there are no separate -dev - # ISOs. + # manifest, attic repo, and upgrade ref), so there are no separate + # -dev ISOs. kiosk-iso = mkIso { targetConfig = "radiantwavekiosk"; diskModule = ./disk-config.nix; diff --git a/radiantwave-updater.nix b/radiantwave-updater.nix index 4729744..2ced142 100644 --- a/radiantwave-updater.nix +++ b/radiantwave-updater.nix @@ -17,19 +17,18 @@ in description = "Attic cache URL to fetch the app closure from (fetch-only, never builds)."; }; - baselinePackage = lib.mkOption { - type = lib.types.package; - description = "Copy of RadiantWave baked into the system closure, used to seed the runtime pointer before the first update."; + baselinePath = lib.mkOption { + type = lib.types.str; + description = "Store path of the RadiantWave closure baked into the system closure (from the channel manifest), used to seed the runtime pointer before the first update."; }; }; config = lib.mkIf cfg.enable { # Runtime pointer the login shell launches RadiantWave from. Seeded on - # first boot to the baseline copy already baked into the system closure - # (so the ISO carries it with no duplication); the updater service later - # re-points it atomically to newer closures fetched from Attic. + # first boot to the exact closure the channel manifest announced (the same + # path the updater would fetch), so no spurious first update is triggered. systemd.tmpfiles.rules = [ - "L /var/lib/radiantwave/current - - - - ${cfg.baselinePackage.outPath}" + "L /var/lib/radiantwave/current - - - - ${cfg.baselinePath}" ]; systemd.services.radiantwave-updater = { From f74d8cb708d950d788822a19e2b223c406514dfb Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 7 Oct 2026 10:48:15 -0500 Subject: [PATCH 09/10] Make the baked app a real store-path dependency via builtins.storePath The previous commit wrapped the manifest's storePath string in a symlinkJoin. symlinkJoin passes its paths as a file, so a bare store-path string registered no dependency: Nix never substituted the closure and the symlinkJoin failed with 'radiantwave-9b57e48: No such file or directory'. builtins.storePath turns the published path into a real store-path dependency that Nix substitutes from the channel's Attic cache, so the app is baked and never compiled. Keep the raw string (publishedPath) for the updater's plain-string baseline pointer. --- configuration.nix | 13 ++++--------- flake.nix | 24 +++++++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/configuration.nix b/configuration.nix index f230103..9f90368 100644 --- a/configuration.nix +++ b/configuration.nix @@ -1,15 +1,10 @@ { config, pkgs, radiantwaveApp, upgradeFlakeRef, radiantwavePlymouth, ... }: let + # The published app is a store path with context (builtins.storePath in + # flake.nix), so Nix substitutes it from the channel's Attic cache and the + # system closure depends on it. radiantApp = radiantwaveApp; - # The published app arrives as a bare store-path string (from the channel - # manifest, see flake.nix). Wrap it in a derivation built with this system's - # pkgs so it can live in environment.systemPackages; the wrapper is a symlink - # farm and the real closure is substituted from the channel's Attic cache. - radiantAppPackage = pkgs.symlinkJoin { - name = "radiantwave-published"; - paths = [ radiantApp ]; - }; # When the live-updater module is enabled, RadiantWave launches through the # mutable /var/lib/radiantwave/current # pointer so it can be swapped without a full nixos-rebuild. Otherwise it @@ -40,7 +35,7 @@ in # Included packages environment.systemPackages = [ pkgs.cage pkgs.rsync pkgs.dbus pkgs.mesa pkgs.git pkgs.tailscale pkgs.sqlite pkgs.wireplumber - radiantAppPackage + radiantApp ]; # This populates the EFI partition and tells the NUC how to start NixOS. diff --git a/flake.nix b/flake.nix index 1219660..3e1ea20 100644 --- a/flake.nix +++ b/flake.nix @@ -93,11 +93,17 @@ url = "${channelManifestDir}/${manifestFile}"; })); - # The published closure as a plain store-path string. configuration.nix - # wraps it in a symlinkJoin so it can live in environment.systemPackages - # (a bare string there would be coerced with builtins.storePath, which - # requires the closure to already be local at eval time). - publishedApp = manifestFile: (manifestFor manifestFile).storePath; + # The published closure as a plain store-path string. Used for the + # updater's runtime baseline pointer (a plain string; the closure itself + # is pulled in via publishedApp below). + publishedPath = manifestFile: (manifestFor manifestFile).storePath; + + # The published closure as a real store-path dependency. + # builtins.storePath makes the derivation depend on this exact path and + # substitute it from the channel's Attic cache (configured as a trusted + # substituter), so the app is never compiled from source. The closure + # itself is not fetched by this repository; RadiantWave CI publishes it. + publishedApp = manifestFile: builtins.storePath (publishedPath manifestFile); # Live app-update side channel. The manifest is served by the # fileserver; closures are fetched (never built) from the channel's @@ -249,7 +255,7 @@ diskModule = ./disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedApp "manifest.json"; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedPath "manifest.json"; }) ]; }; radiantwavekiosk-commercial = mkKioskSystem { @@ -258,7 +264,7 @@ diskModule = ./commercial-disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-commercial"; extraModules = [ - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedApp "manifest.json"; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedPath "manifest.json"; }) ]; }; # Rotated screen for android-style displays. @@ -269,7 +275,7 @@ upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-android"; extraModules = [ ./rotated_display.nix - (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedApp "manifest.json"; }) + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = publishedPath "manifest.json"; }) ]; }; # Raspberry Pi 5. @@ -279,7 +285,7 @@ extraModules = [ (radiantwaveUpdaterModule { repo = channel.atticRepo; - baseline = publishedApp "aarch64-manifest.json"; + baseline = publishedPath "aarch64-manifest.json"; manifestFile = "aarch64-manifest.json"; }) ]; From a3e6da70538eda3d3033f3e08c8c7da09bf5e3cb Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 7 Oct 2026 11:09:01 -0500 Subject: [PATCH 10/10] Ignore VERIFY-DEV.md and VERIFY-PROD.md Local verification checklists kept alongside the checkout; not configuration. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 49d9794..30bf11e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ build/out/ .direnv wifi-psk.txt *.qcow2 +VERIFY-DEV.md +VERIFY-PROD.md