Cancelling superseded CI runs with concurrency groups Jump to heading
Push three fixes to a branch in ten minutes and a typical pipeline starts three full runs, finishes all three, and reports three sets of results β of which exactly one, the last, will be read. The other two consumed runner minutes, occupied the queue that a colleagueβs genuinely current build was waiting in, and delayed the answer everyone actually wanted. A concurrency group fixes this in about four lines, and this recipe covers both the fix and the one place it must not be applied, extending the trigger design in CI/CD Pipeline Trigger Mapping.
When to use this approach Jump to heading
- Developers push several times in quick succession while iterating on a branch, which is normal and good.
- Queue time is a visible complaint, or runner minutes are metered and rising.
- Your pipeline is long enough that a superseded run is still executing when the next push arrives.
- You have already scoped which jobs run using path-specific triggers; concurrency is the complementary question of how many run at once.
- If your pipeline finishes in ninety seconds, the saving is small β spend the effort elsewhere.
Step 1 β Measure how much work is being wasted Jump to heading
# Runs on a branch over the last fortnight, newest first
gh run list --branch feat/payments --limit 50 \
--json databaseId,headSha,status,conclusion,createdAt,updatedAt
# How many distinct commits, versus how many runs?
gh run list --branch feat/payments --limit 50 --json headSha \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d),'runs on',len({r['headSha'] for r in d}),'commits')" What changed: nothing β but the ratio tells you whether this is worth doing. Runs greatly outnumbering merged commits means most of that compute produced results nobody consulted.
Step 2 β Choose a group key that isolates branches Jump to heading
The group key decides which runs compete. Get it wrong and you cancel a colleagueβs build.
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
# One group per workflow per ref: runs on different branches never interfere.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
What changed: a second push to the same branch cancels the first run; a push to any other branch is unaffected because its github.ref differs.
# Confirm the group key resolves per branch, not globally
gh run list --limit 5 --json name,headBranch,status,conclusion Include both the workflow and the ref. A key of just $ puts every workflow on the branch into one group, so a push cancels the lint run and the nightly security scan that happened to be running β a surprising and unwelcome interaction.
For pull requests, keying on the PR number rather than the ref avoids a subtle duplicate: a push to a PR branch produces both a push and a pull_request event, and without care they land in different groups and both run.
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
Step 3 β Exempt anything that must not be interrupted Jump to heading
# .github/workflows/deploy.yml β a separate workflow with the opposite policy
name: deploy
on:
push:
branches: [main]
concurrency:
# One deployment at a time per environment; a second one WAITS.
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.sh
What changed: deployments now serialise instead of overlapping, and a new one never interrupts one in flight.
SAFETY WARNING β applying
cancel-in-progress: trueto a workflow that deploys, migrates a database, or publishes a package can terminate it between two irreversible operations. The result is a partially deployed fleet or a half-applied migration, and the pipeline reports βcancelledβ rather than βfailedβ, so it does not page anyone. Split deploy jobs into their own workflow with their own group before enabling cancellation anywhere.
Step 4 β Verify both paths Jump to heading
# Cancel path: two pushes in quick succession
git commit --allow-empty -m "test: first" && git push
sleep 5
git commit --allow-empty -m "test: second" && git push
gh run list --branch "$(git rev-parse --abbrev-ref HEAD)" --limit 3
# Expect: the first run "cancelled", the second "in_progress" or "completed"
# No-cancel path: a deploy must survive a subsequent push
gh workflow run deploy.yml --ref main
sleep 5
git commit --allow-empty -m "test: push during deploy" && git push
gh run list --workflow deploy.yml --limit 2
# Expect: the deploy still running or completed β never cancelled Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Will cancelling runs hide a failure that mattered? Jump to heading
Only for a commit that has already been superseded on the same branch, which nobody is going to act on. The newest commit always runs to completion, and that is the one review and merge depend on. If you genuinely need per-commit results β bisecting a flaky test across a branch, for instance β push to a separate branch so each commit gets its own concurrency group.
Why must deployment jobs be excluded? Jump to heading
Because cancelling a deployment halfway through leaves the target in an undefined state: some instances updated, some not, migrations possibly half-applied. Deployments want the opposite policy β queue rather than cancel β so a second deploy waits for the first to finish instead of interrupting it. Use a separate concurrency group for them with cancellation disabled.
Does this interact with required status checks? Jump to heading
Yes, and it is worth checking. A cancelled run usually reports as cancelled rather than failed, and some platforms treat a cancelled required check as unsatisfied rather than pending. Since the superseding run reports shortly afterwards, this resolves itself β but if merges appear to be blocked by a cancelled check, that interaction is the reason.
Related Jump to heading
- CI/CD Pipeline Trigger Mapping β the parent guide: which events start which pipelines, and where concurrency fits.
- Optimizing CI Triggers for Path-Specific Changes β reduce which jobs run at all, the other half of pipeline cost.
- Caching pre-commit Environments in CI β make the runs that do happen finish faster.