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.

Three pushes, with and without a concurrency groupWithout concurrency control, three pushes start three runs that all execute to completion, and only the third one's results are read. With a concurrency group set to cancel in progress, the first two are stopped when superseded and only the third runs to completion.without a concurrency grouprun 1 β€” completes, ignoredrun 2 β€” completes, ignoredrun 3 β€” the only one readpushpushpushwith cancel-in-progresscancelledcancelledrun 3 β€” completessame answer, roughly a third of the compute, and the queue frees up for everyone else

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

Cancel checks; queue deploymentsTests, linting and builds are safe to cancel because a superseded result is worthless and nothing outside CI is affected. Deployments, migrations and release publishing must never be cancelled mid-flight, so they use a separate group that queues instead.safe to cancelcancel-in-progress: trueunit and integration testslinting and formatting checkstype checking and compilationpreview builds for reviewnothing outside CI changes statenever cancelcancel-in-progress: falsedeployments to any environmentdatabase migrationspackage publishing and tagginginfrastructure apply stepsan interrupted run leaves state undefined

# .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: true to 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
What happens to an incoming runAn arriving run computes its group key. If no run holds that group it starts immediately. If one does and cancellation is enabled it cancels the holder and takes its place. If cancellation is disabled it queues until the holder finishes.run arrivescompute group keygroup is freenothing else holds itheld, cancel enableda check workflowheld, cancel disableda deploymentstart immediatelycancel the holder,take the groupqueue behind it

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.