Concurrency Controls and Deployment Queues

Concurrency controls and deployment queues decide whether GitHub Actions kills a redundant run or makes it wait in line, and mixing the two up is one of the fastest ways to leave a production deployment half finished. A concurrency group is a name you assign to a set of workflow runs. GitHub Actions guarantees that at most one run in a given group is active at a time. What happens to the others depends on a single setting: cancel-in-progress. When it is true, a new run in the same group stops the older one immediately. When it is false, the new run waits until the older one finishes, then starts. The first behavior is perfect for continuous integration, where a stale test result is worthless the moment a newer commit exists. The second behavior is what turns a pile of simultaneous deployment triggers into an orderly queue, which is exactly what you want once real infrastructure is on the line.

Overview / How it works

Every GitHub Actions workflow run can declare a concurrency block, either at the workflow level or inside an individual job. The block has two parts: a group, which is a string expression evaluated per run, and cancel-in-progress, a boolean. Two runs belong to the same group only if their evaluated group strings are identical. This matters more than it sounds: if your group expression only includes the workflow name, every branch and every pull request collapses into one shared lane, and a push to a feature branch can cancel a run on main. If your group expression includes the ref or the pull request number, each branch or pull request gets its own lane instead.

CI, continuous delivery, and continuous deployment behave differently under concurrency because they carry different risk. CI just re-verifies that a commit builds and passes tests, so cancelling a stale CI run costs nothing but a little compute time and the run restarts cleanly on the next push. Continuous delivery packages a release and stages it for a human-gated promotion, so a cancelled mid-package run can leave an artifact half built. Continuous deployment pushes that release straight to production automatically, so a cancelled mid-deploy run can leave a service half upgraded, with old and new code paths running side by side or a database migration applied without its matching application code. That is why deployment workflows almost always want cancel-in-progress: false and a queue instead of a cancellation.

Environments add a second, independent layer of control. A GitHub Environment such as production can require reviewers to approve a deployment, enforce a wait timer, or restrict which branches can deploy to it. Environment protection rules gate whether a deployment job is allowed to start at all; concurrency groups control the order and overlap of jobs that are already allowed to start. Combining both gives you a queue that only lets one deployment run at a time, and a gate that makes sure each one is intentional.

Syntax or workflow structure

The concurrency key can sit at the top of the workflow file, applying to every job, or inside a single job for finer control. The group value is usually built from GitHub context expressions so that unrelated branches and pull requests do not share a lane by accident.

name: WORKFLOW_NAME

on: [push, pull_request]

concurrency:
  group: SCOPE_EXPRESSION   # e.g. ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: BOOLEAN  # true cancels older runs, false queues them

jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - run: echo "job body"

The group expression commonly combines github.workflow with github.ref for branch-scoped pipelines, or with github.event.pull_request.number for pull request pipelines, since the ref for a pull request event is a merge ref that changes shape between pushes. cancel-in-progress can itself be an expression rather than a literal true or false, which lets one workflow behave differently depending on the event that triggered it: cancel stale pull request runs, but queue pushes to main instead of cancelling them.

Examples

The first example is the shape most teams start with: a pull request test workflow that should never waste minutes on a commit nobody will look at again.

name: CI

on:
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Run test suite
        run: npm test

After a contributor pushes a second commit to an open pull request, the run for the first commit is cancelled immediately and only the run for the second commit reports a status check, so reviewers never see two conflicting results for the same pull request.

The second example extends the group to cover both pull requests and direct pushes to main, but only cancels for the pull request case, because a push to main represents a commit that already merged and deserves a completed result even if a second push arrives moments later.

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

Every commit pushed directly to main gets a completed test run, queued one after another in the same lane, while pull request commits keep cancelling their predecessors, so main’s history of check results stays gapless.

The third example moves from CI to deployment. The concurrency group is a fixed string, deploy-production, not scoped by ref, because there is exactly one production environment and only one deployment should ever be in flight against it regardless of which commit or trigger started it. cancel-in-progress is false, so a second push to main while a deployment is running does not kill that deployment mid-way; it waits.

name: Deploy to Production

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  id-token: write

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - id: build
        name: Build and push image
        run: |
          docker build -t registry.example.com/app:${{ github.sha }} .
          docker push registry.example.com/app:${{ github.sha }}
          DIGEST=$(docker inspect registry.example.com/app:${{ github.sha }} --format '{{.RepoDigests}}')
          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Deploy image by digest
        run: ./scripts/deploy.sh --image "${{ needs.build.outputs.digest }}"
      - name: Health check
        run: ./scripts/health-check.sh https://app.example.com/healthz

If main receives three pushes within a minute, three deploy runs are queued against the deploy-production group. The first runs to completion, including its health check, before the second starts, and the second before the third, so production only ever sees one deployment’s worth of change at a time and each one finishes or fails cleanly before the next begins. Deploying by digest, captured as a job output from the build job, also protects the queue: whichever run reaches the front deploys the exact image that was built and pushed for it, not whatever a mutable tag happens to point to by then.

