GitHub Actions Concurrency Control

Concurrency control prevents multiple workflow runs from conflicting with each other. When several people push code at the same time, or when someone pushes multiple commits quickly, multiple workflow runs start simultaneously. Without concurrency control, two deployment runs can fight over the same server, or an older deployment can overwrite a newer one. This topic covers how to manage simultaneous runs safely.

Why Concurrent Runs Cause Problems

Picture two delivery trucks heading to the same address at the same time. Truck 1 carries version 1.5 of your app. Truck 2 carries version 1.6. If Truck 2 arrives first but Truck 1 arrives five minutes later, version 1.5 overwrites version 1.6 — and your users get the older version.

Without concurrency control:

  Push commit A → Run 1 starts: test → build → deploy
  Push commit B → Run 2 starts: test → build → deploy
                                              ↑
                         Run 1 deploys AFTER Run 2
                         → Old code overwrites new code!

The concurrency Key

The concurrency key at the workflow or job level defines a concurrency group. Only one run with the same group name can be active at a time.

name: Deploy

on:
  push:
    branches: [main]

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

With this configuration:

  • If no other run with the group name deploy-production is active, this run proceeds normally
  • If another run is already queued or running, the behavior depends on cancel-in-progress

cancel-in-progress: true vs false

cancel-in-progress: true
  When a new run starts:
  → The currently running/queued run is CANCELLED
  → The new run proceeds immediately
  
  Best for: deployments, preview environments, anything where
  only the LATEST run matters

cancel-in-progress: false (default)
  When a new run starts:
  → It waits in a QUEUE behind the current run
  → Runs execute one at a time in order
  
  Best for: jobs that must all complete (release builds,
  database migrations)
cancel-in-progress: true

  Commit A pushed → Run 1 starts
  Commit B pushed → Run 1 CANCELLED, Run 2 starts
  Commit C pushed → Run 2 CANCELLED, Run 3 starts
  Result: Only Run 3 (latest) completes

cancel-in-progress: false

  Commit A pushed → Run 1 starts
  Commit B pushed → Run 2 waits for Run 1
  Commit C pushed → Run 3 waits for Run 2
  Result: All three runs complete, in order

Dynamic Concurrency Groups

Use expressions to create group names based on branch, pull request number, or other context values. This allows different branches or PRs to run simultaneously while still controlling concurrency within each one.

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

This creates separate concurrency groups for each branch:

Branch: main     → group: "deploy-main"
Branch: staging  → group: "deploy-staging"
Branch: feature  → group: "deploy-feature-login"

Pushes to main and staging can run in parallel because they have different group names. Multiple pushes to the same branch cancel each other, keeping only the latest.

Per-Pull-Request Concurrency

For CI workflows, create one concurrency group per pull request so each PR's runs do not interfere with other PRs:

concurrency:
  group: ci-${{ github.event.pull_request.number }}
  cancel-in-progress: true
PR #42: multiple pushes → only latest run for PR #42 is active
PR #43: multiple pushes → only latest run for PR #43 is active
PR #42 and PR #43 run in parallel with each other

Job-Level Concurrency

You can set concurrency at the job level instead of the workflow level. This is useful when only one specific job (like the deploy job) needs protection:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test          ← No concurrency limit here

  deploy:
    needs: test
    runs-on: ubuntu-latest
    concurrency:               ← Concurrency only on deploy
      group: deploy-prod
      cancel-in-progress: true
    steps:
      - run: ./deploy.sh

Multiple test jobs can run in parallel. Only the deploy step is serialized.

Preventing Concurrent Database Migrations

Database migrations must run one at a time. Running two migrations simultaneously can corrupt data. Use cancel-in-progress: false to queue them safely:

jobs:
  migrate:
    runs-on: ubuntu-latest
    concurrency:
      group: db-migrations-production
      cancel-in-progress: false    ← Queue, never cancel
    steps:
      - run: npm run db:migrate

Concurrency vs Timeout

Feature             | Concurrency              | Timeout
--------------------|--------------------------|---------------------------
Prevents            | Simultaneous runs        | Stuck or runaway jobs
Acts on             | Multiple workflow runs   | Single job or step
Configured with     | concurrency key          | timeout-minutes key

Set a timeout-minutes on jobs to prevent a stuck job from blocking the concurrency queue indefinitely:

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30         ← Kill job if it runs over 30 minutes
    concurrency:
      group: deploy-prod
      cancel-in-progress: false
    steps:
      - run: ./deploy.sh

Leave a Comment

Your email address will not be published. Required fields are marked *