Cancel runs a newer push replaced
Group each workflow's runs by pull request so a new push cancels the run it made obsolete, and leave default-branch, release and deploy runs alone.
Saves runner minuteslow risk
Signals in your baseline
- Workflows triggered by pull_request have no concurrency block.
- Runs of the same workflow overlap on one pull request because pushes land faster than a run finishes.
- Queue time rises during bursts of pushes from developers or agents.
Why it works
Once a pull request has a newer commit, the run for the older commit can only report on code nobody will merge. GitHub's concurrency key puts runs into a named group, and cancel-in-progress stops the older run as soon as a newer one joins the group. The freed runners pick up work that still matters, which also cuts queue time when pushes arrive in bursts.
Apply it
Add a workflow-level concurrency block to each workflow that validates pull requests:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
github.workflowkeeps different workflows from cancelling each other.- The pull request number puts every push to one pull request in the same group.
github.refcovers pushes and manual runs. - The
cancel-in-progressexpression cancels only pull request runs. Runs on the default branch wait their turn instead, so every merged commit still gets a result.
If the workflow already has a concurrency block, change it rather than adding another. GitHub allows one block at the workflow level and one per job. See GitHub's guide to controlling workflow concurrency.
Keep it safe
- Leave deploy, release, publish and migration workflows out, or set
cancel-in-progress: falseon them. Stopping those halfway can leave half-applied state. - Don't key the group on
github.refalone inpull_request_targetorworkflow_runworkflows. Their ref is the base branch, so unrelated pull requests would cancel each other. Use the pull request number from the event payload. - A cancelled run reports as cancelled, not failed. Required checks show as pending until the newer run finishes, which is the behavior you want.
- Cancellation stops the step that is running, but steps with
if: always()still run. Make sure cleanup steps don't assume the cancelled step finished.
Verify
- Push two commits to a test pull request about a minute apart. The first run should show as cancelled within seconds of the second push.
- After a week, compare runner minutes per merged pull request with the baseline. Wall time per run shouldn't change; total minutes, and queue time in busy hours, should fall.
- Confirm that no default-branch run shows as cancelled.
BerryBox