CI runs too often
Duplicate CI runs from misconfigured triggers
By Keith Mazanec, Founder, CostOps ยท Updated January 29, 2026
A developer pushes a commit to a PR branch. GitHub Actions fires two workflow runs for the same commit: one from the push event and one from pull_request. Both run the full test suite. Both bill minutes. Only one provides useful signal. This happens because on: [push, pull_request] is the most common trigger configuration in the wild, and GitHub does not deduplicate these events. The fix is a one-line YAML change.
Symptoms
How to tell if duplicate triggers are doubling your CI bill
Open your repository’s Actions tab and filter by a single PR branch. If you see paired runs for every push, you have this problem:
-
Two runs per push on PR branches. Every commit to a branch with an open pull request produces two workflow runs within seconds of each other. One is labeled with the branch ref, the other with refs/pull/<N>/merge. They run identical jobs on the same code.
-
Different SHAs for the same commit. The push run uses your actual commit SHA. The pull_request run uses a temporary merge commit SHA that GitHub generates to simulate the PR merged into the base branch. This makes the duplication less obvious because they look like different commits yet test the same code changes.
-
Duplicate check entries on every PR. The PR’s checks tab shows the same workflow twice, once from push and once from pull_request. Reviewers see double entries, and if one passes while the other is still running, they may merge prematurely or wait unnecessarily.
Metrics
The cost of running every workflow twice
The math is simple: if every push to a PR branch triggers two runs instead of one, you are paying exactly double for feature-branch CI. Here’s what that looks like for a team averaging 40 PR pushes per day on Linux runners:
Before (duplicate triggers)
At $0.006/min (Linux 2-core) · 22 working days
After (single trigger per push)
Save $64/mo · $768/year · per workflow
On macOS runners at $0.062/min, the same scenario goes from $1,311/mo to $655/mo, saving $656/mo from a single YAML change. If you have a matrix strategy that multiplies jobs (say 4 × 3 = 12 jobs per run), the duplicate trigger doubles that to 24 jobs. At macOS rates with a 15-minute suite, one duplicate push costs $22.32.
Fix 1
Restrict push triggers to the default branch
The most common and simplest fix. Scope the push trigger to only fire on main (or your default branch). Feature branch CI is handled entirely by the pull_request trigger. Direct pushes and merges to main still fire the push event. No overlap, no duplication. This is the same approach covered in our guide to stopping CI on every push.
on: [push, pull_request] # push to feature branch → Run A # PR sync event → Run B # Same code, double cost
on: push: branches: [main] pull_request: # push → only on merge to main # pull_request → handles PRs
This is a one-line change. The pull_request event fires on opened, synchronize (new pushes), and reopened by default, which covers every PR commit. The scoped push event covers post-merge validation on main.
One caveat: if you need push events on feature branches for other reasons such as deploy previews, per-branch Docker images, or Netlify builds, you cannot use this approach alone. See Fix 2 for a conditional alternative.
Fix 2
Use a conditional to skip same-repo PR duplicates
When you need both push and pull_request triggers (e.g., to support fork PRs while also running on push), you can add a job-level condition that deduplicates same-repo events. The push event handles all same-repo pushes, while pull_request only runs for fork PRs where no push event fires in your repository.
name: CI on: [push, pull_request] jobs: test: # Run on push events, OR on pull_request only from forks if: > github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm test
The condition checks whether the PR’s head repository matches the base repository. For same-repo PRs (where both push and pull_request fire), the pull_request run is skipped because the push run already covers it. For fork PRs (where only pull_request fires in your repo), the condition passes and the job runs normally.
One caveat: skipped pull_request runs still appear in the Actions tab as “skipped” rather than not appearing at all. If this workflow is a required status check, the skipped run reports as successful, so branch protection still works. However, the push event does not appear in the PR’s check suite, so only pull_request-triggered checks show there. If you need a check status on the PR itself, consider using Fix 3 instead.
Fix 3
Consolidate into a single trigger with concurrency
If removing one trigger is impractical, add a concurrency group to ensure only one run completes per commit. When both push and pull_request fire for the same commit, the concurrency group cancels the first run when the second arrives (or vice versa). You still pay for a small window of overlap, but the duplicate run is terminated early instead of running to completion.
name: CI on: push: pull_request: concurrency: group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm test
The key is github.head_ref || github.ref. For pull_request events, github.head_ref is the PR branch name. For push events to the same branch, github.head_ref is empty, so it falls back to github.ref which is refs/heads/<branch>. Both resolve to the same branch, putting the duplicate runs in the same concurrency group.
One caveat: the cancelled run still bills for the minutes it was active before cancellation. If both triggers fire within the same second, the overlap is negligible. If there is a delay (common under heavy load), you may pay for a minute or two of the doomed run. This approach reduces but does not fully eliminate the waste, so Fix 1 remains the cleaner solution when you can use it. For a deeper look at concurrency group costs, see canceled runs wasting minutes.
Reference
Why GitHub Actions fires two events for one push
This is not a bug. It is intentional behavior. The push and pull_request events serve different purposes and provide different context:
| push | pull_request | |
|---|---|---|
| GITHUB_SHA | Actual commit | Merge commit (synthetic) |
| GITHUB_REF | refs/heads/<branch> | refs/pull/<N>/merge |
| Fires for forks | No | Yes |
| PR checks tab | Not shown | Shown |
| Secrets access | Full | Read-only for forks |
The pull_request event exists specifically to test what the merge result would look like, and to gate PRs from forks safely (with restricted permissions). The push event tests the exact commit that was pushed. When you need both behaviors, keep both triggers but use the fixes above to avoid running the full pipeline twice.
Reference
Other trigger misconfigurations that cause duplicate runs
The bare on: [push, pull_request] pattern is the most common, but other configurations produce the same duplication:
-
Wildcard branch filters on both triggers. Writing branches: ['**'] on both push and pull_request is functionally identical to the bare dual trigger. The wildcard matches every branch, so both events still fire on every PR push.
-
Tag and branch push overlap. If your workflow triggers on both branches: ['**'] and tags: ['**'], running npm version patch (or any tag-and-push operation) fires two runs: one for the branch commit and one for the tag ref.
-
Redundant activity types. Writing pull_request: types: [opened, synchronize, reopened] alongside an unscoped push is the same as the bare dual trigger because those are the default activity types. The synchronize type fires on every push to the PR head branch, which is the same commit that triggers push.
In all of these cases, the same fixes apply: scope push to main only, add a fork-aware conditional, or use a concurrency group to cancel duplicates.
Related guides
Stop CI Running on Every Push
Concurrency groups, scoped triggers, and path filters to cut redundant runs 30-50%.
Canceled Runs Are Wasting Your CI Minutes
How canceled runs still bill for active minutes and how to minimize the waste window.
Reduce Full CI on Branch Pushes
Scope triggers to PRs and main to cut non-PR CI minutes 60-80%.
Matrix Explosion
When matrix strategies multiply jobs beyond what is useful, duplicate triggers make it worse.