Make CI a Function of the Current Commit with GitHub Actions Concurrency Groups
Rapid PR pushes spawn stale CI runs that waste minutes and confuse required checks. Learn how to scope GitHub Actions concurrency groups to cancel superseded pull_request runs while leaving push and deploy workflows untouched.
31 Jul 2025, 17:14 UTC

The problem: three pushes, three runs, one branch tip
You are reviewing a pull request. The author pushes three commits in quick succession to address feedback. Each push triggers your CI workflow. Within minutes the Actions tab shows three runs: one completed, one in progress, one queued. The completed run reflects the first commit, not the branch tip. The queued run will not start for another twenty minutes because the in-progress run is still executing. Required checks show a mix of green, red, and pending statuses that no longer describe the same code.
This happens whenever contributors iterate quickly on a feature branch, and it wastes runner minutes on results nobody will read.
Thesis: CI should track the latest commit, not every historical push
Instead of treating every push as a separate event that deserves its own full pipeline, treat continuous integration as a function of the current commit on a ref. GitHub Actions supports this through workflow-level concurrency groups. A concurrency group gives runs that share a key a single slot. When you add cancel-in-progress, a new run in the same group cancels the older one before it starts queueing.
The engineering decision is to scope that cancellation narrowly: cancel superseded runs for pull_request events, but let push, release, and deployment workflows queue normally.
How concurrency groups work
Concurrency is declared at the top level of a workflow file, not inside individual jobs. You supply a group string and an optional cancel-in-progress setting. The group string can interpolate context expressions such as github.workflow, github.ref, github.head_ref, or github.event.pull_request.number. Runs whose evaluated group strings match share a slot; runs with different group strings remain independent.
Enforcement happens per repository. Cancelling a run does not create additional runner capacity; it simply stops work that is already queued or running so the replacement can start sooner.
A worked example for PR-triggered CI
Place the following block near the top of .github/workflows/ci.yml on your default branch. Editing this file requires write access to the repository.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Here is what each placeholder does:
github.workflowexpands to the workflow name, so two different workflows on the same branch do not cancel each other.github.refexpands to the Git ref that triggered the run, such asrefs/heads/mainorrefs/pull/42/merge. This keeps different branches independent.- If you prefer to key by PR number or head branch name instead of the merge ref, you can substitute
github.head_reforgithub.event.pull_request.number. cancel-in-progressevaluates totrueonly when the event ispull_request. Forpushorscheduleevents the expression isfalse, so runs queue behind each other.
Expected checks: after merging this change, open the Actions tab and push two commits to a PR branch within seconds. The first run should transition to a cancelled conclusion, and the second run should proceed. If you push to a branch without an open pull request, the push event should leave earlier runs queued rather than cancelling them.
Risks: a long-running test job that is 90% complete will still be killed if a new commit arrives. If your pipeline includes deployment or release steps, do not apply this pattern to those workflows; a cancelled deploy can leave an environment in a partial state.
Comparing trigger behavior
| Trigger | Group key | Cancels prior run? |
|---|---|---|
pull_request | ci-refs/pull/42/merge | Yes |
push | ci-refs/heads/main | No |
schedule | ci-refs/heads/main | No |
Trade-offs and limitations
Cancellation is a blunt instrument. It saves minutes on obsolete work, but it cannot distinguish between a stale run and one that was about to finish. If your CI takes forty minutes and contributors push every five minutes, you may cancel runs that were still producing useful signal.
More importantly, concurrency groups do not solve integration drift. Two PRs can each pass in isolation and still conflict when merged. Cancellation does not replace branch protection or a merge queue. If your repository requires checks to be up to date with the base branch, a cancelled required check can briefly block a merge until the replacement run reports. That is usually the correct behavior, but reviewers should understand why the check is pending.
Finally, GitHub may change defaults, expression availability, and plan gating for Actions features. Workflow-level concurrency has been available for several years, but verify current behavior in the official documentation before relying on version-specific semantics.
What to do next
- Add the
concurrencyblock to the two or three workflows that run on every push or pull request. Start with test, lint, and build workflows. - Leave deployment, release, and any workflow that mutates external state uncancelled.
- Watch the Actions run list for one week. Compare the number of cancelled versus completed runs, and compare runner-minute consumption before and after the change.
- Decide per workflow whether
pull_request,push, orscheduletriggers should cancel or queue. A nightly security scan probably should queue; a fast unit-test suite probably should cancel.
If you need to revert, remove the concurrency block and merge the change to the default branch. Runs already in flight are unaffected, but subsequent runs will queue normally.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.