GitHub Actions concurrency groups: cancel superseded CI runs without cancelling the wrong ones
A concurrency block with a group expression and cancel-in-progress stops superseded CI runs. Here is the queue rule, a worked YAML example, and the matrix and head_ref mistakes that break it.
06 Oct 2025, 02:22 UTC

The short answer
Add a concurrency block to a workflow file. GitHub evaluates the group string when a run is queued; runs that resolve to the same string share a queue, and cancel-in-progress: true tells GitHub to stop the older run instead of letting both finish. On a branch where you push three times in two minutes, that means one run completes and two are cancelled rather than three competing for runners.
A worked configuration
Place this at the top level of .github/workflows/ci.yml, alongside name and on:
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
Reading the group expression left to right: github.workflow is the workflow's name value, so two different workflows never share a queue. Then the expression picks a per-target identifier — the pull request number when the event is pull_request, and otherwise github.ref, which looks like refs/heads/feature/login on a push.
The || operator is the part that matters. On a pull_request event, github.event.pull_request.number is populated, so the group stays stable across every push to that PR even when the head branch lives in a fork. On a push event that field does not exist, the expression falls through to github.ref, and each branch gets its own group.
What actually happens in the queue
Within one group, a repository holds at most one running run and one pending run. When a new run is queued:
- Any previously pending run in that group is cancelled immediately.
- If a run is currently running and
cancel-in-progressis true, that run is also cancelled. - The new run becomes pending, then starts when a runner is free.
So the group behaves as a single-slot queue with a replacement policy, not a general-purpose scheduler. There is no queue depth setting, no priority field, and no way to say "keep the last three".
Workflow-level versus job-level groups
Job-level concurrency is evaluated separately from workflow-level concurrency. A job still belongs to its workflow's group and can be cancelled by it; the job-level block adds a second, narrower queue on top.
That makes job-level groups the right tool when you want to serialise one thing — a deploy step, or a job that writes to a shared environment — without throttling the whole pipeline:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
steps:
- run: ./deploy.sh
With cancel-in-progress: false, a second deploy queues behind the first instead of interrupting it. That is usually what you want for anything that changes external state.
Protecting a branch from cancellation
cancel-in-progress accepts an expression, so you can cancel aggressively on feature branches while letting main-branch runs finish:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
The expression evaluates to true on any ref that is not refs/heads/main, and false on main. Note that this compares the full ref, not the short branch name — github.ref_name would give you main without the refs/heads/ prefix.
Common mistakes
Using github.head_ref in the group
github.head_ref is populated only for pull_request events. On a push it resolves to an empty string, so every branch's group collapses to something like CI-. Unrelated branches then serialise against each other, and with cancel-in-progress: true they cancel each other's runs. Use github.ref as the fallback, or github.head_ref || github.ref if you specifically want the branch name on pull requests.
Giving every matrix leg the same job-level group
If a job uses a matrix and you set group: ${{ github.workflow }} at job level, all legs land in one group and cancel one another — you get one surviving leg instead of a full matrix. Include the matrix value:
concurrency:
group: ${{ github.workflow }}-${{ matrix.os }}-${{ github.ref }}
Assuming cancellation is instant
Cancellation is cooperative. A step already executing may run for a while before the runner stops it, and long-running steps interrupted mid-way can still consume runner minutes. Treat cancellation as a way to stop future work, not as a hard kill.
Cancelling work that must not be interrupted
Do not put cancel-in-progress: true on workflows that run production deployments or database migrations unless the operation is genuinely idempotent and safe to interrupt mid-flight. A half-applied migration is worse than a slow queue.
Limits worth knowing before you rely on this
| Limit | Practical consequence |
|---|---|
| Groups are scoped to one repository | You cannot serialise work across repositories with this feature |
| No queue depth or priority control | One running plus one pending run per group; older pending runs are dropped |
| Cancellation is cooperative | In-flight steps may run briefly before stopping |
| Group names are matched as strings | Do not rely on letter case to keep groups distinct |
| A run waiting on environment approval still occupies its group | Newer runs may queue behind an approval nobody has clicked |
One more interaction to check: a cancelled run reports a cancelled conclusion. If a branch protection rule requires a status check with a particular name, confirm that a later run publishes that same check name before you depend on cancellation for required checks.
How to verify it works
Push two commits to the same branch a few seconds apart. Then, from a terminal with the GitHub CLI authenticated (gh auth status to confirm), run:
gh run list --limit 10
gh run view <run-id>
The first run should show a cancelled conclusion and the second should complete. gh run view shows which jobs were cancelled, which is the quickest way to tell a workflow-level cancellation from a job-level one.
If the group string is not resolving the way you expect, add a temporary step that prints the inputs:
- name: Debug concurrency inputs
run: |
echo "workflow=${{ github.workflow }}"
echo "ref=${{ github.ref }}"
echo "head_ref=${{ github.head_ref }}"
Run it once from a push and once from a pull_request and compare the values. Remove the step afterwards — it adds noise to every run.
Because GitHub changes Actions behaviour over time, check the current workflow syntax documentation for the concurrency key before treating any of this as permanent. The expression contexts available inside concurrency and the queue rules are the parts most likely to shift.
Rolling it back
The change is confined to the workflow YAML. Delete the concurrency block — or set cancel-in-progress: false — and commit; the next run behaves as it did before. Runs already cancelled under the old configuration stay cancelled in the history and are not re-run automatically, so if a cancelled run was the only one that would have produced a required check, trigger a fresh run.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.