← Deploy Concurrency Check

How a deploy ships an older commit

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.

The shape of it

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.

1. No concurrency group at all

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.

2. A group that is different on every run

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.

3. cancel-in-progress on a deploy

For 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

4. Two groups for one target

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:.

5. Deploying a branch name instead of a commit

- 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 }}

Questions

Why does a deployment race not show up as a failed check?

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.

Why is a per-run concurrency group worse than no concurrency block at all?

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.

Why should a deploy queue instead of cancelling?

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.

Why can two correct workflow files still race each other?

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.

Why is deploying a branch name a problem if the branch is correct?

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.

Does adding concurrency make a deployment safe?

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.

Why concurrency is not the whole answer

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.