From eb3fc0780cd21f160d8636e65686de02991799c2 Mon Sep 17 00:00:00 2001 From: etrobert-bot Date: Fri, 10 Jul 2026 04:14:42 +0200 Subject: [PATCH 1/5] feat(pi): deploy from gated `deploy` ref within minutes of merge Pi converged on main only nightly. Now CI fast-forwards a `deploy` ref after `all-builds` passes, and pi's autoUpgrade polls it every minute, gated by an ExecCondition so idle ticks cost one `git ls-remote` instead of a 3-5 min flake eval on the Pi 4. The gate records the rev it acted on before switching and promotes it only on success, so a rev pushed mid-deploy is caught next tick, never lost. Gating on `deploy` (never raw main) also guarantees the aarch64 closure is already in Cachix, pushed by tower's post-build hook in CI. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 20 ++++++++++ CLAUDE.md | 5 ++- modules/hosts/pi/configuration.nix | 63 ++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16684c4f..b6c43a2f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,3 +130,23 @@ jobs: || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} run: exit 1 + + # Fast-forward the `deploy` ref to this commit so pi's gated autoUpgrade + # converges within a minute. Gated on all-builds succeeding, which also + # guarantees the aarch64 closure is already in Cachix (pushed by tower's + # post-build hook during the build). Main only, and all-builds runs with + # if: always(), so require its explicit success. + deploy: + name: deploy + needs: all-builds + if: >- + ${{ github.ref == 'refs/heads/main' + && needs.all-builds.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - name: Fast-forward deploy ref + run: git push origin HEAD:deploy diff --git a/CLAUDE.md b/CLAUDE.md index 020a74b6..65d82df7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,8 +116,9 @@ the insecure package, the assertion fails and prompts removal. are port-forwarded to tower (`.10`). **LAN DHCP + DNS:** served by `pi` via `dnsmasq` (`modules/lan-dns.nix`, -listening on `end0`, static `.18`). Pi auto-upgrades from main nightly — test -before merging. +listening on `end0`, static `.18`). Pi auto-upgrades from the `deploy` ref +(which CI fast-forwards to main once `all-builds` is green), converging within +minutes of a merge — test before merging. **Static LAN addresses:** `pi end0` `.18` (MAC `DC:A6:32:13:51:14`), `tower` `.10` (motherboard NIC `enp11s0`, static via NetworkManager in diff --git a/modules/hosts/pi/configuration.nix b/modules/hosts/pi/configuration.nix index 03c53511..b9719d67 100644 --- a/modules/hosts/pi/configuration.nix +++ b/modules/hosts/pi/configuration.nix @@ -4,6 +4,59 @@ ... }: +let + # Gates the autoUpgrade timer on the `deploy` ref, which CI fast-forwards only + # after all-builds passes. `check` (ExecCondition) skips the run unless deploy + # advanced past the last live rev, so idle minute-ticks cost one `git + # ls-remote` instead of a 3-5 min flake eval on the Pi 4. `record` + # (ExecStartPost) promotes the gated rev after a successful switch. + # + # `check` records the rev it gated on, not a fresh ls-remote at the end: a rev + # pushed mid-deploy is then caught on the next tick rather than lost. + deployGate = pkgs.writeShellApplication { + name = "pi-deploy-gate"; + + runtimeInputs = [ + pkgs.gitMinimal + pkgs.coreutils + ]; + + inheritPath = false; + + text = '' + state_dir=/var/lib/nixos-upgrade + last_rev="$state_dir/last-deployed-rev" + pending_rev="$state_dir/pending-rev" + deploy_url=https://github.com/etrobert/setup.git + + case "''${1:-}" in + check) + mkdir -p "$state_dir" + rev=$(git ls-remote "$deploy_url" deploy | cut --fields=1) || true + if [ -z "$rev" ]; then + echo "could not resolve deploy ref; skipping" >&2 + exit 1 + fi + if [ -f "$last_rev" ] && [ "$rev" = "$(cat "$last_rev")" ]; then + echo "deploy $rev already live; skipping" + exit 1 + fi + printf '%s\n' "$rev" > "$pending_rev" + echo "deploy $rev differs from live; upgrading" + ;; + record) + if [ -f "$pending_rev" ]; then + mv --force "$pending_rev" "$last_rev" + fi + ;; + *) + echo "usage: pi-deploy-gate {check|record}" >&2 + exit 2 + ;; + esac + ''; + }; +in { environment.systemPackages = with pkgs; [ ghostty.terminfo @@ -29,13 +82,17 @@ system.autoUpgrade = { enable = true; - flake = "github:etrobert/setup/main#pi"; + flake = "github:etrobert/setup/deploy#pi"; flags = [ "--accept-flake-config" "--print-build-logs" ]; - # dates = "4:40"; # default value - randomizedDelaySec = "5min"; + dates = "*:0/1"; + }; + + systemd.services.nixos-upgrade.serviceConfig = { + ExecCondition = "${lib.getExe deployGate} check"; + ExecStartPost = "${lib.getExe deployGate} record"; }; networking.hostName = "pi"; From c81786015653a2a63818dce0ea9871d0c7899ef6 Mon Sep 17 00:00:00 2001 From: etrobert-bot Date: Fri, 10 Jul 2026 13:13:54 +0200 Subject: [PATCH 2/5] refactor(pi): address review on gated deploy - Drop the auto-upgrade sentence from the LAN DHCP+DNS note in CLAUDE.md - Let systemd own the state dir via StateDirectory; drop mkdir -p - Simplify the deploy gate script (no ${1:-}, no `|| true`, plain mv/echo) - Document the every-minute autoUpgrade cadence inline - Drop redundant needs.all-builds.result check and stale comments Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 9 +-------- CLAUDE.md | 4 +--- modules/hosts/pi/configuration.nix | 20 ++++++-------------- 3 files changed, 8 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6c43a2f..ca06202d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -131,17 +131,10 @@ jobs: || contains(needs.*.result, 'skipped') }} run: exit 1 - # Fast-forward the `deploy` ref to this commit so pi's gated autoUpgrade - # converges within a minute. Gated on all-builds succeeding, which also - # guarantees the aarch64 closure is already in Cachix (pushed by tower's - # post-build hook during the build). Main only, and all-builds runs with - # if: always(), so require its explicit success. deploy: name: deploy needs: all-builds - if: >- - ${{ github.ref == 'refs/heads/main' - && needs.all-builds.result == 'success' }} + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: write diff --git a/CLAUDE.md b/CLAUDE.md index 65d82df7..3ab499b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,9 +116,7 @@ the insecure package, the assertion fails and prompts removal. are port-forwarded to tower (`.10`). **LAN DHCP + DNS:** served by `pi` via `dnsmasq` (`modules/lan-dns.nix`, -listening on `end0`, static `.18`). Pi auto-upgrades from the `deploy` ref -(which CI fast-forwards to main once `all-builds` is green), converging within -minutes of a merge — test before merging. +listening on `end0`, static `.18`). **Static LAN addresses:** `pi end0` `.18` (MAC `DC:A6:32:13:51:14`), `tower` `.10` (motherboard NIC `enp11s0`, static via NetworkManager in diff --git a/modules/hosts/pi/configuration.nix b/modules/hosts/pi/configuration.nix index b9719d67..1d4c0520 100644 --- a/modules/hosts/pi/configuration.nix +++ b/modules/hosts/pi/configuration.nix @@ -5,14 +5,6 @@ }: let - # Gates the autoUpgrade timer on the `deploy` ref, which CI fast-forwards only - # after all-builds passes. `check` (ExecCondition) skips the run unless deploy - # advanced past the last live rev, so idle minute-ticks cost one `git - # ls-remote` instead of a 3-5 min flake eval on the Pi 4. `record` - # (ExecStartPost) promotes the gated rev after a successful switch. - # - # `check` records the rev it gated on, not a fresh ls-remote at the end: a rev - # pushed mid-deploy is then caught on the next tick rather than lost. deployGate = pkgs.writeShellApplication { name = "pi-deploy-gate"; @@ -29,10 +21,9 @@ let pending_rev="$state_dir/pending-rev" deploy_url=https://github.com/etrobert/setup.git - case "''${1:-}" in + case "$1" in check) - mkdir -p "$state_dir" - rev=$(git ls-remote "$deploy_url" deploy | cut --fields=1) || true + rev=$(git ls-remote "$deploy_url" deploy | cut --fields=1) if [ -z "$rev" ]; then echo "could not resolve deploy ref; skipping" >&2 exit 1 @@ -41,12 +32,12 @@ let echo "deploy $rev already live; skipping" exit 1 fi - printf '%s\n' "$rev" > "$pending_rev" + echo "$rev" > "$pending_rev" echo "deploy $rev differs from live; upgrading" ;; record) if [ -f "$pending_rev" ]; then - mv --force "$pending_rev" "$last_rev" + mv "$pending_rev" "$last_rev" fi ;; *) @@ -87,10 +78,11 @@ in "--accept-flake-config" "--print-build-logs" ]; - dates = "*:0/1"; + dates = "*:0/1"; # every minute }; systemd.services.nixos-upgrade.serviceConfig = { + StateDirectory = "nixos-upgrade"; ExecCondition = "${lib.getExe deployGate} check"; ExecStartPost = "${lib.getExe deployGate} record"; }; From 55326619c1a40dfeb6b00440ec715c6aa6dce333 Mon Sep 17 00:00:00 2001 From: etrobert-bot Date: Fri, 10 Jul 2026 13:30:11 +0200 Subject: [PATCH 3/5] refactor(pi): extract gated upgrade into a dedicated module Move the deploy-gate script, system.autoUpgrade settings, and the nixos-upgrade ExecCondition/ExecStartPost/StateDirectory wiring out of pi's configuration.nix into modules/gated-upgrade.nix, registered as flake.nixosModules.gatedUpgrade and imported only by pi. Built system is unchanged (identical toplevel drvPath). Co-Authored-By: Claude Fable 5 --- flake.nix | 1 + modules/gated-upgrade.nix | 65 ++++++++++++++++++++++++++++++ modules/hosts/pi/configuration.nix | 60 --------------------------- modules/hosts/pi/default.nix | 1 + 4 files changed, 67 insertions(+), 60 deletions(-) create mode 100644 modules/gated-upgrade.nix diff --git a/flake.nix b/flake.nix index bfb1b304..388d58f6 100644 --- a/flake.nix +++ b/flake.nix @@ -93,6 +93,7 @@ ./modules/file-manager.nix ./modules/pimsync.nix ./modules/lan-dns.nix + ./modules/gated-upgrade.nix ./modules/darkman.nix ./modules/mpd.nix ./modules/hypridle.nix diff --git a/modules/gated-upgrade.nix b/modules/gated-upgrade.nix new file mode 100644 index 00000000..35973458 --- /dev/null +++ b/modules/gated-upgrade.nix @@ -0,0 +1,65 @@ +_: { + flake.nixosModules.gatedUpgrade = + { pkgs, lib, ... }: + let + deployGate = pkgs.writeShellApplication { + name = "pi-deploy-gate"; + + runtimeInputs = [ + pkgs.gitMinimal + pkgs.coreutils + ]; + + inheritPath = false; + + text = '' + state_dir=/var/lib/nixos-upgrade + last_rev="$state_dir/last-deployed-rev" + pending_rev="$state_dir/pending-rev" + deploy_url=https://github.com/etrobert/setup.git + + case "$1" in + check) + rev=$(git ls-remote "$deploy_url" deploy | cut --fields=1) + if [ -z "$rev" ]; then + echo "could not resolve deploy ref; skipping" >&2 + exit 1 + fi + if [ -f "$last_rev" ] && [ "$rev" = "$(cat "$last_rev")" ]; then + echo "deploy $rev already live; skipping" + exit 1 + fi + echo "$rev" > "$pending_rev" + echo "deploy $rev differs from live; upgrading" + ;; + record) + if [ -f "$pending_rev" ]; then + mv "$pending_rev" "$last_rev" + fi + ;; + *) + echo "usage: pi-deploy-gate {check|record}" >&2 + exit 2 + ;; + esac + ''; + }; + in + { + system.autoUpgrade = { + enable = true; + flake = "github:etrobert/setup/deploy#pi"; + flags = [ + "--accept-flake-config" + "--print-build-logs" + ]; + dates = "*:0/1"; # every minute + }; + + systemd.services.nixos-upgrade.serviceConfig = { + StateDirectory = "nixos-upgrade"; + ExecCondition = "${lib.getExe deployGate} check"; + ExecStartPost = "${lib.getExe deployGate} record"; + }; + }; +} diff --git a/modules/hosts/pi/configuration.nix b/modules/hosts/pi/configuration.nix index 1d4c0520..080838a4 100644 --- a/modules/hosts/pi/configuration.nix +++ b/modules/hosts/pi/configuration.nix @@ -4,50 +4,6 @@ ... }: -let - deployGate = pkgs.writeShellApplication { - name = "pi-deploy-gate"; - - runtimeInputs = [ - pkgs.gitMinimal - pkgs.coreutils - ]; - - inheritPath = false; - - text = '' - state_dir=/var/lib/nixos-upgrade - last_rev="$state_dir/last-deployed-rev" - pending_rev="$state_dir/pending-rev" - deploy_url=https://github.com/etrobert/setup.git - - case "$1" in - check) - rev=$(git ls-remote "$deploy_url" deploy | cut --fields=1) - if [ -z "$rev" ]; then - echo "could not resolve deploy ref; skipping" >&2 - exit 1 - fi - if [ -f "$last_rev" ] && [ "$rev" = "$(cat "$last_rev")" ]; then - echo "deploy $rev already live; skipping" - exit 1 - fi - echo "$rev" > "$pending_rev" - echo "deploy $rev differs from live; upgrading" - ;; - record) - if [ -f "$pending_rev" ]; then - mv "$pending_rev" "$last_rev" - fi - ;; - *) - echo "usage: pi-deploy-gate {check|record}" >&2 - exit 2 - ;; - esac - ''; - }; -in { environment.systemPackages = with pkgs; [ ghostty.terminfo @@ -71,22 +27,6 @@ in }; }; - system.autoUpgrade = { - enable = true; - flake = "github:etrobert/setup/deploy#pi"; - flags = [ - "--accept-flake-config" - "--print-build-logs" - ]; - dates = "*:0/1"; # every minute - }; - - systemd.services.nixos-upgrade.serviceConfig = { - StateDirectory = "nixos-upgrade"; - ExecCondition = "${lib.getExe deployGate} check"; - ExecStartPost = "${lib.getExe deployGate} record"; - }; - networking.hostName = "pi"; networking.networkmanager = { diff --git a/modules/hosts/pi/default.nix b/modules/hosts/pi/default.nix index f92a6d2d..13e5c866 100644 --- a/modules/hosts/pi/default.nix +++ b/modules/hosts/pi/default.nix @@ -12,6 +12,7 @@ self.nixosModules.nixosBase self.nixosModules.base self.nixosModules.lanDns + self.nixosModules.gatedUpgrade inputs.agenix.nixosModules.default ]; }; From df316a1d393d045fa1d72d67d1250b7ef32b8a85 Mon Sep 17 00:00:00 2001 From: etrobert-bot Date: Fri, 10 Jul 2026 13:41:43 +0200 Subject: [PATCH 4/5] refactor(pi): rename gated-upgrade module to autoUpgrade Co-Authored-By: Claude Fable 5 --- flake.nix | 2 +- modules/{gated-upgrade.nix => auto-upgrade.nix} | 2 +- modules/hosts/pi/default.nix | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename modules/{gated-upgrade.nix => auto-upgrade.nix} (98%) diff --git a/flake.nix b/flake.nix index 388d58f6..c41dbc55 100644 --- a/flake.nix +++ b/flake.nix @@ -93,7 +93,7 @@ ./modules/file-manager.nix ./modules/pimsync.nix ./modules/lan-dns.nix - ./modules/gated-upgrade.nix + ./modules/auto-upgrade.nix ./modules/darkman.nix ./modules/mpd.nix ./modules/hypridle.nix diff --git a/modules/gated-upgrade.nix b/modules/auto-upgrade.nix similarity index 98% rename from modules/gated-upgrade.nix rename to modules/auto-upgrade.nix index 35973458..fef61329 100644 --- a/modules/gated-upgrade.nix +++ b/modules/auto-upgrade.nix @@ -1,5 +1,5 @@ _: { - flake.nixosModules.gatedUpgrade = + flake.nixosModules.autoUpgrade = { pkgs, lib, ... }: let deployGate = pkgs.writeShellApplication { diff --git a/modules/hosts/pi/default.nix b/modules/hosts/pi/default.nix index 13e5c866..7533b830 100644 --- a/modules/hosts/pi/default.nix +++ b/modules/hosts/pi/default.nix @@ -12,7 +12,7 @@ self.nixosModules.nixosBase self.nixosModules.base self.nixosModules.lanDns - self.nixosModules.gatedUpgrade + self.nixosModules.autoUpgrade inputs.agenix.nixosModules.default ]; }; From 403ffa17b44e9d70d491c1d6519eae6913105c53 Mon Sep 17 00:00:00 2001 From: etrobert-bot Date: Fri, 10 Jul 2026 13:58:24 +0200 Subject: [PATCH 5/5] fix(pi): write pending deploy rev with printf, not echo Co-Authored-By: Claude Fable 5 --- modules/auto-upgrade.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auto-upgrade.nix b/modules/auto-upgrade.nix index fef61329..960ac486 100644 --- a/modules/auto-upgrade.nix +++ b/modules/auto-upgrade.nix @@ -29,7 +29,7 @@ _: { echo "deploy $rev already live; skipping" exit 1 fi - echo "$rev" > "$pending_rev" + printf '%s\n' "$rev" > "$pending_rev" echo "deploy $rev differs from live; upgrading" ;; record)