6 min readRishi

GitHub Actions Concurrency: The Group That Cancelled the Production Deploy

GitHub Actions Concurrency: The Group That Cancelled the Production Deploy

The deploy to production was two minutes into az webapp deploy. The run flipped to Cancelled. Nobody pressed the button. A push to a feature branch had started the same workflow file. Both runs used one concurrency group. The new run cancelled the old one because that is what you asked for.

concurrency is not "don't waste minutes on stale PR checks." It is a repository-wide lock name. Anything that prints the same group string shares the lock — other jobs, other workflows, other branches. cancel-in-progress decides whether the holder is killed or allowed to finish. The name is the whole design.

What GitHub actually does

A concurrency block has a group and an optional cancel-in-progress flag (default false). You can put it on the workflow or on one job.

When a run wants a group that is already busy:

  1. The new run becomes pending.
  2. Any other pending run in that group is cancelled. GitHub keeps at most one waiter.
  3. If cancel-in-progress is true, the running occupant is cancelled too, and the newcomer starts.
  4. If it is false, the occupant finishes, then the waiter starts.

That third pending push never runs. cancel-in-progress: false does not mean a queue of every SHA. It means one in flight, one next, everyone else dropped. On a single branch that is usually what you want: deploy A, skip B, deploy C, and C already contains B. Across two workflows that accidentally share a name, B was the production deploy and C was a lint job.

The group is a string, not "this workflow"

GitHub does not namespace the group by workflow file. github.workflow is the name key (or the filename if you omitted name). Two YAML files with name: CI and group: ${{ github.workflow }} are the same group.

These all collide:

# ci.yml and deploy.yml both contain:
concurrency:
  group: ${{ github.ref }}
  cancel-in-progress: true

On main, both print refs/heads/main. A push CI run and a deploy run serialize. With cancel-in-progress: true, CI cancels the deploy.

concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true

One group for every branch that uses that workflow. A push to feat/button cancels the in-progress run on main. That is the incident in the first paragraph when CI and deploy live in one file.

concurrency:
  group: production

A literal. Every run of every workflow that copies that block shares it. Copy-paste from a blog post will do this.

Put the workflow identity and the ref (or the environment, or the PR number) in the string, on purpose:

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

Each PR has its own group. A new push to that PR cancels the stale check. main is refs/heads/main, separate from refs/pull/412/merge. Other workflows stay out of the string, so they do not share the lock.

Cancel PR checks. Serialize the deploy.

The useful split is not "concurrency: on or off." It is which occupant you are willing to kill.

PR / branch CI — the in-progress run is already wrong the moment a newer SHA exists. Cancel it.

on:
  pull_request:
  push:
    branches: [main]

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

cancel-in-progress accepts an expression. On a pull request it is true. On push to main it is false: a second push waits, it does not murder the first. The skipped-pending rule still applies — three rapid pushes to main yield the running SHA and the latest SHA, not the middle one.

Production deploy — a second name, and do not cancel the occupant. Cancelling terraform apply or a slot swap leaves the target in whatever state the process had reached. The slot-swap pipeline is a routing change you want to finish or roll back on purpose, not a runner SIGTERM.

# deploy.yml — push to main, or a GitHub Environment job after the merge queue
concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    environment: Production
    permissions:
      id-token: write
      contents: read

deploy-production is a literal on purpose. One production apply at a time, across this workflow. Put the OIDC login on that job, not on the PR workflow. The merge queue already serializes what reaches main; concurrency here serializes the apply, which the queue does not.

If you want "only the latest SHA deploys" without cutting an apply in half, keep cancel-in-progress: false and accept that a burst of merges deploys first-then-latest. If every SHA must hit production (a migration that is not in the latest commit), do not share a group at all — or you will skip the middle run by design.

Job-level vs workflow-level

Workflow-level concurrency holds the group for the entire run, including a ten-minute test job that has not deployed anything yet. A second main push then waits on tests, or cancels them.

Job-level concurrency holds the group only while that job runs. Tests on two SHAs can overlap. The deploy job still serializes:

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

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: Production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh

Two caveats. Order among jobs that queue for the same job-level group in the same moment is not something to bet on — if that matters, use one deploy workflow and one group. And a cancelled job is not a failed job. Downstream jobs with the default if (success()) skip. The run can still be green with the deploy skipped, which is the same class of lie as a test stage that never ran. If deploy is required, require that job on the branch rule and do not continue-on-error it.

What this is not for

Stopping two matrix legs from racing. Matrix legs of one job are one job for concurrency. Use a lock inside the job, or don't share the resource.

An org-wide mutex. The group is per repository. Two repos can deploy the same App Service at once unless something outside Actions (the Azure lock, a storage lease, Terraform state) says no.

A substitute for Environments. concurrency does not add reviewers, wait timers, or secret scoping. environment: Production does. Use both: reviewers on the Environment, a dedicated group on the deploy job.

Rescue after you cancelled an apply. If cancel-in-progress already killed a deploy, the cloud is in whatever state the CLI left. Finish or roll back from the environment, then turn the flag off before the next one.

Name the group after the thing that must not overlap — this-workflow plus this-PR, or deploy-production — and set cancel-in-progress only for runs whose in-flight work is disposable. A cancelled check is cheap. A cancelled deploy is an incident with a green-looking neighbour in the Actions tab.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

  • No comments yet. Be the first.