What Happens When Two Workflows Share a Concurrency Group?
Two workflows naming the same concurrency group serialize against each other, because a group is a repository-wide lane rather than a per-workflow setting.
Answer
They serialize against each other. A concurrency group is a lane across the repository rather than a setting scoped to one workflow file, so when two workflows resolve the same group string, GitHub allows one run in that lane at a time and parks the other as pending, per the concurrency reference in the workflow syntax docs.
The lane holds one running item and one pending item, so a third arrival cancels the pending run and takes its place. Two details turn this from a design choice into a surprise. First, the match is on the resolved string, and GitHub's concurrency guide records that the group name is case insensitive, so group: Deploy in one file and group: deploy in another are the same lane. Second, cancel-in-progress belongs to the arriving run rather than to the group. A workflow that sets cancel-in-progress: true to clear its own stale runs will also cancel an in-flight run of a different workflow that happens to share the group name.
Here is the sequence with two workflow files, ci.yml and nightly.yml, both carrying concurrency: { group: build }, with neither setting cancel-in-progress.
| Time | Event | Lane state after the event |
|---|---|---|
| 09:00 | Push to main starts ci.yml run 41 | ci 41 running |
| 09:02 | Schedule fires nightly.yml run 12 | ci 41 running, nightly 12 pending |
| 09:05 | Second push starts ci.yml run 42 | nightly 12 canceled, ci 42 pending |
| 09:11 | ci 41 finishes | ci 42 starts, nightly has to be retriggered |
The nightly run never executes, and its logs show a cancellation rather than a failure, which is why teams chase the scheduler before they check the group name.
Detail
The naming convention that prevents accidental collisions
Most collisions come from a static group name pasted between files, such as a bare concurrency: build copied into three workflows. Build the group from the workflow identity and the ref instead, so each workflow gets its own lane per branch:
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm testgithub.workflow resolves to the workflow name, so ci.yml and nightly.yml land in different lanes even on the same branch. Adding github.ref keeps a feature branch from parking a run on main. The cancel-in-progress expression cancels superseded pull request runs while letting main runs finish, so a deploy gate on main is never killed mid-flight. The same expression shape is covered in the guide to GitHub Actions concurrency limits.
When the shared lane is the point
Sometimes two workflows should serialize. A release workflow and a hotfix workflow that both write to the same production environment want one lane, and they want the running item protected. Put the group at the job level, key it on the environment, and leave cancellation off:
deploy:
needs: test
runs-on: warp-ubuntu-latest-x64-2x
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.shAny job in any workflow that names deploy-production now queues behind the in-flight deploy instead of racing it. The one behavior to keep in mind is the single pending slot: if three deploys arrive while one runs, the middle one is canceled. Teams that need every request to land use a merge queue or an external lock instead of the concurrency key. The answer on stopping a workflow from blocking deploys covers the reverse case, where an unrelated workflow is sitting in the deploy lane. For the term itself, see the concurrency group glossary entry.
What a shared lane costs
Serialization moves wall clock time and leaves billed minutes alone. In the timeline above, nightly 12 was canceled after zero compute, and ci 42 waited 6 minutes before starting. Both runs bill for the minutes they execute, whether they run together or one after the other.
The labels in the two workflows above resolve to these machines, from the cloud runners documentation, checked on 2026-08-13.
| runs-on label | OS | vCPU | RAM | Storage | Rate per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 |
A 6-minute test job on warp-ubuntu-latest-x64-4x bills $0.048 whether it starts at 09:02 or 09:11. Matched shape for shape, the rate sets the bill: warp-ubuntu-latest-x64-2x (2 vCPU, 8GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest on the same 2 vCPU, 8GB shape, a 33 percent lower list price, from GitHub's per-minute rates, checked on 2026-08-13. Full rates by runner type are on the pricing page.
Separating the group from runner capacity
A shared group and an exhausted runner pool produce similar screenshots and need different fixes. A run held by a group shows a waiting banner that names the group, which is the string to grep for across .github/workflows. A job held by capacity shows no banner and simply sits queued.
Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out, with the scope stated in the cloud runners documentation: features that are Generally Available support unlimited concurrency on Linux and Windows runners, while features in beta may carry limits. Because that ceiling is out of the picture, a queue on warp- labels usually points at the group name in your YAML. A job that stays queued with an empty log view is a third case, routing rather than queueing, and the common issues checklist covers the registration and permission checks for it.
Related Questions
Do concurrency groups apply across workflow files?
Yes. GitHub matches on the resolved group string, so two workflow files that produce the same string share one lane, per the workflow syntax reference. The group name is also case insensitive, so a job with group: Deploy and a job with group: deploy queue behind each other.
Which run waits when two workflows enter the same group?
The run already holding the lane keeps running, and the arriving run sits pending until the lane frees. A third arrival cancels the pending run and takes the pending slot, so a busy workflow can starve a quieter one that shares the group name. The timeline table above walks through that sequence minute by minute.
Can one workflow cancel another workflow's run?
Yes, when both name the same group and the arriving run sets cancel-in-progress: true. The setting belongs to the arriving run rather than to the group, per GitHub's concurrency guide, so a workflow that cancels its own stale runs will also cancel an in-flight run of a different workflow in the same lane. Scope the group with ${{ github.workflow }} to keep that blast radius inside one file.
Does a shared group cost extra billed minutes?
No. Serialization changes wall clock time and leaves billed minutes alone, since each job still runs once for the same duration. The cost of a shared lane is engineers waiting on a pull request check. The guide to GitHub Actions concurrency limits works the same arithmetic across a full matrix.
Audit your group names, then price the suite behind them against the per-minute rates on the pricing page.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.