Step by step

  1. Decide the trigger: a push to the default branch, a manual workflow_dispatch for on-demand releases, or both.
  2. Choose a concurrency group name that identifies the deployment target rather than the branch or commit, such as deploy-production or deploy-staging, so every run targeting that environment shares one lane no matter what triggered it.
  3. Set cancel-in-progress to false for that group, so queued deployments wait instead of interrupting one another mid-flight.
  4. Set explicit minimal permissions on the workflow: contents: read to check out code, and id-token: write only if the deploy step authenticates to a cloud provider through OpenID Connect instead of a long-lived secret.
  5. Attach a GitHub Environment such as production to the deploy job, and configure required reviewers or a wait timer on that environment in repository settings, so a human or a delay stands between a queued run reaching the front of the line and it actually touching production.
  6. Deploy by digest rather than by a mutable tag, so the exact image that was built and scanned is the exact image that runs, and add a health check step after the deploy that fails the job if the new version does not come up cleanly.
  7. Give yourself a rollback route: either a separate workflow_dispatch workflow that redeploys a known-good digest, or a deploy script that keeps the previous digest available and can be invoked the same way.

You can confirm runs are queuing instead of racing with the GitHub CLI.

gh run list --workflow=deploy.yml --branch main --json status,conclusion,createdAt,headSha --limit 5

The list shows at most one deployment run with a status of in_progress at any moment; any others triggered around the same time show queued until the in-progress one completes.

Common Mistakes

Mistake 1: reusing the same concurrency group for a workflow that both tests and deploys, with cancel-in-progress left at its default of true. If a build-and-deploy workflow shares one group with cancellation enabled, a fresh push while a deployment is running cancels that deployment partway through, potentially after the application code has been replaced but before a database migration step has run, or the reverse. Correction: give deployment jobs their own dedicated group, scoped to the environment rather than the branch or run, with cancel-in-progress: false, so later triggers queue behind an in-flight deployment instead of interrupting it.

Mistake 2: writing a concurrency group that omits the ref or pull request number entirely, such as group: ci. Every branch and every pull request in the repository then shares one lane, so a push to an unrelated feature branch can cancel the test run for someone else’s pull request, and CI results become unreliable and hard to trust. Correction: always include a value that uniquely identifies the branch or pull request in the group expression, such as github.ref or github.event.pull_request.number, so only runs for the same branch or the same pull request compete with each other.

Best Practices

  • Scope CI concurrency groups by branch or pull request, and set cancel-in-progress to true there, since a stale CI result has no value and cancelling it saves compute time and shortens feedback loops.
  • Scope deployment concurrency groups by environment name, and set cancel-in-progress to false there, since an interrupted deployment can leave infrastructure in a mixed state that is harder to diagnose than a queued wait.
  • Never let a deployment workflow share a concurrency group with the workflow that builds or tests the same change, because their cancellation needs are opposite.
  • Pair concurrency with GitHub Environments and required reviewers for production, so the queue controls ordering while the environment protection rule controls whether a deployment is allowed to proceed at all.
  • Deploy by image digest rather than by a tag that can be overwritten, so a queued deployment that waits behind another one still deploys the exact artifact that passed tests and security scans, not whatever a tag happens to point to by the time its turn comes.
  • Grant only the minimal permissions a deployment workflow needs, typically contents: read plus id-token: write for OpenID Connect authentication to a cloud provider, instead of the default broader token.
  • Always add a health check step after a deployment and a documented rollback path, since a queue guarantees order, not correctness, and a bad release can still reach the front of the line.

Practice Exercises

  1. Take an existing pull request test workflow and add a concurrency block scoped to the pull request number with cancel-in-progress: true, then push two commits in quick succession and confirm the first run is cancelled.
  2. Modify that same workflow so it also runs on pushes to main, using a single concurrency group scoped by github.ref, with cancel-in-progress set to true only for the pull_request event, and confirm that pushes to main queue instead of cancelling each other.
  3. Write a separate deployment workflow with a fixed concurrency group such as deploy-production, cancel-in-progress: false, an attached production environment, and a health check step, then trigger it twice in a row and use the GitHub CLI to confirm the second run stays queued until the first finishes.

Summary

Concurrency groups decide which workflow runs compete for the same lane, and cancel-in-progress decides what happens when a new run arrives while an old one is still active: cancellation for cheap, restartable work like CI, and queuing for expensive, stateful work like production deployments. Scope groups precisely by branch, pull request, or environment so unrelated work never competes, keep CI and deployment in separate groups since they need opposite cancellation behavior, and combine deployment queues with environment protection rules, digest-pinned images, health checks, and a rollback path so an orderly queue also produces safe releases.