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=false

Three 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.

TimeEventResult
14:02Merge A to main starts deploy run 88, terraform apply beginsRun 88 running, 9 of 14 resources applied
14:05Merge B to main arrives in the same group with cancel-in-progress: trueRun 88 cancelled mid-apply, state file written by nobody
14:06Run 89 plans against that statePlan shows drift it did not create, or blocks on a held lock
14:20An engineer reconciles state by handThe 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 seeHold responsibleFix
A Waiting banner naming a concurrency groupGroup string shared with another workflowRename the deploy group to a fixed target name
A queued deploy flips to Canceled when a newer merge landsqueue: single with a full lane, or cancel-in-progress: trueSet queue: max, leave cancel-in-progress at false
A Review pending or Waiting banner naming an environmentRequired reviewers or a wait timerExpected behavior, no runner is held
Queued with no runner assigned and no bannerRunner capacity behind the runs-on labelAdd capacity or move the label
Queued forever with no logsLabels match zero online runnersCheck 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 labelOSvCPURAMStorageRate per minute
warp-ubuntu-latest-x64-4xUbuntu 24.04416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832GB150GB 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.

ItemMinutesRateCost
Checks, 12 shards on warp-ubuntu-latest-x64-4x72$0.008$0.576
Deploy, 1 job on warp-ubuntu-latest-x64-8x9$0.016$0.144
Approval wait before dispatch40no runner assigned$0.00
Total per merge81 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.

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.