Engineering · 7 min read

GitHub Actions Concurrency Groups vs Environment Locks: What Each Actually Protects

Lockit Team · 2026-09-12

A common conversation in teams that deploy from GitHub Actions goes like this. Someone's test on staging gets overwritten by a deploy. Someone else says "we should add a concurrency group". A concurrency group is added. A week later, the same thing happens.

The concurrency group was working. It just protects something narrower than people assume. This post goes through what concurrency groups, GitHub environments, and an external environment lock each protect, where each stops, and how to layer them.

All GitHub behaviour described here is from GitHub's documentation as of 2026.

What a concurrency group does

A concurrency group is a named slot. GitHub's documentation puts it plainly: there can be at most one running job or workflow in a concurrency group at any time.

concurrency:
  group: deploy-staging

You can set it at the workflow level or the job level. When a second run reaches the same group while the first is running, it waits as pending.

What happens to waiting runs depends on the queue property:

SettingBehaviour
queue: single (default)At most one run can be pending. A newer pending run replaces the older one, which is cancelled.
queue: maxUp to 100 runs can be pending. Once the queue is full, more runs are cancelled.
cancel-in-progress: trueThe currently running job or workflow is cancelled when a new one arrives.

queue: max and cancel-in-progress: true can't be combined; GitHub rejects the workflow. Waiting runs are processed roughly first-in-first-out, based on when each started waiting, though GitHub notes the order isn't guaranteed.

Group names are case-insensitive, so Staging and staging are the same group, and they can use expressions:

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

That pattern, with cancel-in-progress, is the standard way to stop five pushes to a pull request from running five test suites.

What a concurrency group does not protect

Here is the part that causes the confusion. A concurrency group coordinates GitHub Actions runs in one repository. It knows nothing else exists. It does not protect against:

  • A person using the environment. A QA engineer running a manual regression pass on staging isn't a workflow run. The group is empty as far as GitHub is concerned, and the deploy goes ahead.
  • Another repository. If your frontend and backend repos both deploy to the same staging cluster, they have separate groups even if both are called deploy-staging.
  • Another CI system. A Jenkins job, a GitLab pipeline or a cron job on a VM deploying to the same place.
  • Manual changes. kubectl apply, a database migration run from a laptop, a feature flag flipped in a dashboard.
  • The time between runs. The slot frees the moment the run ends. If someone deployed a branch to test and the run finished, the group is empty, and the next merge to main deploys straight over their branch.

That last one is the most common source of "but we have a concurrency group". The group protects the deploy while it runs. It doesn't protect what was deployed afterwards, while someone is using it.

What GitHub environments add

GitHub environments are a separate feature, and they're about gating rather than mutual exclusion. A job that references an environment has to pass that environment's protection rules before it starts:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging

The rules available, according to GitHub's 2026 docs:

RuleWhat it doesPlan notes (2026)
Required reviewersUp to six users or teams; one approval lets the job proceedFree, Pro, Team: public repos only
Wait timerDelays the job by 1 to 43,200 minutesFree, Pro, Team: public repos only
Deployment branches and tagsOnly listed branches or tags can deployPrivate repos need Pro or Team or higher
Custom protection rulesA GitHub App approves or rejects the deploymentPublic preview; Free, Pro, Team: public repos only

Environments are useful: they keep production secrets away from feature branches, require a human to approve production, and record deployment history. But none of these rules is "nobody else is using this right now". The documentation describes the checks a job must pass, not mutual exclusion between jobs.

Required reviewers can act as a human lock by accident ("only deploy to staging if Priya approves"), but it's a poor one. It blocks every deploy on one person, and it still says nothing about who is currently testing.

Custom protection rules are the closest GitHub comes. A GitHub App could check an external lock and reject the deployment if someone holds it. That's real engineering work, it's in public preview, and on most plans it's public-repo only.

What an environment lock adds

An environment lock is a record outside your CI system that says "this environment is held by this person (or pipeline) until this time". Everyone who uses the environment checks and takes it: people from chat, pipelines from an API.

