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: false

Common mistakes

More guides