The green deploy that deployed nothing
A green check next to NixOS Deploy is one of the easiest things to
misread on this fleet. It is tempting to read it as the change is on the
hosts now. What it actually means is narrower and worth stating exactly,
because the gap between those two sentences is where the interesting
failures live.
The workflow is public: .forgejo/workflows/nix-deploy.yml in the
nix-config repository. It
starts by deciding which hosts the run is about, and only then runs a
deploy job per host.
Green means “nothing to do”, not “done”
The first job is called Compute changed host scope, and its whole output
is one line in the log:
1
scoped hosts: ' public'
That line is computed from a single diff:
1
git diff --name-only HEAD~1...HEAD
— the last commit only. A path on the shared list puts every host in scope; a path under a single host’s directory puts that host in scope; anything else puts nothing in scope at all.
| Changed path | Hosts in scope |
|---|---|
flake.nix, flake.lock, modules/**, overlays/**, hosts/common/**, secrets/** |
every host |
hosts/<host>/** |
<host> only |
| README, docs, anything else | none |
Each host’s deploy job is then gated on its own name:
1
if: contains(needs.scope.outputs.hosts, 'mail')
With an empty scope, contains() is false for every host, so every
deploy job is skipped. A skipped job is not a failure. The scope job
succeeds, the run concludes, and the summary is green. Nothing was
deployed, and nothing was wrong — the pipeline correctly decided there was
nothing to do.
That is the design working. It becomes a problem in exactly one situation: when a file that a host genuinely consumes is not on either list. Then the run is green, the log says no hosts, and the host keeps running whatever generation it had before. There is no red mark anywhere, because from the workflow’s point of view there was no work.
The scope rules are a list, and lists go stale
This is the part worth internalising: the scope rule is an enumeration of
path prefixes, and an enumeration is a claim about the future that someone
has to keep true. The comment above the file list in the workflow says it
plainly — the shared paths are the ones that “scope every host”, and
secrets/** is on that list because the secrets store is shared code, not
a document. It was not always on that list. While it was missing, a change
to it was classified as irrelevant, produced an empty scope, and deployed
nothing at all — with a green run.
So the rule of thumb is not “does CI pass”. It is:
A green deploy run tells you the scope was computed. Read the scope.
What you can actually check
- The run’s own log.
scoped hosts: '...'is printed before anything deploys. If you changedhosts/mail/**and that line does not containmail, the run is telling you it did not deploy mail. - The host, not the run. The check that counts is the one on the machine itself — the generation it is running, and the timestamp on it. A run that is green and a host that is three days behind are entirely compatible facts.
- The dispatch path, which does not guess. A manual dispatch takes an
explicit
hostsinput, and that input is authoritative: the job takes no checkout and computes no diff. An empty or unrecognised value aborts the run rather than quietly scoping to nothing. When you mean “deploy this host”, say so.
This is the same argument as anywhere else on this site where a status is published: the audit page is built from signed artifacts precisely because a claim you cannot inspect is only a claim about intentions. A green badge is an input to a decision, not the decision.
For the layer underneath this one — the hosts, the runner, the health gate that verifies a deploy after the fact and rolls it back if it fails — see how the stack is built.