Most pipeline bugs announce themselves with a red check. This one does the opposite: both runs go green, and the wrong one is live. Here is what each of the five patterns looks like from the outside, and why the evidence is so hard to find afterwards.
You push commit A. A minute later you push B. Both trigger the deploy workflow, and both runs start, because nothing told them not to.
Nothing failed. Both runs did exactly what they were asked to do, and neither one is aware the other existed. The branch shows two green checks in commit order, so the obvious reading — the newest commit is deployed — is wrong, and nothing on the page says so.
What makes it expensive is the debugging. The symptom arrives as “the fix I shipped isn’t live”, and every place you would look to check that — the commit, the check, the run log — says it is. The usual next step is to push again, which often fixes it, because this time the runs did not overlap. That turns a reproducible bug into an intermittent one, and an intermittent deployment bug can survive for years.
The default. GitHub Actions runs whatever is triggered, in parallel, and
does not serialize deployments for you — not even two runs of the
same workflow, and not even two jobs naming the same
environment:. Environment protection rules gate whether
a deployment proceeds; they are not a queue.
concurrency:
group: deploy-production
cancel-in-progress: false
That is the whole fix for this one. Runs that share a group name never overlap: the second waits for the first, so they apply in the order they were queued.
This is the one worth knowing about, because it is invisible in review. The block is present, it is spelled correctly, and it does nothing:
concurrency:
group: deploy-${{ github.sha }} # a new group every single run
A concurrency group only serializes runs that land in the same group. Key it on the commit, the run id, the run number or the attempt and every run gets a private group, so no run ever waits for another. The behaviour is identical to having no block at all — but now there is a block to point at, and the next person to read the file concludes the question has been dealt with.
It usually arrives by copying a CI workflow, where
group: ci-${{ github.ref }} is correct and useful, and then
changing the key while keeping the shape. github.ref and
github.workflow are stable across the runs you want
serialized; github.sha is not.
cancel-in-progress on a deployFor a test run this is the right setting and saves real money: a superseded run has nothing to protect, so cancelling it costs only the minutes it had already spent. A deploy is not like that. By the time the cancel signal arrives it may have uploaded 300 of 900 files, applied a migration the new code has not shipped for yet, or replaced half a symlinked release directory. The run stops where it is, and nothing comes back to finish it.
The result is a state neither commit produced, which is worse than either of them being live. For a deploy, queue instead:
cancel-in-progress: false
A repository with a push-triggered deploy and a manual
workflow_dispatch deploy usually has two workflow files. Each
one is internally correct — each declares a concurrency group, each
queues rather than cancels — and the two group names are different,
because nobody wrote them at the same time.
A group is only a queue for the runs inside it. Two names means two queues means no ordering between them, so a manual deploy and an automatic one can write to the same target simultaneously. Reviewing either file on its own shows nothing wrong, which is exactly why this survives: the defect is in the relationship between the files, and no file contains it.
The same thing happens inside one file when two jobs name the same
environment: and each carries its own job-level
concurrency:.
- uses: actions/checkout@v4
with:
ref: main # resolved when the step runs
A branch name is a pointer, and it is read at the moment the step executes
— not when the run was queued. A job that waited twenty minutes for a
runner checks out whatever main means twenty minutes later. A
re-run of last week’s deploy, clicked to “redeploy that
version”, deploys today’s.
This one is less about ordering than about identity: the run says it deployed commit A because A triggered it, and it shipped the tree at C. The run log records the ref, not the commit it resolved to, so afterwards there is often nothing that says which code went out.
with:
ref: ${{ github.sha }}
Because nothing fails. Both runs do exactly what they were asked to do, and neither is aware the other existed. The branch ends up showing two green checks in commit order, so the obvious reading — that the newest commit is what is deployed — is wrong, and nothing on the page says so. The run that applied the older code does not report overwriting anything, because from inside that run nothing was overwritten.
The behaviour is identical: no run ever waits for another. The difference is that now there is a block to point at. A reviewer who sees a correctly spelled concurrency section with an obviously well-intentioned group key ticks the line off, so the defect is protected by the thing that looks like its fix. It usually arrives by copying a CI workflow, where keying the group on the ref is correct and useful, and then changing the key while keeping the shape.
By the time a cancel signal arrives, a deploy may have uploaded 300 of 900 files, applied a migration the new code has not shipped for yet, or replaced half a release directory. The run stops where it is and nothing comes back to finish it, so the result is a state neither commit produced — which is worse than either of them being live. Queueing makes the newer run wait and then apply last, which is the order you wanted.
A concurrency group is only a queue for the runs inside it. A repository with a push-triggered deploy and a manual deploy usually has two files, each declaring a group, each queueing rather than cancelling, and the two group names differ because nobody wrote them at the same time. Two names means two queues, which means no ordering between them. Reviewing either file alone shows nothing wrong, because the defect is in the relationship between the files and no single file contains it.
A branch name is a pointer read at the moment the step executes, not when the run was queued. A job that waited twenty minutes for a runner checks out whatever that branch means twenty minutes later, and a re-run of last week's deploy clicked to redeploy that version deploys today's. The run log records the ref rather than the commit it resolved to, so afterwards there is often nothing that says which code actually went out.
No. Serializing runs makes them apply in queue order, which is what you want almost always. It does not make a deployment atomic, it does not help if the deploy step itself is not idempotent, and it does nothing about a target that pipelines outside this repository also write to. It is the cheapest correct thing, not the last word.
Serializing runs makes them apply in queue order, which is what you want almost always. It does not make deployment atomic, it does not help if the deploy step itself is not idempotent, and it does nothing about a target that several pipelines outside this repository also write to. It is the cheapest correct thing, not the last word.
Check a workflow of your own — free, in your browser, nothing uploaded. Or read the comparison against reading them yourself.
Free and provided as is, with no warranty of any kind. This page describes patterns and is not professional advice, a security audit, or a guarantee about any pipeline; check anything here against your own workflows before acting on it. Nothing on this page is uploaded, stored, or tracked — it makes no network request at all. Sponsoring is optional and changes nothing about the tool.