Cancel outdated GitHub Actions runs on pull requests with concurrency
Every push to a PR starts a new run while the old one keeps going. A 3-line concurrency group cancels it, without cancelling deploys on main.
Push three commits to a pull request in five minutes and GitHub Actions runs CI three times, in full, even though only the last result matters. The outdated runs burn minutes and hold runners that the current one is waiting for. In our scan, 97 of 129 repos (75.2%) had pull-request workflows with no concurrency group.
The fix
Add this at the top level of the workflow, next to on::
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}Runs in the same group are serialized: when a new one starts, the one in progress is cancelled if cancel-in-progress is true. The group key gives each pull request its own lane, and each branch its own lane for pushes.
Why not just cancel-in-progress: true?
Many snippets online use cancel-in-progress: true. That also cancels runs on main when two merges land close together. If the same workflow deploys, the first deploy can be stopped halfway. The expression above cancels only on pull requests; pushes to main always finish.
Without cancellation, GitHub still keeps at most one pending run per group: if a third run arrives while one is running and one is waiting, the waiting one is cancelled. For deploys that is usually what you want.
Deploy jobs: queue, don't cancel
Concurrency also works per job. A common setup lets CI cancel freely while deploys to the same environment run one at a time:
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: deploy-production
cancel-in-progress: falseCommon mistakes
- Leaving
github.workflowout of the key. Concurrency groups are shared across the whole repository, so a CI workflow and a lint workflow that both usegroup: ${{ github.ref }}cancel each other. - Using
github.head_refalone. It is empty on push events, so every push to every branch lands in one group. Fall back togithub.ref. - A fixed string as the group for CI. Then a push to any PR cancels CI on every other PR.