CI runs too often
Why your CI runs on every push (and how to stop it)
By Keith Mazanec, Founder, CostOps ยท Updated February 17, 2026
A developer pushes 8 commits to a feature branch throughout the day. Each push runs the full test suite: build, lint, unit tests, integration tests, E2E. Nobody is waiting on those results. There's no open PR, no reviewer, no merge decision pending. You're billed for all 8 runs anyway. This is the single most common source of wasted CI spend, and it's fixable with a few lines of YAML.
Symptoms
How to tell if this is costing you money
You don't need a dashboard to spot this problem. Open your repository's Actions tab and look for these patterns:
-
Non-PR runs dominate billable minutes. Your Actions billing shows that the majority of minutes come from runs with no associated pull request. These are pure push-triggered runs on feature branches where the developer is still working and no one is blocked on the result.
-
High runs-per-PR. A single pull request triggers 5–15 workflow runs before it merges. Each push to the branch starts a new run, even though the previous one is already stale. Most of these runs will never inform a merge decision.
-
Clusters of cancelled runs. Your Actions tab shows grey "cancelled" badges stacked up in groups. This means runs were superseded by newer commits, but you're still billed for every minute they were active before cancellation.
-
Duplicate runs on the same SHA. The same commit triggers two workflow runs simultaneously: one from the push event and one from pull_request. Double the cost, zero additional signal. This is the default behavior when you use on: [push, pull_request] without branch scoping. See our guide to fixing duplicate trigger runs for a deep dive.
-
CI time scales with team size, not change size. Adding a developer to the team adds another 5–10 push-triggered runs per day. Your CI bill grows linearly with headcount even though the codebase hasn't changed.
Metrics
Quantify the waste
Consider a team of 10 developers, each pushing 8 times per day to feature branches. Every push triggers a 15-minute pipeline on Linux runners. Only about 20% of those pushes, specifically the final push before opening a PR, actually need the full suite. Here's a typical scenario:
Before: full CI on every push
At $0.006/min (Linux 2-core) · 22 working days
After: full CI only on PRs + main
Save $126/mo · $1,518/year · per workflow
That's one workflow on Linux. On macOS runners at $0.062/min (10x the rate), the same scenario goes from $1,637/mo to $327/mo, saving $1,310/mo from trigger configuration alone. Multiply across all your workflows and the numbers get serious.
Fix 1
Auto-cancel superseded runs
When a developer pushes multiple commits to a PR branch in quick succession, only the most recent commit matters for CI. The earlier runs are already stale. GitHub's concurrency key lets you automatically cancel in-progress runs when a newer one arrives for the same branch.
The group key creates a unique identifier per workflow and branch. When a new run enters the same group, any running or queued run in that group is cancelled. Add cancel-in-progress: true and you get automatic cleanup.
name: CI on: push: branches: [main] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm test
With this in place, 5 rapid pushes result in 1 completed run instead of 5. The first four are cancelled as soon as the next push arrives:
Cancelled runs still bill for their active minutes before cancellation. Concurrency groups minimize that window. See canceled runs wasting minutes for more detail.
One caveat: you probably don't want to cancel runs on main, since those represent merged code you actually want to validate. Use a conditional to protect production builds:
concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Fix 2
Scope push triggers to main and release branches
The root cause of most wasted CI minutes is a bare on: push or on: [push, pull_request] without branch filters. This fires the full pipeline on every push to every branch. When both triggers are present without scoping, every push to a PR branch fires two runs: one for the push event and one for the pull_request synchronize event. GitHub does not deduplicate these, so you pay for both.
The fix is to restrict push to only protected branches and let pull_request handle all feature branch CI. When you write push: branches: [main], the workflow only fires on direct pushes to main (i.e., after a merge). Feature branch pushes are ignored entirely by the push trigger, and the pull_request trigger picks them up once a PR is opened.
on: [push, pull_request] # push to feature/login → full CI # push to feature/login → full CI # push to feature/login → full CI # open PR → full CI # 4 runs, 3 had no audience
on: push: branches: [main] pull_request: branches: [main] # push to feature/login → nothing # open PR → full CI # 1 run, 0 wasted
One caveat: the branches filter on pull_request filters by the target branch, not the source branch. Writing pull_request: branches: [main] means "run for PRs that target main," which is almost always what you want. PRs targeting other branches (e.g., release backports) will need their own trigger if they need CI.
Fix 3
Run a lightweight preflight on push
Some teams still want fast feedback on every push: a lint check or unit test run that catches obvious mistakes before the developer opens a PR. Cutting off all push feedback is not the only option. The solution is a two-tier approach: a fast, cheap preflight on every push, and the full suite gated behind pull_request.
Use github.event_name to conditionally skip expensive jobs on push events. The workflow triggers on both events, but integration and E2E tests only run when there's a PR.
name: CI on: push: branches: ['**'] pull_request: branches: [main] # Concurrency group to auto-cancel superseded runs concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # Runs on every push - fast, cheap lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint - run: npm run typecheck unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run test:unit # Only runs on PRs - expensive integration-tests: if: github.event_name == 'pull_request' needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run test:integration e2e-tests: if: github.event_name == 'pull_request' needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright test
With this setup, a branch push runs lint + unit tests in about 3 minutes ($0.018 on Linux). The full suite with integration and E2E only runs when a PR exists. The developer gets fast feedback on syntax and basic correctness without paying for the heavy tests. If you want a quick preflight on branch pushes, this adds about 3 minutes per push, bringing the after cost to roughly $55/mo on Linux. Still a 65% reduction compared to full CI on every push.
One caveat: use github.event_name (a string), not github.event (an object). The expression github.event == 'push' will silently evaluate to false because it's comparing an object to a string.
Fix 4
Filter by changed paths
Not every commit needs CI. If someone updates a README, fixes a typo in the docs, or edits a license file, there's no reason to spin up a full test suite. GitHub's paths-ignore filter lets you skip the workflow entirely when only non-code files change. The run won't even start, meaning zero minutes are consumed. We cover this in depth in why docs changes trigger full CI.
on: push: branches: [main] paths-ignore: - 'docs/**' - '**.md' - 'LICENSE' - '.gitignore' pull_request: paths-ignore: - 'docs/**' - '**.md' - 'LICENSE' - '.gitignore'
There are two variants: paths (an allow-list that only runs when these paths change) and paths-ignore (a deny-list that runs for everything except these paths). You can't use both on the same trigger. For most repositories, paths-ignore is simpler. For monorepos, use paths to create separate workflows per package so that backend CI only runs when app/** changes. See our monorepo CI optimization guide for detailed patterns.
One caveat: if this workflow is a required status check in your branch protection rules, skipped runs will leave the check in a permanent “Pending” state, which blocks the PR from merging on docs-only changes. GitHub does not mark a skipped workflow as passing. The simplest workaround is to move path filtering into the job using a paths-filter action (like dorny/paths-filter) so the workflow always runs but individual jobs are skipped. The workflow still reports a status, and branch protection sees a passing check.
Fix 5
Gate the full suite behind a merge queue
The ultimate version of this pattern moves the full test suite entirely out of PR CI and into the merge queue. PRs get only the fast preflight (lint + unit). When a PR is approved and enters the queue, the merge queue runs the full suite against the actual merge state, meaning your PR plus everything ahead of it in the queue, merged into the target branch.
This is higher-fidelity than standard PR CI because it catches conflicts between concurrent PRs. And it reduces total CI spend because the full suite runs once per merge, not once per push to the PR branch. Without a merge queue, every PR gets its own CI run at merge time. When 5 PRs are ready to merge, that's 5 separate validation runs. The queue groups them into batches, testing PRs against the actual state they'll merge into.
name: CI on: push: branches: [main] pull_request: branches: [main] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: # Fast checks - run on PRs and merge queue lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run test:unit # Full suite - only in merge queue or on main integration-tests: if: github.event_name == 'merge_group' || github.ref == 'refs/heads/main' needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run test:integration e2e-tests: if: github.event_name == 'merge_group' || github.ref == 'refs/heads/main' needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright test
Enterprise Cloud Merge queues require GitHub Enterprise Cloud for private repositories. Public repos in an organization can use them on any plan. Personal (user-owned) repos and GitHub Team private repos are not supported.
If your team merges 10 PRs per day and each PR averages 5 pushes, the full suite runs 10 times (once per merge) instead of 50 times (once per push). That's an 80% reduction in full-suite minutes, with the bonus of catching inter-PR conflicts that standard PR CI misses entirely. The queue gives you three things: batching (N PRs validated in one run instead of N runs), ordering (PRs tested against actual merge state, not stale bases), and automatic ejection (failed PRs removed without blocking the queue).
Reference
Which approach to use
The five fixes above aren't mutually exclusive. Most teams start with Fix 1 (concurrency groups) and Fix 2 (scoped triggers), add Fix 3 (preflight) if developers want push feedback, layer on Fix 4 (path filters) for docs-heavy repos, and adopt Fix 5 (merge queue) when the team is large enough that concurrent PRs cause merge conflicts.
| Approach | On push | On PR | Savings |
|---|---|---|---|
| Concurrency groups only | latest only | full suite | 30–50% |
| Scoped triggers | nothing | full suite | 60–80% |
| Preflight + gated suite | lint + unit | full suite | 40–65% |
| Merge queue gating | nothing | lint + unit | 70–90% |
Savings percentages assume the typical pattern of 5–8 pushes per branch before merge. Teams with higher push frequency see larger savings. The merge queue approach shows the highest reduction because the full suite runs only once per merge, regardless of how many pushes occurred during development.
Reference
Complete optimized workflow
Here's all five fixes combined into a single workflow file. This is a good starting point. Copy it and adjust the paths-ignore list and the if conditions for your repository.
name: CI # Fix 2: Scoped triggers - no duplicate runs on PRs on: push: branches: [main] paths-ignore: # Fix 4: Skip docs-only changes - 'docs/**' - '**.md' - 'LICENSE' pull_request: branches: [main] paths-ignore: - 'docs/**' - '**.md' - 'LICENSE' merge_group: # Fix 5: Merge queue support # Fix 1: Auto-cancel superseded runs concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: # Fix 3: Fast preflight on every trigger lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run test:unit # Fix 5: Full suite gated behind merge queue / main integration: if: github.event_name == 'merge_group' || github.ref == 'refs/heads/main' needs: [lint, test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run test:integration build: needs: [lint, test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run build
Reference
Current GitHub Actions rates
To estimate your own savings, you need to know your per-minute cost. Here are the current standard hosted runner rates as of January 2026:
| Runner | Rate | Multiplier |
|---|---|---|
| Linux 2-core | $0.006/min | 1x |
| Windows 2-core | $0.010/min | 2x |
| macOS (M1/Intel) | $0.062/min | 10x |
| Linux ARM64 2-core | $0.005/min | 0.8x |
Free tier included minutes: 2,000/mo (Free), 3,000/mo (Team/Pro), 50,000/mo (Enterprise). Minutes from macOS runners consume free tier at the 10x multiplier rate.
FAQ
Common questions about CI trigger optimization
How do I stop GitHub Actions from running on every push?
Add concurrency groups to auto-cancel superseded runs, scope your push trigger to main and release branches only, use paths-ignore to skip non-code changes, and consider a lightweight preflight workflow for branch pushes instead of the full test suite. These changes are all YAML-level configuration, meaning no code changes required.
Why does my CI run twice on the same commit?
If your workflow uses on: [push, pull_request] without branch filters, pushing to a PR branch fires both a push event and a pull_request synchronize event. GitHub does not deduplicate these, so you pay for both runs. Fix this by scoping push to only trigger on main. See our duplicate trigger runs guide for a deep dive.
Do cancelled GitHub Actions runs still cost money?
Yes. GitHub bills for every minute a runner was active before the run was cancelled. Concurrency groups minimize the window by cancelling stale runs as soon as a newer commit arrives, but you still pay for the time between the run starting and the cancellation signal. See canceled runs wasting minutes for strategies to reduce this cost.
Should I run CI on feature branch pushes before a PR is opened?
Running the full test suite on every branch push is wasteful because nobody is waiting on the results. A better approach is a two-tier strategy: run a fast preflight (lint + unit tests, about 3 minutes) on every push for quick feedback, and reserve the full suite for pull_request and merge_group events. This gives developers fast feedback at a fraction of the cost.
What is a merge queue and does it save CI minutes?
A merge queue batches approved PRs and runs CI once for the batch against the actual merge state. Instead of running the full test suite on every push to a PR branch, the full suite runs only when the PR enters the queue. This can reduce full-suite CI minutes by 70–90%. Merge queues require GitHub Enterprise Cloud for private repositories; public repos in an organization can use them on any plan.
Will paths-ignore break my required status checks?
It can. If a workflow is a required status check and it gets skipped due to paths-ignore, the check stays in "Pending" state and blocks the PR from merging. The workaround is to use a job-level path filter action (like dorny/paths-filter) instead. The workflow always runs and reports a status, but individual jobs are skipped when only non-code files changed.
Related guides
Fix Duplicate CI Runs from Misconfigured Triggers
Eliminate double runs caused by unscoped push and pull_request triggers.
Canceled Runs Are Wasting Your CI Minutes
Concurrency groups and debounced triggers to stop paying for runs nobody needs.
Stop Docs Changes Triggering Full CI
Use path filters to skip CI on README, changelog, and config-only commits.
E2E Tests Running Too Often
Gate Playwright and Cypress suites by risk and defer full runs to merge queue.