DEV Community

robsonheals
robsonheals

Posted on Originally published at devrescue.vercel.app

Stop paying for GitHub Actions runs nobody reads (and the cancel-in-progress mistake in most snippets)

Push three commits to a pull request in five minutes and GitHub Actions runs your CI three times, in full. Only the last result matters. The other two burn minutes and hold runners the current run is waiting for.

I recently scanned the workflows of 139 popular JS/TS repos (1k–20k stars). 97 of 129 repos with push/PR workflows (75%) had at least one workflow with no concurrency group at all. So this isn't a beginner problem; it's in well-maintained projects too.

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' }}
Enter fullscreen mode Exit fullscreen mode

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 key gives every pull request its own lane, and every branch its own lane for pushes.

Why not just cancel-in-progress: true?

That's what most snippets online use. It also cancels runs on main when two merges land close together, and if the same workflow deploys, the first deploy can get stopped halfway.

The expression above only cancels on pull requests. Pushes to main always finish.

(Even without cancellation, GitHub keeps at most one pending run per group, so a burst of merges still collapses to "the running one + the latest one". For deploys that's usually exactly 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
Enter fullscreen mode Exit fullscreen mode

Three mistakes I keep seeing

  1. Leaving github.workflow out of the key. Concurrency groups are shared across the whole repo, so a ci.yml and a lint.yml that both use group: ${{ github.ref }} cancel each other.
  2. Using github.head_ref alone. It's empty on push events, so every push to every branch lands in one group. Always fall back to github.ref.
  3. A fixed string as the group for CI (e.g. group: ci). Now a push to any PR cancels CI on every other PR.

Checking your own repos

You can grep for it (grep -L concurrency .github/workflows/*.yml), but missing concurrency usually travels with two other leaks: no dependency cache and jobs with no timeout-minutes (60.9% of the 2,181 jobs in the scan had none, so a hung job runs for 6 hours).

I built a free checker that flags all three per workflow file, with the exact line to change: devrescue.vercel.app. Paste a public repo URL, no signup. If you'd rather have it in CI, it's also a GitHub Action that annotates the workflow lines on each PR.

What does your team use for the group key? I'm curious whether anyone has a case where cancelling on main is actually what you want.

Top comments (0)