A secret rotation that merged green and reached no host

A credential rotation merges to main, and the run list stays empty. Not a red run — no run. There is nothing to misread, because there is nothing there.

That is a different silent failure from the one where a run goes green and deploys nothing. The fleet deploy has two independent gates between a merge and a host, and they answer two different questions.

One filter decides whether a run exists at all

NixOS Deploy is triggered by a paths: filter (.forgejo/workflows/nix-deploy.yml in the nix-config repository):

1
2
3
4
5
6
7
8
9
  push:
    branches: [main]
    paths:
      - 'flake.nix'
      - 'flake.lock'
      - 'hosts/**'
      - 'modules/**'
      - 'overlays/**'
      - 'secrets/**'

That list is not scoping. It is creation. A merge touching none of those paths produces no workflow run at all — and a missing run is not a pending run. Waiting for it never resolves; there is no job that will eventually appear.

The comment directly above the block exists because of exactly this, and it is worth reading in full:

secrets/** is host-relevant, not docs-like: the sops store is SHARED code (one file, every host’s defaultSopsFile), and the scope step below maps secrets/ to ALL hosts. Without it here, a secrets-only merge is a silent no-op: no run is created, and even a forced run would scope to zero hosts.

Two halves of one sentence, and they are the whole post. No run is created is the filter. Even a forced run would scope to zero hosts is the second gate, and it fails independently — which is why a rotation bug has two chances to be invisible.

The other gate decides which hosts

Once a run exists, its first job computes which hosts the run is about, and the whole output of that computation is one line:

1
scoped hosts: ' public'

The shared-code branch of that computation is an explicit list:

1
git diff --name-only "HEAD~1...HEAD" -- flake.nix flake.lock modules/ overlays/ hosts/common/ secrets/ | grep -q .

A hit there scopes every host, with one measured exception: a flake.lock-only bump that moves just a rev/narHash scopes to vpn alone, and the guard for that path additionally requires that no input was added, removed or renamed. A path under hosts/<host>/ scopes just that host. Anything else scopes nothing, and with an empty scope each host job’s if: contains(needs.scope.outputs.hosts, 'mail') is false, every deploy job is skipped, and the run concludes green. That mechanism — a green run that deployed nothing — is the subject of its own post. It is worth keeping the three apart:

Failure Where it happens What you see
No run on: push: paths: filter no run; a watch that never resolves
Green run, zero hosts scope job, empty result scoped hosts: ''
Green run, wrong hosts scope job, stale enumeration scoped hosts: without the host you changed

Two lists, and they are not the same list

This is the part that makes the class of bug recur. The paths: filter carries hosts/**; the scope’s shared-code list carries hosts/common/. That difference is deliberate and correct — a change to one host’s directory should reach that host, not the fleet. Each list answers its own question, so copying one into the other is not a fix, and adding a directory to only one of them is a partial fix whose remaining half fails silently.

The rule that follows: when a new top-level directory starts being consumed by hosts, it needs an entry in both places, and the second entry is the one that is easy to forget because the first one makes CI look alive. The same class shows up one level down. The deploy workflow’s filter deliberately names config paths only — no .forgejo/workflows/** — so a workflow-only merge does not auto-deploy, and a workflow change is validated by a manual dispatch instead. The build-check workflow’s filter does include workflows, because it has to be able to validate its own changes. And a script under .forgejo/scripts/** that a workflow step calls needs that path in the calling workflow’s filter, or editing the script runs nothing.

The escape hatch, and why it is not the fix

workflow_dispatch is not subject to paths: filters at all. A manual dispatch always runs, and its hosts input is authoritative — that job takes no checkout and computes no diff, so nothing can scope it down. That makes dispatch a reliable way to deploy a host on purpose, and it is why the forced run case in the comment is still recoverable.

It is not a fix for a missing filter entry, because the failure mode is ignorance: the rotation merged, nothing went red, and nobody knows there is work to do. An escape hatch only helps the person who already suspects.

What you can check

The same discipline as everywhere else on this site: the deploy pipeline is only as trustworthy as the claim you can independently re-derive from the files it runs from.

󰔟 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