How Do I Stop a Workflow From Blocking Deploys?
Give deploys a concurrency group of their own and leave cancel-in-progress off it, so test runs cannot queue ahead of a release or cancel one mid-flight.
Answer
Give deploys a concurrency group of their own, named for the deployment target rather than for the branch, and leave cancel-in-progress off that group so it keeps its default of false. Checks then throttle inside their own group, a release stops waiting behind a test run that happened to resolve the same group string, and a merge that lands during a deploy can no longer cancel the deploy that is already applying changes.
The group name is the whole mechanism. GitHub matches on the resolved string across the repository rather than per workflow file, and the name is case insensitive, per the concurrency section of the workflow syntax reference, checked on 2026-08-13. A checks workflow with group: ${{ github.ref }} and a deploy workflow with the same expression are one lane, which is how a release ends up parked behind a test run nobody was waiting on. The concurrency group glossary entry covers the term itself.
Two files, two lanes:
# .github/workflows/ci.yml
name: checks
on:
pull_request:
push:
branches: [main]
concurrency:
group: checks-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
test:
runs-on: warp-ubuntu-latest-x64-4x
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/12# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
queue: max
jobs:
deploy:
runs-on: warp-ubuntu-latest-x64-8x
environment:
name: production
url: https://app.example.com
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- run: terraform init -input=false
- run: terraform apply -auto-approve -input=falseThree keys carry the behavior. group: deploy-production is a fixed string, so every merge to main lines up in one release lane no matter which branch it came from. cancel-in-progress: false keeps a running apply alive when the next merge arrives. queue: max lets up to 100 runs wait in order rather than dropping all but the newest, and it cannot be combined with cancel-in-progress: true. Under the default queue: single the lane holds one running item and one pending item, and a third arrival cancels the pending run, which is the right shape when you only ever want the newest commit deployed.
The environment key does the second half of the work. A job bound to an environment stays in a waiting state until every protection rule on that environment passes, and GitHub dispatches it to a runner only after that, per the environments documentation, checked on 2026-08-13. Required reviewers accept up to 6 users or teams and one approval releases the job, while a wait timer accepts 1 to 43,200 minutes. Both holds happen before the job takes a machine, so an approval that sits overnight accrues no runner minutes. The deployment environment glossary entry has the full rule table.
Detail
The failure this prevents
Setting cancel-in-progress: true on a lane that contains a deploy job turns every merge into a chance to stop a release halfway. A cancelled job ends its running step where it stands, so a terraform apply or a schema migration leaves the target holding whatever it reached.
| Time | Event | Result |
|---|---|---|
| 14:02 | Merge A to main starts deploy run 88, terraform apply begins | Run 88 running, 9 of 14 resources applied |
| 14:05 | Merge B to main arrives in the same group with cancel-in-progress: true | Run 88 cancelled mid-apply, state file written by nobody |
| 14:06 | Run 89 plans against that state | Plan shows drift it did not create, or blocks on a held lock |
| 14:20 | An engineer reconciles state by hand | The release lands 18 minutes late with manual steps in it |
The queued alternative costs the wait for run 88 to finish. That trade is why deploy lanes keep the default.
Three holds that look the same in the run view
A deploy that has not started produces almost the same screen in every case. The distinguishing detail is where the run stops.
| What you see | Hold responsible | Fix |
|---|---|---|
| A Waiting banner naming a concurrency group | Group string shared with another workflow | Rename the deploy group to a fixed target name |
| A queued deploy flips to Canceled when a newer merge lands | queue: single with a full lane, or cancel-in-progress: true | Set queue: max, leave cancel-in-progress at false |
| A Review pending or Waiting banner naming an environment | Required reviewers or a wait timer | Expected behavior, no runner is held |
| Queued with no runner assigned and no banner | Runner capacity behind the runs-on label | Add capacity or move the label |
| Queued forever with no logs | Labels match zero online runners | Check the label spelling against the catalog |
The last two rows are capacity rather than YAML, and editing the concurrency key does nothing for them. The guide to GitHub Actions concurrency limits walks each symptom to its mechanism.
When the block is capacity
On GitHub-hosted runners the concurrent job ceiling is account wide, from 20 jobs on Free to 500 on Enterprise (GitHub usage limits, checked on 2026-08-13). A 12-shard test matrix in one repository therefore delays the deploy job in another repository on the same account, and no concurrency group in either file changes that.
Jobs on warp- labels queue on pool capacity instead. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a checks matrix fanning out does not take the slot the deploy job needs. The scope for that statement sits in the cloud runners documentation: features that are Generally Available support unlimited concurrency on Linux and Windows runners.
Deploy jobs also hold production credentials, which is why the isolation model matters as much as the queue. Each runner runs in its own virtual machine, created on demand and destroyed after the build, and build secrets stay in the repository rather than with the runner provider (security documentation).
What the two lanes cost per merge
Rates and shapes below come from the cloud runners documentation, checked on 2026-08-13, and match the labels in the workflow files above.
| runs-on label | OS | vCPU | RAM | Storage | Rate per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 |
Assume 12 checks shards at 6 minutes each and one deploy job at 9 minutes, with an approval that sits for 40 minutes before someone clicks it.
| Item | Minutes | Rate | Cost |
|---|---|---|---|
Checks, 12 shards on warp-ubuntu-latest-x64-4x | 72 | $0.008 | $0.576 |
Deploy, 1 job on warp-ubuntu-latest-x64-8x | 9 | $0.016 | $0.144 |
| Approval wait before dispatch | 40 | no runner assigned | $0.00 |
| Total per merge | 81 billed | $0.72 |
At 120 merges to main a month that is $86.40. Every deploy cancelled mid-flight and re-run adds another $0.144 in billed minutes plus whatever the manual reconciliation costs, which is the part that does not show up on an invoice.
Per-minute rates for every runner type are on the pricing page.
Related Questions
Should a deploy workflow set cancel-in-progress?
Leave it at the default of false on any group that contains a deploy job. Cancelling a run that is halfway through applying infrastructure or migrating a database stops the step where it stands and leaves the target in whatever state it reached, which costs more to reconcile than a queued release costs to wait for. Use queue: max when every commit should ship in order, and the default queue: single when only the newest commit matters. The guide to deployment jobs on GitHub Actions shows the full job shape.
Why does my deploy job wait when no other deploy is running?
Three holds look identical in the run view. A concurrency group whose resolved string matches another workflow parks the run as pending, an environment protection rule holds the job before GitHub dispatches it, and a busy runner pool leaves the job queued with no runner to take it. Check the group name first, then the environment, then queue wait for the label. The mapping from symptom to mechanism is in the guide to GitHub Actions concurrency limits.
Does a concurrency group make a deploy start sooner?
No. A group only holds work back or cancels it, as the concurrency group glossary entry sets out. Giving deploys their own group removes the wait caused by sharing a lane with checks, and time spent waiting because every runner is busy is a separate problem with a separate fix. Protection rules on a deployment environment add a deliberate hold that no group setting overrides.
Split the lanes, then price the deploy job against the per-minute rates on the pricing page.
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.