In GitHub Actions, what does a concurrency group with cancel-in-progress do?
answer
- One lane per key
- The newest push wins, the middle ones vanish
- Only one run may wait
- The key expression is the whole design
- Fine for pull requests, dangerous for deploys
basics
~20 sA concurrency group allows only one run of that group at a time. With cancel-in-progress true, a new run cancels the one already running; with it false or absent, the new run waits, and only the newest waiting run survives.
solid answer
~40 s`concurrency` takes a `group` string — usually built from expressions such as `${{ github.workflow }}-${{ github.ref }}` — and serialises everything sharing that string. Set `cancel-in-progress: true` and a newly triggered run cancels the in-flight one; leave it out and the new run sits `pending` until the current one finishes. Crucially only **one** run may be pending per group: if a third arrives, the queued one is cancelled and replaced, so you always get the oldest running plus the newest queued. Group names are case-insensitive. You can declare `concurrency` at workflow level or on an individual job, and `cancel-in-progress` accepts an expression, which is how teams cancel superseded pull-request builds while never interrupting a deploy: `cancel-in-progress: ${{ github.event_name == 'pull_request' }}`.
code
yaml · 18 linesname: CI
# One lane per workflow + ref; only pull-request runs are disposable.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew buildgo deeper
Recall that concurrency limits a workflow to one run per group key and that cancel-in-progress: true cancels the older run. Know the common ${{ github.workflow }}-${{ github.ref }} group.
Explain the queueing mechanics precisely: at most one pending run per group, newer arrivals cancelling the queued one, case-insensitive group names, and the difference between workflow-level and job-level declarations.
Demonstrate the production judgment — cancellable pull-request builds versus non-cancellable deploys, an expression-valued cancel-in-progress, and cleanup steps guarded by if: cancelled() so a cancelled run leaves nothing locked.
Own the cost and safety tradeoff across the org: how group keys are standardised, where serialisation protects shared environments, and when a queued deploy lane becomes a delivery bottleneck that needs a different design.
## The problem it solves Push three commits to a pull request in five minutes and, by default, GitHub Actions starts three full runs. The first two are already obsolete — nobody will look at their results — but they hold runner capacity and burn minutes. On the other side of the pipeline, two deploy runs racing to the same environment is worse than wasteful: it is a correctness problem. `concurrency` is the one key that addresses both. ## Syntax ``` concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` `group` is any string; the expressions are just how you make it meaningful. Two runs are in the same group if their evaluated group strings are equal, compared **case-insensitively**. The shorthand `concurrency: my-group` sets the group with `cancel-in-progress` defaulting to false. ## The three-run rule The queueing behaviour is small but exact, and it is what interviewers probe. - **`cancel-in-progress: true`.** A new run cancels any in-progress run in the same group and starts. Cancellation propagates to the running jobs, which receive a cancellation signal; `if: always()` steps still get their chance to run, `if: cancelled()` steps run specifically on this path. - **`cancel-in-progress: false`** (the default). The new run enters `pending` and waits for the in-progress run to complete. - **A third arrival, either way.** Only one run may be pending per group. When a newer run arrives, the currently pending run is **cancelled** and the newcomer takes its place. So the steady state is at most one running plus one queued — the oldest in flight and the newest waiting, with everything in between discarded. ## Choosing the group key The group expression *is* the design decision. - `${{ github.workflow }}-${{ github.ref }}` — per workflow, per branch or PR. The default choice for build/test workflows: superseded commits on one branch cancel each other, unrelated branches never interfere. - `${{ github.workflow }}` alone — every branch shares one group. Almost always a bug: a push to a feature branch cancels the run for `main`. - `deploy-production` (a constant) — one global lane, typically with `cancel-in-progress: false`, so deploys queue rather than race. - `${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}` — a common variant keeping pull-request runs distinct from branch runs of the same ref. Note that `github.ref` differs by event: on a `pull_request` event it is `refs/pull/<n>/merge`, on a push it is `refs/heads/<branch>`. That is usually what you want — the PR run and the branch run stay in separate groups — but it explains why two runs you expected to collide sometimes do not. ## Workflow level versus job level Declared at the top level, `concurrency` governs the whole run. Declared inside a job, it governs that job only, and other jobs in the same run proceed independently. The idiomatic combination is an aggressive workflow-level group for the fast feedback path plus a conservative, non-cancelling job-level group on the deploy job: ``` concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: deploy: concurrency: group: deploy-production cancel-in-progress: false ``` `cancel-in-progress` accepting an expression is what makes that first block safe: pull-request runs are disposable and get cancelled, while runs on the default branch are never interrupted mid-deploy. ## Failure modes to name **Cancelling a deploy halfway.** Terraform mid-apply, a database migration mid-transaction, a partially-rolled-out service. Deploy jobs want a constant group and `cancel-in-progress: false`; if the work is genuinely not resumable, queueing is the only safe answer. **Too-broad a group.** Constant groups on a build workflow serialise the whole repository and make CI feel mysteriously slow — runs sit `pending` with no explanation on the run page beyond "waiting". **Expecting deduplication.** Concurrency does not merge runs or skip identical work; it cancels and queues. It also does not span repositories — groups are scoped to the repository. **Confusing it with a matrix limit.** `max-parallel` bounds parallel legs inside one job's matrix; `concurrency` bounds runs or jobs across the repository. Different keys, different scope. ## Why interviewers like it It is a two-line change with visible cost impact — often the single biggest reduction in wasted runner minutes available in a repository — and it has a genuine sharp edge, since the same two lines applied to a deploy workflow can interrupt production work. A candidate who can state the queueing rule *and* the deploy caveat has clearly run this in anger.
- Three pushes land on a branch while a run is already in progress and cancel-in-progress is false. What is the end state?The first extra run goes `pending`. When the next arrives it **cancels** the pending run and takes its place, and the third does the same — only one run may be queued per group. So the original run finishes, then the newest push runs; the intermediate ones are cancelled without ever starting.
- Why is concurrency: group: ${{ github.workflow }} usually the wrong key?It omits the ref, so every branch and pull request shares one lane. With `cancel-in-progress: true` a push to any feature branch cancels the run for the default branch; with it false, unrelated branches serialise behind each other and CI appears mysteriously slow. Include `${{ github.ref }}` so each branch or PR gets its own group.
- How do you cancel superseded pull-request builds without ever interrupting a production deploy?Make `cancel-in-progress` an expression: `${{ github.event_name == 'pull_request' }}`, so only PR runs are cancellable. Then give the deploy job its own job-level `concurrency` with a constant group such as `deploy-production` and `cancel-in-progress: false`, so deploys queue behind one another instead of racing or being killed mid-apply.
- What does a cancelled run do to the steps that were executing?Jobs receive a cancellation signal and stop. Steps with `if: always()` still get a chance to run, and `if: cancelled()` targets this path specifically — that is where you put artifact upload, teardown of external resources, or an unlock. Work that cannot be interrupted safely, such as a migration mid-transaction, should not be in a cancellable group at all.
saying these in an interview costs you the question
- Thinks queued runs all eventually execute in order
- Uses one global group for every workflow and branch
- Enables cancel-in-progress on the deploy workflow
- Confuses concurrency with matrix max-parallel
- Believes concurrency deduplicates identical work