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

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.

󰔟 Running the same kind of stack?
The tunnel this site is written from is the one I sell: WireGuard to a gateway in Montréal, three DNS postures, one price — and the same "check it yourself" habit as everything above.
[ CREATE ACCOUNT ] [ WHAT IS THE VPN ]
󰣨 ymrtech@ymrtech | 󰌠 NixOS | 󰍢 UTF-8