1,800 CI Minutes for 680 Minutes of Work: Where a Free-Tier Budget Actually Goes

Date:

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.

The short answer: GitHub Actions bills each job, rounded up to a whole minute, so your CI-minutes bill tracks how many jobs you run, not how long they take. A ten-repo private estate burned about 1,819 billable minutes for roughly 680 minutes of real compute — nearly two-thirds of it per-job rounding and duplicate 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.

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 · triggerRunsAverageBilled
test suite · push58529s641 min
PR base check · pull_request34917s409 min
test suite · pull_request33135s395 min
governance guards · push12110s122 min
project CI · push + PR19040s190 min
A bar chart of where a month of CI minutes went, one bar per workflow, showing billed minutes far exceeding the compute actually used. The push test suite billed 641 minutes for about 283 minutes of real work; the pull-request base check, flagged in red, billed 409 minutes for only about 99 minutes of compute; the pull-request test suite billed 395 for about 193; project CI billed 190 for about 127; governance guards billed 122 for about 20. A note says roughly 62 percent of the 1,819-minute bill was per-job rounding and duplicate triggers, not compute.

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.

  1. Scope push to 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.
  2. Cancel superseded runs. A concurrency group keyed on the ref, with cancel-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.
  3. 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_request triggers running the same commit twice, and many short jobs each billed a full minute.
  • The fixes are structural: scope push to the default branch, add a concurrency group with cancel-in-progress (never on the default branch), and merge two rounded-up jobs into one.
  • Don’t cut blind. A paths-ignore rule 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.

Share post:

Subscribe

Popular

More like this
Related

How to Merge Spreadsheets by URL (and Why Our Tool Reported 5 Misses That Weren’t)

Matching two spreadsheets on a URL column is the same job as matching a stock list to a price list by product code. It goes wrong in the same two places: how you clean the code, and how you count what didn't match.

Many-to-One: Why a Mapping Backlog Outgrows Its Target Taxonomy

You're mapping a messy source vocabulary onto a clean target taxonomy. The target has about 5,600 slots, so the job must be roughly 5,600 decisions — right? The backlog is already past 8,900, and still climbing.

The Fix That Didn’t Take: When a Content-Hash Gate Skips Your Code Change

You shipped a fix to how one field is computed, re-ran the pipeline across everything, and it reported a clean run. Then the consistency check still flags those records as wrong. The fix is in the code, the rebuild "ran" — and the records never changed. Your own optimisation skipped them.

Your Dedup Scan Is Over-Counting — Because It Forgot What You Already Fixed

The report said 80 duplicate groups still to resolve. You'd been resolving them for days. You run it again after a big batch and it *still* says 80-ish. The backlog isn't stubborn — the scan is counting work you already finished.