Goal
Pi converges on merged main within minutes instead of nightly. Promptness is the
requirement ("starts ≤1 min after CI green"); strict liveness (live ≤1 min) is
explicitly not required. France box deferred — this is pi-only.
Invariant: never deploy raw main
Every deploy gates on all-builds green. That guarantees both that the commit
builds and that the aarch64 closure is already in Cachix (tower's post-build
hook pushes synchronously during the CI build). This is an improvement over the
status quo, where the nightly autoUpgrade pulls raw main with no CI gating
at all.
CI cannot catch builds-fine-but-misbehaves-at-runtime changes; those deploy
minutes after merge. Accepted: rollback is manual (ssh pi +
nixos-rebuild switch --rollback), physical access as backstop.
Two candidate implementations
Build both as separate PRs and compare working code; the loser doesn't merge.
A. Gated pull
- CI job (after
all-builds, main only, contents: write) fast-forwards a
deploy ref: git push origin HEAD:deploy.
- Pi's
system.autoUpgrade retargets from main to deploy, dates = "*:0/1"
(or *:0/2), drop randomizedDelaySec.
ExecCondition on the nixos-upgrade service compares git ls-remote of
deploy against a last-deployed-rev state file (configurationRevision is
unset in this flake, so nixos-version --configuration-revision can't be the
comparison source). Idle ticks cost one git ls-remote.
- Latency: ~CI + 3–5 min (flake eval runs on the Pi 4 every real deploy).
- No new credentials anywhere.
B. CI push
- Deploy job (after
all-builds, push to main only) on a tower runner:
nixos-rebuild switch --flake .#pi --target-host <pi>. Eval happens on tower;
pi only receives store paths and activates — no on-pi eval.
- SSH key stored as a GitHub environment secret with the environment
restricted to main — branch/PR workflow runs cannot read it, so "can push a
branch" does not become "root on pi".
- Key is
from=-pinned to tower in pi's authorized_keys, so an exfiltrated
key is useless off tower.
- Existing nightly
autoUpgrade kept unchanged as offline-catch-up backstop.
- Latency: ~CI + ≤1 min.
Comparison criteria
- End-to-end latency (merge → live on pi)
- Moving parts / custom code each design actually required
- Credential surface (A: none; B: main-scoped,
from=-pinned key — verify the
scoping holds in practice)
- Failure behavior when pi is offline or the deploy fails mid-way
Explicitly rejected
- Polling raw
main faster: races CI/Cachix and wastes on-pi eval; frequency
was never the bottleneck.
- comin: no use for its testing-branch feature over
ssh pi +
nixos-rebuild test; dependency not earning its place.
- Auto-rollback / health checks on pi: LAN physical access suffices (reconsider
for the France box).
- Deploy windows and
allowReboot: complexity with no observed problem behind
it. Kernel updates still apply only on manual reboot.
Doc follow-through (whichever lands)
- Update "Pi auto-upgrades from main nightly — test before merging" in
CLAUDE.md — merges now go live in minutes, so the discipline tightens.
- Same note in
modules/lan-dns.nix if repeated there.
🤖 Generated with Claude Code
Goal
Pi converges on merged main within minutes instead of nightly. Promptness is the
requirement ("starts ≤1 min after CI green"); strict liveness (live ≤1 min) is
explicitly not required. France box deferred — this is pi-only.
Invariant: never deploy raw
mainEvery deploy gates on
all-buildsgreen. That guarantees both that the commitbuilds and that the aarch64 closure is already in Cachix (tower's post-build
hook pushes synchronously during the CI build). This is an improvement over the
status quo, where the nightly
autoUpgradepulls rawmainwith no CI gatingat all.
CI cannot catch builds-fine-but-misbehaves-at-runtime changes; those deploy
minutes after merge. Accepted: rollback is manual (
ssh pi+nixos-rebuild switch --rollback), physical access as backstop.Two candidate implementations
Build both as separate PRs and compare working code; the loser doesn't merge.
A. Gated pull
all-builds, main only,contents: write) fast-forwards adeployref:git push origin HEAD:deploy.system.autoUpgraderetargets frommaintodeploy,dates = "*:0/1"(or
*:0/2), droprandomizedDelaySec.ExecConditionon thenixos-upgradeservice comparesgit ls-remoteofdeployagainst a last-deployed-rev state file (configurationRevisionisunset in this flake, so
nixos-version --configuration-revisioncan't be thecomparison source). Idle ticks cost one
git ls-remote.B. CI push
all-builds, push to main only) on a tower runner:nixos-rebuild switch --flake .#pi --target-host <pi>. Eval happens on tower;pi only receives store paths and activates — no on-pi eval.
restricted to
main— branch/PR workflow runs cannot read it, so "can push abranch" does not become "root on pi".
from=-pinned to tower in pi'sauthorized_keys, so an exfiltratedkey is useless off tower.
autoUpgradekept unchanged as offline-catch-up backstop.Comparison criteria
from=-pinned key — verify thescoping holds in practice)
Explicitly rejected
mainfaster: races CI/Cachix and wastes on-pi eval; frequencywas never the bottleneck.
ssh pi+nixos-rebuild test; dependency not earning its place.for the France box).
allowReboot: complexity with no observed problem behindit. Kernel updates still apply only on manual reboot.
Doc follow-through (whichever lands)
CLAUDE.md— merges now go live in minutes, so the discipline tightens.modules/lan-dns.nixif repeated there.🤖 Generated with Claude Code