Guides / Duplicate runs from misconfigured triggers

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)

Pushes/day 40
Runs per push 2
Minutes/run 12
Monthly minutes 21,120
Monthly cost $127/mo

At $0.006/min (Linux 2-core) · 22 working days

After (single trigger per push)

Pushes/day 40
Runs per push 1
Minutes/run 12
Monthly minutes 10,560
Monthly cost $63/mo

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.

2 runs per PR push
on: [push, pull_request]

# push to feature branch  → Run A
# PR sync event           → Run B
# Same code, double cost
1 run per PR push
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.

.github/workflows/ci.yml
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.

.github/workflows/ci.yml
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

Guides / Duplicate runs from misconfigured triggers

Find which workflows fire duplicate runs

CostOps detects overlapping push and pull_request triggers automatically and shows you exactly which workflows are doubling your bill.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.