A continuous-integration bill is not a speed problem. It is a counting problem — and almost every instinct you have about making builds cheaper is aimed at the wrong variable.
push/pull_request triggers, not slow builds. Making a sub-minute job faster saves nothing; the levers that work are running fewer jobs (scope push to the default branch, cancel superseded runs, merge two jobs into one) and knowing what your account tier allows. And any CI you switch off to save money has to be measured first, or you silently drop test coverage you thought was redundant.Twenty-four days into July, a ten-repository private estate had 200 of its 2,000 monthly GitHub Actions minutes left. The obvious diagnosis was that something had grown slow. The obvious fix was to profile the longest job and trim it.
Both were wrong. The estate had run 1,637 workflow runs that month, and reconstructing them from run history gave roughly 1,819 billable minutes against about 680 minutes of actual compute. Nearly two-thirds of the bill bought nothing at all. No job was slow. There were simply far too many of them, and each one was being rounded up.
Table of Contents
What “CI minutes” actually meter
Continuous integration is the practice of running your checks automatically on a server for every change, rather than trusting each developer’s machine. On GitHub Actions, a hosted runner is a fresh virtual machine that boots, checks out your code, runs your steps, reports pass or fail, and is then destroyed. Free accounts get 2,000 minutes per month for private repositories; public ones are unmetered.
The detail that governs everything: Actions bills each _job_, rounded up to a whole minute.
Not each run. Not each second. Each job, rounded up.
A CI bill is not a speed problem; it is a counting problem.
The problem, in numbers
Breaking the month down by workflow and trigger made the shape obvious:
| Workflow · trigger | Runs | Average | Billed |
|---|---|---|---|
| test suite · push | 585 | 29s | 641 min |
| PR base check · pull_request | 349 | 17s | 409 min |
| test suite · pull_request | 331 | 35s | 395 min |
| governance guards · push | 121 | 10s | 122 min |
| project CI · push + PR | 190 | 40s | 190 min |
Read the average column. Ten seconds. Seventeen seconds. Twenty-nine seconds. Every one of those rows is a job that finishes comfortably inside its first billed minute and is charged for the whole thing. The 349-run base check — a single string comparison confirming a pull request targets the default branch — cost 409 minutes to answer one yes-or-no question 349 times.
There were two distinct leaks.
Duplicate triggers. Several workflows declared both push on all branches and pull_request. Push a commit to a branch with an open PR and the same workflow runs twice, on the same code, to the same conclusion. In the largest repository that meant 340 push runs against 124 pull requests — well over half of them redundant.
Per-job rounding. Everything else. Short jobs, billed long.
The approach
The fixes that mattered were structural, not performance work.
- Scope
pushto the default branch. A feature branch is already covered by its pull request run. Keeping the main-branch run preserves a genuine post-merge check, since a merge commit differs from the PR head. Roughly 440 minutes per month. - Cancel superseded runs. A
concurrencygroup keyed on the ref, withcancel-in-progress, stops a second push from leaving the first run to finish and bill in full. Deliberately disabled on the default branch, where the post-merge run must always complete. - Count jobs, not steps. Two jobs mean two rounded-up minutes even if each takes fifteen seconds. Merging them into one job halves that line item.
Three things that looked like savings and were not
This is the part worth stealing, because each of these was proposed in good faith and each was wrong.
Path filters that skip the check you need most. The tempting move is paths-ignore on **.md and docs/** — why run CI for a documentation edit? Because in this estate the CI job validated the documentation: review dates, cross-reference integrity, register consistency. A documentation change was precisely the case that most needed the check. And it would have saved nothing anyway: those runs already finished under the billing floor. Shortening a run that is already under a minute has no effect on the bill.
Splitting jobs for clarity. Separating concerns into distinct jobs is normally good hygiene. Under per-job rounding it is a direct cost increase — an extra job is an extra minute, every time, forever. The same asymmetry runs the other way: a pull request costs the same whether it changes one file or twenty-two. So never split a coherent change into three reviewable pieces to be tidy; you have tripled its CI cost and gained nothing. Batch trivia instead — the one-line “record this fact” commits — and leave substantive changes whole.
Removing a trigger that turns out to be load-bearing. The base check ran on opened, edited, reopened, and synchronize. Since synchronize means “new commits pushed” and cannot change a pull request’s base, dropping it looks like free money — a third of that workflow’s runs re-checking a value that provably did not move.
It is free money right up until you enable branch protection. Required status checks are evaluated against the head commit SHA. Without synchronize, the check runs once at open, you push three more commits, and the newest commit has no check result — so the merge blocks on a check that appears to be missing. The redundant-looking trigger is exactly what keeps the guard working under the configuration you were saving up to buy.
A pull request costs the same whether it changes one file or twenty-two.
The expensive lesson: cost cuts hide coverage gaps
To survive the last week of the month, the heaviest workflows were paused. The stated justification was that a local pre-push hook ran the same checks anyway, so only a backstop was being lost.
That justification was not verified before acting. When it was, the hook ran 37 tests. The CI workflow ran 685.
Pausing CI had not removed a redundant backstop. It had removed the only place 648 tests ran — and in a repository where the test suite had been widened three days earlier for exactly that reason, after a broken assertion sat undetected on the main branch across several releases.
The lesson generalizes past CI. A redundant system is only redundant if you have measured that the substitute covers the same ground. “The local hook does the same thing” was a belief, held confidently, that took one command to falsify.
The durable fix was not to remember this. It was to make the two sides fail loudly when they diverge: a test that reads both the hook script and the workflow file, extracts each pytest invocation, and fails if they differ. Widen one, and the build tells you to widen the other. The local suite is now 46 seconds slower per push, which is a trivial price for the only automated check that survives CI being unavailable.
A redundant system is only redundant once you’ve measured that the substitute covers the same ground.
What the budget cannot fix
One finding reframed the whole exercise. The obvious escape from metered minutes is a self-hosted runner — your own machine, unbilled and unlimited (the same self-host-to-escape-metering logic behind running a local AI agent instead of a metered API). But self-hosted runners register at the repository, organization, or enterprise level, and a personal account only has the repository level. “One runner for the estate” was not available at any price: it meant ten registrations and ten background services on one host. A shared runner required creating an organization first.
Worth knowing before that migration tempts you: a free organization still does not include branch protection on private repositories. You can spend days moving ten repositories and arrive with unlimited minutes and no enforcement.
There is also a real security shift. A hosted runner is a disposable VM. A self-hosted runner executes workflow code on your machine — the one with your production database and your credentials file. That is defensible when every repository is private with no outside contributors, and stops being defensible the moment one goes public, because a fork’s pull request would then execute on your box. Managed-versus-own-machine is the same trade you weigh when choosing a hosting service: control and cost on one side, isolation and someone else’s patching on the other.
Results and verdict
Trigger scoping and cancellation recovered roughly 440 minutes per month, about a quarter of the bill, and cost nothing in coverage. Pausing non-active repositories bought the remaining runway. Pinning the local hook to CI closed a 648-test gap that had been invisible.
The honest verdict: at around 400 pull requests a month, per-PR job rounding dominates, and no amount of workflow tuning removes it. Each pull request carries a fixed floor of roughly four billed minutes — the PR run, the base check, and the post-merge run — regardless of its size. Tuning reduced the bill; it could not change the shape.
If your CI spend is genuinely constraining you, the first question is not “which job is slow?” but “how many jobs do I run per change, and why that many?” Answer that and you will usually find a duplicate trigger and a job that should have been a step. Both are worth more than a week of profiling. Watch, too, for the job that runs and enforces nothing: a green check that does no work still bills its rounded-up minute every time, and a later re-audit of this same estate found one such no-op guard had quietly become its single largest line item — a guard that, on a plan without required status checks, could never have blocked a merge anyway. It is the same counting error that makes a database’s connection limit look like its concurrency limit: the visible number is rarely the one that governs the cost.
Data period: 1–24 July 2026, reconstructed from workflow run history across ten private repositories. All metrics first-hand.
The short version
- The bill tracks job count, not job duration. GitHub Actions rounds every job up to a whole minute, so speeding up a sub-minute job saves nothing.
- Two leaks dominate: duplicate
push/pull_requesttriggers running the same commit twice, and many short jobs each billed a full minute. - The fixes are structural: scope
pushto the default branch, add aconcurrencygroup withcancel-in-progress(never on the default branch), and merge two rounded-up jobs into one. - Don’t cut blind. A
paths-ignorerule or a dropped trigger can remove the check you most need, and pausing CI can silently drop test coverage — measure what a backstop actually runs before you trust it. - Your account tier caps your options more than your budget does: personal accounts register runners only per-repository, and a free organization still lacks private-repo branch protection.
Frequently Asked Questions
Does making my CI jobs faster reduce my Actions bill?
Usually not. Actions bills each job rounded up to a whole minute, so a job that finishes in ten seconds and one that finishes in fifty seconds cost the same. Optimization only pays once your jobs routinely exceed a minute. Below that threshold, the only levers are running fewer jobs and running them fewer times.
Why does my workflow run twice on the same commit?
Almost always because it declares both push and pull_request triggers. Pushing to a branch with an open pull request satisfies both, so the same workflow runs twice on the same code. Scoping push to your default branch fixes it while keeping a genuine post-merge check.
Are private repositories charged differently from public ones?
Yes, and it is the single biggest factor. Actions minutes are metered only for private repositories; public repositories on GitHub-hosted runners are unmetered. An estate that is entirely private meters every single run.
Is a self-hosted runner a safe way to avoid minute charges?
It removes the charges, but it changes your exposure. Workflow code runs on your own machine rather than a disposable VM, so anything that can land a workflow file in your repository can run code with that machine’s access. It is reasonable for private repositories with no outside contributors, and unwise the moment a repository accepts pull requests from forks.
Should I split large pull requests into smaller ones to speed up CI?
Not for cost reasons. A pull request carries a fixed CI cost regardless of its size, so splitting one coherent change into three multiplies that cost. Split for review quality when review quality genuinely improves; batch small mechanical changes together when it does not.