Cancelling Superseded Workflow Runs with Concurrency Groups
A developer pushes three commits in four minutes. Three full CI runs start; the first two will be obsolete before they finish, and nobody will ever open their logs. Concurrency groups let the newest run cancel its predecessors — and, with cancellation switched off, let production deploys queue instead of racing.
When to use this pattern
- Developers push frequently while a run is still going, which is normal on an active branch.
- Runner minutes or queue depth are a constraint, and superseded runs are a visible share of them.
- Two deploys to the same environment can currently overlap.
Prerequisites
What a concurrency group is
A group is just a string. Two runs whose key strings match belong to the same group, and a group runs one thing at a time. cancel-in-progress decides what happens to the incumbent when a newcomer arrives: cancel it, or make the newcomer wait.
The asymmetry is deliberate and it is the whole design: validation results are disposable because a newer commit invalidates them, while deploys are not, because a cancelled deploy can leave an environment half-updated.
Complete working example
# .github/workflows/ci.yml — validation: newest wins.
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
# head_ref exists only on pull_request events; ref covers pushes. Including
# github.workflow keeps this workflow from contending with any other.
group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
- run: npm test# .github/workflows/deploy.yml — deploys: oldest finishes, newest waits.
name: Deploy
on:
push:
branches: [main]
concurrency:
# One group per environment. No branch in the key: two deploys to production
# must serialise even if they came from different refs.
group: deploy-production
cancel-in-progress: false # never interrupt a deploy mid-flight
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.shFor the middle case — a long release pipeline where an outdated queued run is pointless but a running one must not be killed — cancel only the pending entry:
concurrency:
group: release-${{ github.ref }}
# Available on newer runners: drops the waiting run, leaves the running one alone.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}Step-by-step walkthrough
Include the workflow in the key. Without github.workflow, two different workflows triggered by the same push land in one group and cancel each other — the classic symptom is a lint run that mysteriously dies whenever the test run starts.
Use the head_ref || ref fallback. github.head_ref is empty on push events, so a key built from it alone collapses to ci-- for every push on every branch. All of them then share one group, and each new push cancels an unrelated branch’s run. The fallback is two extra tokens and prevents a genuinely confusing outage.
Give deploys a key with no branch in it. The point of the deploy group is that the environment is the contended resource. Keying on github.ref would let a hotfix branch deploy to production while the main branch’s deploy is still running, which is exactly the race the group exists to prevent.
Never cancel a running deploy. A deploy cancelled between “uploaded new assets” and “switched the entry document” leaves an environment in a state nothing planned for. If a queue of pending deploys is a problem, batch the triggers or debounce them — do not solve it by cancelling.
Cancellation is not free for the developer. A cancelled run reports as cancelled, not success, and branch protection treats that as not-passing. That is correct — the newest run will report shortly — but it does mean the pull-request page shows a red interim state, which is worth mentioning to the team before enabling it.
Verification
# 1. Cancelled-run minutes over the last 200 runs, before and after.
gh run list --limit 200 --json conclusion,createdAt,updatedAt \
--jq '[.[] | select(.conclusion=="cancelled")
| ((.updatedAt|fromdateiso8601) - (.createdAt|fromdateiso8601))/60]
| { cancelled_runs: length, minutes_reclaimed: (add // 0 | floor) }'
# 2. Prove the group actually engages: push twice in quick succession.
git commit --allow-empty -m "concurrency probe 1" && git push
sleep 5
git commit --allow-empty -m "concurrency probe 2" && git push
gh run list --limit 2 --json displayTitle,status,conclusionExpected from the second probe — the older run cancelled, the newer one alive:
concurrency probe 2 in_progress
concurrency probe 1 completed cancelledIf both are in_progress, the group keys differ; echo github.workflow, github.head_ref, and github.ref in a step and compare the resolved strings.
Common pitfalls
One group for everything. A single hard-coded group name serialises the entire repository. Every pull request then waits behind every other, and the queue times get worse than the minutes you saved.
Cancelling a workflow that publishes. Anything that pushes a tag, publishes a package, or writes to an environment should not be interruptible. Split publishing into its own workflow with its own group rather than trying to protect it with a conditional inside a cancellable one.
Assuming cancellation reclaims the whole run. Minutes already consumed are billed; only the remainder is saved. The saving is real but it is roughly half the run on average, not all of it — worth knowing before you present the number.
How much this is worth
Before proposing the change, it helps to know the shape of the saving, because it is bimodal rather than uniform. Repositories where developers push once and wait see almost nothing; repositories where a review comment triggers three quick fix-up commits see a large fraction of their validation minutes disappear. The determining number is the share of runs that are superseded before they finish, and you can measure it directly from the run history.
A useful rule of thumb: if more than 15% of runs on pull requests are followed by another push to the same branch within the run’s own duration, concurrency cancellation will pay for itself immediately and needs no further justification. Below 5% the change is still correct — it costs nothing and removes a class of confusion where an old run reports after a new one — but it will not show up in the bill. The measurement is worth doing before the rollout so the claim you make afterwards is your own number rather than a vendor’s.
Related
- Optimizing Pipeline Concurrency & Queue Limits — the parent guide on runner contention and queue behaviour.
- Reducing GitHub Actions Minutes with Self-Hosted Runners — the other half of the minutes problem.
- Tracking CI/CD Compute Costs for Platform Teams — where the reclaimed minutes show up.
- CI/CD Pipeline Architecture & Fundamentals — the section overview.