How Do I Cancel In-Progress Runs on a New Push?
Declare a concurrency group keyed on the branch ref and set cancel-in-progress to true, so each new push replaces the run already going instead of adding to it.
Answer
Declare a concurrency group keyed on the ref and set cancel-in-progress: true, so the newest push takes the lane and the run already going is cancelled. The two keys live at the top level of the workflow file and are documented in GitHub's concurrency syntax reference, checked on 2026-08-13.
name: ci
on:
push:
branches-ignore: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4Two things follow from that block. A concurrency group holds one running item and one waiting item, so the second push parks and the third cancels whatever was parked. And the cancellation reaches the runner as a message rather than as an instant kill, which is why a cancelled job still bills the minutes it ran up to that point, covered in full in how a cancelled GitHub Actions job is billed.
What you get back depends on where the jobs run. On GitHub-hosted pools, cancelling superseded runs returns concurrent job slots to the per-account ceiling as well as minutes (GitHub usage limits, checked on 2026-08-13). On warp- labels there is nothing to unblock. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out (cloud runners documentation). What the group returns there is billed minutes.
Detail
Pick the group expression that matches your triggers
The group is a string, and the lane is the string. Any expression that resolves differently across two events puts those events in different lanes. The three context values people key on resolve like this, per GitHub's context reference, checked on 2026-08-13.
| Event | github.ref | github.ref_name | github.head_ref |
|---|---|---|---|
Push to branch feature-login | refs/heads/feature-login | feature-login | empty |
Pull request 42 from feature-login | refs/pull/42/merge | 42/merge | feature-login |
Push of tag v1.4.0 | refs/tags/v1.4.0 | v1.4.0 | empty |
A workflow that fires on both push and pull_request produces two runs for one commit. Keyed on github.ref, those runs sit in separate lanes and neither cancels the other, so the push storm you were trying to stop keeps costing double. github.head_ref || github.ref_name resolves to feature-login on both events and collapses them into one lane.
Prefix the group with something stable when several workflows share the repository. ci- in the example above works, and ${{ github.workflow }} works as well. Skipping the prefix is what causes a lint workflow and a test workflow on the same branch to cancel each other, since group membership is scoped to the repository rather than to a file.
Keep protected refs out of the cancel path
cancel-in-progress accepts an expression, so one workflow can cancel feature branch runs and leave the default branch alone:
concurrency:
group: ci-${{ github.head_ref || github.ref_name }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}That shape keeps the post-merge run on main intact, which matters when a merge queue, a release tag, or a deploy gate reads its result.
What one push storm costs with and without the group
Assumptions, stated up front: one pull request branch, four matrix jobs per run, ten minutes per job when a run completes, all four on warp-ubuntu-latest-x64-8x at $0.016 per minute (cloud runners documentation, checked 2026-08-13). Four commits land at minutes 0, 4, 9, and 15. Cancellation is treated as immediate and per-minute rounding is ignored, both of which make the second column slightly optimistic.
| Push at minute | Without a concurrency group | Job-minutes | With cancel-in-progress | Job-minutes |
|---|---|---|---|---|
| 0 | Runs 0 to 10, completes | 40 | Cancelled at minute 4 | 16 |
| 4 | Runs 4 to 14, completes | 40 | Cancelled at minute 9 | 20 |
| 9 | Runs 9 to 19, completes | 40 | Cancelled at minute 15 | 24 |
| 15 | Runs 15 to 25, completes | 40 | Runs 15 to 25, completes | 40 |
| Total | 160 | 100 |
The storm bills 160 job-minutes at $2.56 without the group and 100 job-minutes at $1.60 with it. A repository that sees 60 storms of this shape in a month keeps 3,600 job-minutes, which is $57.60 on that label.
Label choice moves the same 60 saved job-minutes a long way. All four bill per minute for the time a job holds the machine. Rates below come from the cloud runners documentation, checked on 2026-08-13.
| Label for the same four-job storm | Rate per minute | 160 job-minutes | 100 job-minutes | Kept per storm |
|---|---|---|---|---|
| warp-ubuntu-latest-arm64-8x | $0.012 | $1.92 | $1.20 | $0.72 |
| warp-ubuntu-latest-x64-8x | $0.016 | $2.56 | $1.60 | $0.96 |
| warp-windows-latest-x64-8x | $0.032 | $5.12 | $3.20 | $1.92 |
| warp-macos-latest-arm64-6x | $0.08 | $12.80 | $8.00 | $4.80 |
An iOS branch reaches the same monthly figure in a fifth of the pushes a Linux branch needs. The full billing rules for a job that stops early, including the cleanup steps that keep billing after the cancel signal, are covered in how a cancelled GitHub Actions job is billed.
Where not to put cancel-in-progress
Cancellation is safe for work that only writes to the runner. It is unsafe for jobs that mutate state elsewhere: deploys, database migrations, terraform apply, package publishes, and release tagging. GitHub gives a cancelled job up to five minutes before forcing termination (workflow cancellation reference, checked 2026-08-13), and a migration interrupted anywhere in that window costs more engineering time than the minutes ever saved.
Give that work its own lane:
concurrency:
group: deploy-production
cancel-in-progress: falseOne behavior comes with it. Under the default queue: single, one run waits and a newer arrival cancels the waiting run, so the deploy that eventually runs is the newest commit rather than every commit in order. queue: max lets up to 100 runs wait in order, and it cannot be combined with cancel-in-progress: true, since one setting preserves work in flight while the other ends it (concurrency syntax reference, checked on 2026-08-13).
Confirm the change in the reports
The CI tab of the billing report has one row per job execution with execution time, billed time, and cost, so cancelled runs appear as short rows rather than missing ones (reports documentation). Filter by runner label, set the range to a week before and a week after the change, and compare total billed minutes on the branch label.
Related Questions
Does cancel-in-progress cancel runs on other branches?
Only when they land in the same group. Cancellation is scoped to the group string, so a group keyed on the branch gives one lane per branch and leaves every other branch alone. Group names are case insensitive and scoped to the repository, so two workflow files whose expressions build the same string share one lane and cancel each other. The guide to GitHub Actions concurrency limits separates this behavior from the plan ceiling and from runner capacity, which produce different symptoms.
Why do the push run and the pull request run for one commit not cancel each other?
Because github.ref differs between the two events: refs/heads/feature-login on the push and refs/pull/42/merge on the pull request. A group keyed on github.ref therefore creates two lanes for the same commit. Key on github.head_ref with github.ref_name as the fallback and both events resolve to the branch name, which puts them in one lane. The concurrency group definition covers the expression contexts each level accepts.
How do I keep deploy and release runs from being cancelled?
Give them their own group and set cancel-in-progress: false, so a new run waits instead of interrupting the one holding the lane. Under the default queue: single, only one run waits, and a newer arrival cancels the waiting run, so the deploy that eventually runs is the newest commit rather than every commit in order. Repeated manual re-runs create the same pile-up on the wrong side of that gate, and the guide to re-run storms covers the patterns that cause them.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.