Best practice · Trigger Scope

Cancel superseded runs with concurrency groups

Add a GitHub Actions `concurrency` group scoped to the ref and turn on `cancel-in-progress` for PR runs (it defaults to `false`), so a new push cancels the now-obsolete run instead of leaving both to occupy runners.

AI agents open the PR

StarSling agents inspect your workflow, apply this optimization, and open a reviewable PR automatically.

Do this

PR workflows declare a top-level concurrency group keyed on the workflow and ref, with cancel-in-progress set. Here is what the key actually does. It defaults to false: the run already in progress finishes, and the new run sits as pending until it does. Set it to true and the in-progress run in that group is cancelled immediately, so the new commit starts right away. That is what you want on a PR, where the superseded run is testing a commit no one is waiting on, and what you do not want on main or a release workflow, where every commit's run may deploy or publish and must complete. Scoping the value to the event (rather than a bare true) gives you both. The group itself is broad enough (per-workflow-per-ref) that it doesn't serialize unrelated jobs.12

One live run per ref; cancel superseded PR runs
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  # Cancel superseded runs on PRs; let main / release runs finish.
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# On a workflow that ONLY runs on pull_request, the plain literal is fine:
#
#   concurrency:
#     group: ${{ github.workflow }}-${{ github.ref }}
#     cancel-in-progress: true
#
# The default is cancel-in-progress: false, which lets the in-progress run
# finish while the new run waits as pending. Never set the literal true on a
# workflow that also runs on push to main or on a release tag.

Avoid this

Each new push leaves the old run going, so obsolete commits keep occupying runners.

No concurrency: obsolete runs pile up
# No concurrency block. Every push to a PR starts another full run,
# and the superseded ones keep occupying runners until they finish.

Seen in the wild

Grafana, Moby, and Next.js use concurrency groups so stale pushes stop burning runner time after a newer run supersedes them.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  # Cancel stale PR runs without interrupting push, tag, scheduled, or
  # manually dispatched validation.
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Cancels pull-request runs only. Pushes, tags, and scheduled validation are left to finish, and the comment says why.
concurrency:
  # Limit concurrent runs to 1 per PR, but allow concurrent runs on canary branch
  group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}-{2}', github.workflow, github.ref_name, github.run_id) }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
The same conditional, with the concurrency group keyed per pull request so two branches never cancel each other.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}
The group is keyed per ref and cancellation switches on only for pull-request refs, so a rebuild supersedes its own branch and never a run on main.

Why it matters

Without a concurrency group, rapid pushes to a PR leave several full runs racing, every obsolete one still burning a runner until it ends. Cancelling superseded runs reclaims that compute and frees capacity for runs that matter. Keep the group scoped to the ref, not a global constant, an over-narrow or over-broad group is its own problem (it can serialize or cancel jobs you didn't mean to).

When to use

Use it when

PR- and branch-push workflows where developers commonly push several times in quick succession.

Be careful when

Do not use cancel-in-progress: true on the default branch or release workflows where every commit must complete (each may deploy or publish). Scope cancellation to PRs; let main runs finish.

Verify on your repo

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.

Prompt for your coding agent
Inspect this repo's .github/workflows for a top-level `concurrency:` block on `pull_request` and `push` workflows. Flag any that have none, or one with `cancel-in-progress: false`, so obsolete runs pile up on rapid pushes. Add a `concurrency` group keyed on `${{ github.workflow }}-${{ github.ref }}` with `cancel-in-progress` scoped to PRs, and make sure the default branch and release workflows still let every run finish (never set `cancel-in-progress: true` on `main`). Confirm the group includes `github.ref` so it does not serialize unrelated PRs into one lane. Show me the diff and open a PR rather than applying it blindly.

Ground these changes in the upstream docs before you edit: https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand?

  1. Check PR-triggered workflows for a top-level concurrency: block: grep -rn 'concurrency' .github/workflows/.

  2. Flag pull_request/push workflows with no concurrency group, or one with cancel-in-progress: false.

  3. Confirm the group key includes github.ref (per-ref), not a global constant that would serialize every PR into one lane.

Automate this with StarSling.

Install the StarSling GitHub App. It moves supported jobs onto StarSling runners, then AI agents keep inspecting your workflows and opening optimization PRs you review.

More best practices for GitHub Actions

Where to go next in the CI best-practices catalog.

All CI best practices

FAQ

What does cancel-in-progress do, and what is its default?

cancel-in-progress decides what happens to the run already going in a concurrency group when a new one arrives. It defaults to false, so the in-progress run finishes and the new run waits as pending. With cancel-in-progress: true, GitHub cancels the in-progress run in that group immediately and starts the new one. Nothing else changes: the group key still decides which runs share a lane.

Where does the concurrency block go, workflow or job level?

Put it at the top level of the workflow so it governs the whole run. Job-level concurrency is for finer control, but for cancelling superseded PR runs the workflow-level group keyed on github.ref is what you want.

Is it safe to cancel in-progress runs on main?

No. On the default branch or a release workflow, every commit typically needs its run to complete, cancelling could skip a deploy or publish. Scope cancel-in-progress: true to PR refs and let main runs finish.

Sources

1GitHub Actions · concurrency syntax (opens in new tab)

2GitHub Actions · concurrency (opens in new tab)

Last updated 2026-07-16