One merge, two runs, the same sha

One merge. Two runs of the same workflow. Same commit sha. Neither cancelled the other — the second one waited, then ran to completion. That is the designed behaviour of three lines at the top of .forgejo/workflows/nix-deploy.yml in the nix-config repository, and understanding it changes how you read a deploy log.

Queue, never cancel

1
2
3
concurrency:
  group: nix-deploy-$
  cancel-in-progress: false

Lines 151–153. The workflow’s own comment above them explains why this block exists at all. Forgejo’s documented default, for a workflow with no top-level concurrency, is that for on.push “whenever a new event triggers a new workflow invocation, any other invocations of the same workflow will be canceled automatically”. That default is right for a check — a superseded build is evaluating a commit nobody will merge — and wrong for a deploy. Cancelling a nixos-rebuild switch mid-activation can tear an activation half-way (some units new, some old) and skip the health gate and automatic rollback that follow it. The comment records a live instance: runs 2701 and 2712 cancelled about forty seconds apart while 2701 was mid-switch on vpn, leaving the fleet mixed.

Declaring any top-level concurrency block takes the workflow out of the automatic-cancellation regime. With a group and cancel-in-progress: false, a superseded run queues and completes instead of being killed.

The consequence for reading logs is direct: a later run in the same group waits rather than cancelling. Both runs execute, in order, and the last one to touch a host wins.

The group is keyed on the ref

group: nix-deploy-$ means a push to main and a workflow_dispatch on a PR branch are in different groups and never queue against each other. Two things follow: a dispatch and a push to the same ref share one queue, so the dispatch lines up behind the push rather than racing it; and runs on different refs do not serialise at all, even when they name the same host.

That second point is why the workflow also carries per-host job groups — deploy-mail, deploy-vpn, deploy-public, each with cancel-in-progress: false. As the file notes from Forgejo’s documentation, “Multiple jobs within a workflow are not affected by the concurrency setting”, so the job-level groups are what make two runs exclusive on the same host.

The job graph, and one deliberate always()

Job runs-on needs if
scope [gate]
deploy-rev [gate]
deploy-mail [deploy-mail] [scope, deploy-rev] contains(needs.scope.outputs.hosts, 'mail')
deploy-vpn [deploy-vpn] [scope, deploy-rev] contains(needs.scope.outputs.hosts, 'vpn')
deploy-public [deploy] [scope, deploy-rev, deploy-vpn] always() && contains(needs.scope.outputs.hosts, 'public')

Three per-host deploy jobs, one real ordering edge. deploy-public waits on deploy-vpn because the client DNS tiers split their two halves across the two hosts — primary on vpn, standby on public — and the jobs would otherwise run side by side and bounce both halves in the same second. The always() is the subtle half: without it, a skipped deploy-vpn (vpn simply not in scope) would skip deploy-public too, because a skipped job skips everything that needs: it. So the public job starts even when the vpn job failed or never ran, and its pre-flight step re-checks needs.deploy-vpn.result and refuses to switch if the primary side is broken. That is a deliberate choice about publishing the edge: the fallback halves must not be left un-deployed just because the primary tier was untouched.

Identify a run by three things, not by position

With more than one run able to touch one host, “the most recent run in the UI” is not an identifier. A run is identified by workflow + commit sha + trigger event:

When two runs can touch one host, the generation on the host is evidence and the badge is not. nixos-rebuild list-generations prints newest first, so the current generation is the head of that list; the run’s own badge can only tell you what the pipeline intended.

What you can check

For the scope job these deploys hang off — and why a green run can mean nothing was in scope at all — see the green deploy that deployed nothing.

󰔟 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