The GitHub Actions Concurrency Key Everyone Skips
Every push to a branch queues another workflow run. Most of them are pointless. One setting fixes it and almost nobody uses it.
I was burning through free GitHub Actions minutes faster than made any sense. A client's monorepo — Laravel API, a React front end, a few worker processes — had five or six workflows. I'd push a branch, realize I forgot a dd(), push again thirty seconds later, and both runs would chew through the full pipeline in parallel. The first run was already dead to me the moment I pushed the second commit. GitHub didn't know that. I did. There's a one-line fix and I'm baffled it isn't in every starter template.
What the Problem Actually Is
GitHub Actions queues runs independently. Push three commits in quick succession and you get three runs, all pulling dependencies, all spinning up containers, all running your test suite. The first two are garbage the moment the third one starts — you're going to deploy the HEAD of the branch regardless. But they keep chugging, consuming minutes, occasionally colliding with each other on shared resources, and sometimes finishing out of order so your deployment log is a mess.
For public repos it's mostly an annoyance. For private repos on a team plan or for anyone watching the bill on a self-hosted runner setup with per-minute pricing, it adds up fast. That client's repo was logging close to 800 wasted minutes a month just from redundant test runs on short-lived feature branches.
The fix is the concurrency key. It's been in the GitHub Actions spec for years. It is, in my experience, the most underused feature in the entire platform.
The Concurrency Key
Here's the simplest version:
name: CI
on:
push:
branches:
- main
- 'feature/**'
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: php artisan test
That's it. Two lines under concurrency and you're done.
group defines a string key. Any two runs with the same key are considered to be in the same concurrency group. When a new run enters the group, cancel-in-progress: true kills any run that's already queued or executing. The new one takes over.
github.workflow is the workflow file name. github.ref is the full ref — refs/heads/feature/my-branch for pushes, refs/pull/42/merge for PRs. So each branch and each PR gets its own group, which is exactly what you want. Pushing three times to feature/my-feature cancels the first two and runs the third. Your main branch runs are in a separate group from your PR runs. No cross-contamination.
The Part That Bites People
The default group key in most tutorials is ${{ github.ref }} — just the ref, no workflow name. That works fine until you have more than one workflow file. If your deploy workflow and your test workflow share the same group key, a push can cause your in-progress deploy to get canceled by an incoming test run. I've watched that happen. It's a bad afternoon.
Always include github.workflow in your group. Makes the key unique per workflow file, per ref. Burn that into muscle memory.
Second gotcha: cancel-in-progress: true is not something you want on your production deploy workflow without thinking hard about it. If someone pushes a hotfix while a deploy is running, you probably don't want that deploy killed mid-flight. Half-deployed applications are not fun. For deploy workflows I use a different strategy:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: false keeps the cancellation behavior off but still enforces that only one run in the group executes at a time. New runs queue up instead of canceling. That means your hotfix deploy waits for the current deploy to finish rather than killing it. Much safer.
Third gotcha: PR workflows triggered by pull_request and push events can end up in the same group if you're not careful about how you construct the key, because a PR push fires both events. If you run separate workflows for each, the workflow name in the group key handles this automatically. If you use one workflow that responds to both events, you may want a more explicit key:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
Adding github.event_name separates the push group from the pull_request group for the same ref.
A Real-World Setup I Actually Use
Here's a closer approximation of how I wire this up for a Laravel project with separate test and deploy workflows.
.github/workflows/test.yml
name: test
on:
push:
branches-ignore:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
phpunit:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testing
POSTGRES_USER: runner
POSTGRES_PASSWORD: secret
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests
env:
DB_CONNECTION: pgsql
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: runner
DB_PASSWORD: secret
run: php artisan test --parallel
.github/workflows/deploy.yml
name: deploy
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh
The test workflow gets cancel-in-progress: true — redundant runs on feature branches get killed immediately, which is the whole point. The deploy workflow gets cancel-in-progress: false — hotfixes queue up rather than nuking a live deploy. Two workflows, two group keys (because the names differ), zero cross-contamination.
When to Reach for This
Every project. No exceptions. Even solo projects on public repos. The habit costs you two lines of YAML and saves you the cognitive overhead of watching stale runs pile up in the Actions tab.
Where it matters most:
- Private repos with limited minutes. This is the obvious one. Killing redundant runs is free minute recovery.
- Teams with active PR review cycles. Reviewers leave comments, authors push fixes, everyone pushes fixup commits. Without concurrency groups, your Actions tab looks like a slot machine.
- Any workflow that touches shared infrastructure. Database migrations, S3 syncs, external API calls with rate limits — parallel redundant runs will eventually collide in a way that costs you time debugging something that wasn't actually broken.
Where to be careful:
- Deploy workflows, as covered. Use
cancel-in-progress: falseand let them queue. - Scheduled workflows. Cron-triggered runs on
scheduleevents don't have a meaningfulgithub.refto differentiate on if you only have one default branch. You probably don't want to cancel these anyway — they're typically reporting or maintenance jobs where you want every run to complete. Leave the concurrency key off entirely for those. - Matrix builds where you genuinely want parallel jobs to run. The concurrency key applies at the workflow run level, not the job level. Within a single run your matrix jobs still parallelize normally. Just don't set a key that groups different matrix runs into the same bucket unless you mean to.
Closing
Two lines of YAML, available for years, used by maybe a third of the repos I review when a new client hands me access. I don't know if it's underdocumented or if people just skip past it in the getting-started guides, but it's one of the first things I add to any project I touch. Your CI pipeline should work for you, not run laps in the background burning minutes on commits that are already irrelevant.
Need help shipping something like this? Get in touch.