← All guides

Your workflow runs twice on every PR (push + pull_request duplicate runs)

Open a pull request against a branch that also has an unscoped push trigger and GitHub runs your whole workflow twice — once for the push event, once for the pull_request event, on the exact same commit. Same code, same tests, double the minutes, and it is one of the most common single line items in the CI-slowdown scans I run (more on that here).

Why it happens

on: [push, pull_request] with no branch scoping means every push to a branch fires push, and if that branch also has an open PR, the same push fires pull_request too. Both events point at the identical head SHA. GitHub doesn't dedupe them for you — it schedules two full runs because you asked for two triggers.

The fix: scope push to your default branch

on:
  push:
    branches: [main]
  pull_request:

Now push only fires on direct pushes or merges to main, and every PR — regardless of what its source branch is named — runs exactly once, on pull_request. This is exactly how astro and GitSpider's own CI are configured, and it's the same shape as TypeScript's setup, which scopes both triggers to [main, release-*] so a push to any other branch doesn't fire a run at all — only the PR event does.

The fork exception

Don't drop pull_request to "simplify" this. A push event never fires on the base repository for a pull request opened from a fork — only pull_request does. If your project accepts outside contributions, pull_request is the only trigger that covers them, branch scoping or not.

Or: keep both, add a concurrency group

If you genuinely need push unscoped (say, a status check that only makes sense post-merge, on every branch), a concurrency group with cancel-in-progress limits the damage: the second run supersedes the first instead of both finishing. It's a safety net, not a fix — the duplicate run still gets scheduled and starts billing before it's cancelled. Scoping the trigger is what stops it from starting at all.

Honest counterexample

next.js's build_and_test.yml runs on both push (scoped to canary) and pull_request — and that's deliberate, not an oversight. One job is explicitly gated if: github.event_name != 'pull_request', so the push-triggered run does real post-merge work the PR run skips. Two triggers on one workflow isn't automatically waste; it's waste when both runs do the same thing.

Common questions

Does pull_request run on forks? Yes — it's the only one of the two that does. A push event never fires on the base repository for a PR opened from a fork, so if you accept outside contributions, pull_request has to stay regardless of how you scope push.

Will a branches filter break my main CI? No. Scoping push to [main] only stops it from firing on feature branches — every pull request still runs in full via pull_request, no matter what the source branch is named. Direct pushes and merges to main keep triggering exactly as before.

Is concurrency cancel-in-progress enough on its own? It helps but it isn't the real fix. Cancel-in-progress kills the superseded run once the second one starts, but the duplicate still gets scheduled and burns minutes before it's cancelled. Pair it with a branches scope, don't substitute one for the other.

These hide across however many workflow files you have, which is exactly why nobody sits down and fixes them. Point GitSpider at your repo and it flags which patterns apply, with the fix for each.

No signup. Public repos only. ~30 seconds.