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:
| Setting | Behaviour |
|---|---|
queue: single (default) | At most one run can be pending. A newer pending run replaces the older one, which is cancelled. |
queue: max | Up to 100 runs can be pending. Once the queue is full, more runs are cancelled. |
cancel-in-progress: true | The 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
maindeploys 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:
| Rule | What it does | Plan notes (2026) |
|---|---|---|
| Required reviewers | Up to six users or teams; one approval lets the job proceed | Free, Pro, Team: public repos only |
| Wait timer | Delays the job by 1 to 43,200 minutes | Free, Pro, Team: public repos only |
| Deployment branches and tags | Only listed branches or tags can deploy | Private repos need Pro or Team or higher |
| Custom protection rules | A GitHub App approves or rejects the deployment | Public 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
| Question | Concurrency group | Environment protection rules | External environment lock |
|---|---|---|---|
| Stops two runs of the same workflow overlapping? | Yes | No | Yes, if both runs call it |
| Respects a person testing on the environment? | No | Only via a manual approval | Yes |
| Works across repositories? | No | No | Yes |
| Works across CI systems and manual deploys? | No | No | Yes, if they call it |
| Protects the time after a deploy finishes? | No | No | Yes, for the hold's duration |
| Shows who is using the environment now? | No | Deployment history only | Yes |
| Needs extra setup outside GitHub? | No | No | Yes |
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: falsea 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.