What it protects that the others don't:

  • People. The QA engineer locks staging before starting; the pipeline sees the lock and waits.
  • Holds that outlast a run. A developer deploys their branch and locks staging for two hours to test it. The next merge waits for them.
  • Multiple repos and CI systems. Everyone calls the same lock.
  • Visibility. Anyone can see who has it and until when, without reading Actions logs.

What it doesn't do: it doesn't stop a workflow from starting, and it's only as good as the discipline of calling it. A pipeline that doesn't check the lock will deploy anyway, just like a person who doesn't.

Side by side

QuestionConcurrency groupEnvironment protection rulesExternal environment lock
Stops two runs of the same workflow overlapping?YesNoYes, if both runs call it
Respects a person testing on the environment?NoOnly via a manual approvalYes
Works across repositories?NoNoYes
Works across CI systems and manual deploys?NoNoYes, if they call it
Protects the time after a deploy finishes?NoNoYes, for the hold's duration
Shows who is using the environment now?NoDeployment history onlyYes
Needs extra setup outside GitHub?NoNoYes

How to combine them

The layers aren't alternatives. A sensible setup for a shared staging environment uses all three:

name: Deploy to staging
on:
  push:
    branches: [main]

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - name: Acquire staging lock
        id: lock
        run: ./scripts/lock.sh staging 45     # waits, then fails if held
      - name: Deploy
        run: ./scripts/deploy.sh staging
      - name: Release staging lock
        if: always() && steps.lock.outputs.acquired == 'true'
        run: ./scripts/release.sh staging
  • The concurrency group makes sure only one run of this workflow deploys at a time, and with cancel-in-progress: false a running deploy is never killed halfway.
  • The environment scopes secrets and, for production, can require approval.
  • The lock step waits for any person or other system holding staging, and holds it for the length of the deploy.

The full lock and release scripts, including the details of detecting a refused lock and releasing safely, are in how to lock a staging environment from your CI/CD pipeline.

Which queue setting for deploys?

For "deploy latest main to staging", the default queue: single is usually right: if three merges land while a deploy runs, only the newest needs to deploy next, and the older pending ones are cancelled. For workflows where every run must happen, such as applying a sequence of migrations, queue: max keeps them all, up to 100. Don't use cancel-in-progress: true on deploy workflows; cancelling a deploy halfway can leave an environment in a worse state than either version.

Doing the lock with Lockit

If your team coordinates in Slack, Discord or Microsoft Teams, Lockit gives you the external lock without building anything. People run @Lockit lock staging in Slack (or /lockit lock resource:staging duration:2 hours on Discord), and the pipeline calls the REST API with the same resource name. If staging is locked, a teammate can click "Alert when released" to be pinged when it frees up, or "Ask for takeover", which gives the holder Approve and Reject buttons.

The API is on paid plans; the free plan covers 2 resources with unlimited locks for the chat side. See pricing.

If you're weighing whether to keep a shared staging environment at all, ephemeral environments vs shared staging covers that trade-off.

FAQ

Is a concurrency group shared across repositories?

GitHub's documentation describes concurrency groups in terms of jobs and workflows in the repository, and doesn't describe any cross-repository behaviour. Treat them as per-repository. Two repos that deploy to the same environment need something outside GitHub Actions to coordinate.

Should I use cancel-in-progress on deployment workflows?

Generally no. It's right for test runs on a pull request, where only the latest commit matters. For deploys, cancelling halfway can leave the environment partly updated.

Can required reviewers act as an environment lock?

Only loosely. A reviewer can refuse to approve a deploy while someone is testing, but that depends on the reviewer knowing, and it blocks every deploy on their availability. On Free, Pro and Team plans, required reviewers also only work on public repositories.

Do I need an external lock if only CI ever touches staging?

Probably not. If no person ever tests, demos or debugs on the environment directly, a concurrency group covers you. The moment people use it too, the pipeline needs a way to see them.

Stop asking who's on staging.

Add Lockit to Slack in a minute. Free for your first 2 resources.