You change a copy string. One line, one file. You push, you open a pull request, and then you wait. Type check. Unit tests. Two static-site builds. Two Python suites. Template validation. Security scans, twice. Twenty minutes later a green tick appears and you merge — and the merge starts another twenty minutes of the same work on the same tree.
Nobody designed that. It accumulated. Every job in it was added for a good reason, on a day when the repository was smaller and the pipeline was faster, and no single addition was worth arguing about.
But the bill is real, and so is the damage to the feedback loop. This is what we found when we finally measured ours, what we changed, and the setup we would give a team starting today.
Two different costs, and only one shows up on an invoice
Wall-clock cost is the feedback loop. A 20-minute pipeline does not cost you 20 minutes — it costs you the context switch. You go and do something else, you come back an hour later, and a fix that would have taken two minutes with instant feedback takes half a day across three round trips. Developers stop treating the pipeline as a check and start treating it as a toll booth.
Billed cost is the invoice. On GitHub’s Team plan you get 3,000 free Linux minutes a month and pay per minute after that. That sounds like nothing until a single active repository is burning 16,000 minutes a month.
The two costs share the same root causes, which is the good news: fixing the pipeline for money fixes it for speed.
Measure first — and know what you are billed for
Before changing anything we pulled the numbers. Three things to know:
- GitHub bills every job rounded up to a whole minute. A 10-second job and a 59-second job cost exactly the same. This is the most counter-intuitive fact about Actions pricing and it dominates any granular pipeline.
- The per-run
timingendpoint returns zeros on some plans. Do not trust it — compute billed minutes yourself from the jobs endpoint. - The old org-level Actions billing endpoint is gone (410). Use the newer usage endpoint.
R=your-org/your-repo
gh run list -R $R --limit 400 \
--json databaseId,workflowName,headBranch,event,conclusion,createdAt > runs.json
# then, per run:
gh api repos/$R/actions/runs/<id>/jobs \
--jq '.jobs[] | {name, conclusion, started_at, completed_at}'
# billed minutes = sum over jobs of ceil((completed_at - started_at) / 60)
# skipped jobs cost nothing
gh api "organizations/your-org/settings/billing/usage?year=2026&month=8"
Our sample: one repository, ten hours, 400 runs, 1,283 jobs, 1,586 billed minutes for 831 minutes of actual work. The round-up alone was 48%. Across the month, that one repository was 64% of the entire organisation’s Actions bill.
That last number is the important one. If your spend is concentrated in one repository, you do not have a CI cost problem — you have a repository design problem. Hold that thought.
Where the minutes actually went
| Lane | Billed min | Share |
|---|---|---|
| Checks re-running on the release PR | 583 | 37% |
| Checks on feature PRs | 569 | 36% |
| Dispatched deploys | 166 | 10% |
| Informational end-to-end suite runs | 116 | 7% |
| Everything else (pushes, schedules) | 152 | 10% |
Seventy-three per cent of the bill was checks, and most of that was checks proving the same tree twice.
Learning 1: build once, and never on push
The first finding was almost embarrassing. Our integration branch had a long-lived release pull request open against main. Every squash-merge into the integration branch was a synchronize event on that PR, which re-ran the full 15-job check workflow — on a tree the feature PR had finished checking sixty seconds earlier. Twenty-two times in one day. 37% of the bill.
The integration check has genuine value: it proves the combination of merged changes, not just each change alone. But it has that value once per batch, not once per merge.
The rule we ended up with: a push to the integration branch builds nothing and checks nothing. The batch is proved when a human — or a nightly job — marks the release pull request ready for review. That single event dispatches every changed service’s preprod build at the PR head, runs the checks on the batch once, waits for the results, runs the browser suite, and reports one required check.
The same rule applies to deploys. None of our deploy workflows trigger on push. They are workflow_dispatch only, dispatched by the release gate for preprod and by the release for production. Build on demand, at a commit you name.
Learning 2: the diff decides what runs — but not on the trigger
The obvious fix for “every job runs on every PR” is a paths: filter on the workflow trigger. Do not do this if the workflow owns a required status check.
GitHub treats a required check that never reports as pending forever — the PR sits at “Expected — waiting for status to be reported” and can never merge. Worse, and in the opposite direction: GitHub treats a skipped check run as passing. We have a merged pull request that proves it. It went in because a push run of the same workflow reported the job as skipped on the new head, and branch protection was satisfied before the actual PR run had started.
So path scoping belongs inside the workflow:
jobs:
changes:
name: What changed
runs-on: ubuntu-latest
outputs:
js: ${{ steps.classify.outputs.js }}
astro: ${{ steps.classify.outputs.astro }}
python: ${{ steps.classify.outputs.python }}
sam: ${{ steps.classify.outputs.sam }}
steps:
- id: classify
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
# no checkout: read the PR's own diff from the API
mapfile -t files < <(gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" --name-only)
# ...classify each path into an area, write to $GITHUB_OUTPUT
type-check:
needs: changes
if: needs.changes.outputs.js == 'true'
# ...
# the ONE required check: always runs, fails if anything needed did not pass
pr-checks:
name: PR checks
needs: [changes, type-check, build-sites, python-tests]
if: always() && !cancelled()
runs-on: ubuntu-latest
steps:
- run: # fail when a needed job was skipped or failed
Three details that matter more than they look:
- The
changesjob needs no checkout.gh pr diff --name-onlyreads the diff from the API in a couple of seconds. - Shared files must fan out to everything. A change to
package.json, the lockfile, a shared workspace package, or anything under.github/classifies as all areas on. Getting this wrong is how you ship a break that CI cheerfully approved. - The rollup job is the required check, it always runs, and it fails when a job that should have run was skipped or when CI is paused. That closes the skipped-equals-passing hole permanently.
A portal-only pull request now runs three jobs instead of fifteen: 3–5 billed minutes instead of 17.
Learning 3: duplication hides inside reusable workflow calls
Our shared security-checks reusable caps the number of Python files it will scan per call. A repository over that cap calls it twice. Fine — except the second call also re-ran secrets detection, the action-runtime freshness check and the Python syntax check, because those steps had no toggle.
Three jobs × 52 pull-request runs = 156 billed minutes a day for about 35 minutes of real work. Pure duplication, 10% of the bill, invisible until you list jobs by name and count them.
The fix was one input on the shared workflow (run_secrets_scan) and one line in the caller. When you build shared reusable workflows, give every expensive step a per-job toggle from day one — otherwise every caller that needs the workflow twice pays for all of it twice.
Learning 4: right-size the informational runs
Our shared services fire a repository_dispatch at the product repository after each preprod deploy, which ran the full browser suite. Twenty-one runs a day, nine of them killed mid-flight by the next deploy: twenty-seven minutes of work thrown away daily.
Then we measured the suite by tier. Tier 1 — 202 navigation and redirect tests covering the portal’s own routing — was 85% of the suite’s runtime, and it answers a question that deploying a backend service cannot possibly affect. The slice that actually answers “did that backend break the portal” is the sign-ins, the smoke tier and the rendered-data tier: about two minutes serially, one on four workers.
So the suite gained a projects input. The gate and the nightly run everything. The informational receiver runs the two tiers that answer its question — and, crucially, never stamps a commit as verified, because a partial run is not a pass.
Ask of every recurring job: what question does this answer, and what is the smallest thing that answers it?
Learning 5: a per-file loop is not a deploy
The one that stings. Our documentation site deploy took twenty minutes. Not because the build was slow — build, stack update and asset sync were three minutes together. The other eighteen were this:
# ~18 minutes for 560 files
find dist -name '*.html' -exec sh -c 'aws s3 cp "$1" "s3://$BUCKET/..." \
--cache-control "no-cache"' _ {} \;
One aws s3 cp process per HTML file. 560 files, roughly two seconds each of interpreter start-up, credential resolution and TLS handshake, on a billed runner. And the identical loop had been copied into a shared composite action, so every consumer inherited it.
# ~4 seconds
aws s3 sync dist "s3://$BUCKET" \
--exclude "*" --include "*.html" \
--cache-control "no-cache, no-store, must-revalidate"
One process per class of file, with parallel uploads: one sync for the no-cache HTML, one for the immutable hashed assets, one for the JSON and XML. Twenty minutes became two minutes fourteen seconds.
The general lesson: when a CI step loops over files and shells out to a CLI, the loop is the cost. Look for the batch form of the command. This class of defect hides beautifully, because each individual invocation is fast.
Learning 6: the repository is the unit of build cost
This is the deepest finding, and the answer to why 64% of the bill sat in one repository.
The smallest change you can make costs whatever it costs to prove that repository. If a repository holds a marketing site, a documentation site, a customer portal, a Python REST backend, infrastructure templates and an end-to-end suite, then a copy fix in the marketing site is priced like a change to all six — unless you build the diff-classification machinery above, and even then the fixed overhead of checkout, install and cache restore is paid per job.
Path gating is a mitigation. Repository scope is the cure.
The heuristic we settled on: a repository should hold the smallest end-to-end capability that can be deployed and proved on its own. Not a microservice per repository — that trades build cost for cross-repository coordination cost, which is worse. Something you can name in one sentence, that has its own deployment, its own commit statuses, and its own reason to be released.
We recently split our public documentation site out of the product monorepo into its own repository. Its deploy went from an inherited 20 minutes to 2m14s, its pipeline no longer competes for concurrency with the portal’s, and — the part people miss — a documentation change no longer triggers a single job in the product repository. That is not a 10% saving on those pull requests. It is 100%.
We now make every repository declare what it is, in a file the pipeline itself reads:
# .github/cicd.yml
type: deployable # library | deployable
lane: two-stage # deployable only: full | two-stage
enforce: false # true once migrated: findings block the gate
- library — packages, docs, configuration, tooling. It may publish; it never deploys and never assumes a cloud role.
- deployable — one contract, two implementations. The contract: every build happens once, preprod sees a commit before production does, production comes only from
main, deploys are dispatched rather than triggered by a merge, and each environment stamps a commit status.lane: two-stagefor a single-stack service: one run does preprod, then a smoke test, then production, from the same artifact.lane: fullfor a product with several services and a browser suite: integration branch, batched release gate, ordered promotion.
Two lanes, not twenty bespoke pipelines. An audit script reads every repository’s default branch through the API and reports which ones do not conform. Findings are warnings until a repository sets enforce: true, so migration happens at each team’s pace instead of as a big bang.
Our own inventory when we first ran it was humbling: 2 repositories on the full lane, 4 two-stage, 26 still deploying on push — several of them straight to production — 3 dispatch-only with nothing dispatching, 8 libraries, and 9 with no deploy at all. You will not know your shape until you measure it either.
Learning 7: batching is the biggest lever, and it is free
Our release lane was designed around batches. It was being run at batch size 1 to 3 — twenty-three releases in one day; eight in another, for twenty-eight feature pull requests. Every feature was therefore paying the full gate-and-promote cost of 40–70 billed minutes.
There is no code fix for this. It is a working habit, supported by a nightly job that marks the release pull request ready if nobody pressed the button. Batching to one or two releases a day takes a 1,586-minute day under 600 — a bigger saving than every technical change on this list combined, at zero cost in safety, because it is how the lane was meant to be run in the first place.
The setup we would give a team starting today
One workflow per concern, named for what it does:
| File | Trigger | Purpose |
|---|---|---|
pr-checks.yml |
pull_request → integration branch; workflow_call; dispatch |
Everything that proves a change. One concurrency group. A changes job gates every other job. A rollup job named PR checks is the required check. |
deploy-<service>.yml |
workflow_dispatch only |
One per deployable. Nothing builds on push. |
release-pr.yml |
push → integration branch | Opens and refreshes the release PR as a draft. |
release-gate.yml |
pull_request → main, on ready_for_review — never synchronize |
Ready-for-review is the build signal: dispatch builds, call the checks once, wait, run the suite, write the manifest. |
release.yml |
push → main |
Promotes in order: backends, contract smoke, frontends. |
gate-batch.yml |
nightly schedule |
Marks the release PR ready if a human did not. |
Split release-pr.yml and release-gate.yml into two files. They look like one workflow. They must not be: a job whose name matches a required check has to live in a file whose only trigger is pull_request, or a push run of the same file reports it as skipped on the new head and satisfies the rule before the real run starts.
A caller contract that every shared reusable enforces for you. Ours carries, inside each reusable job:
- Concurrency, grouped as
<caller workflow>-<PR number or ref>-<reusable>, withcancel-in-progressonpull_requestevents only. Inside a reusable workflowgithub.*is the caller’s context, so every consumer gets “a new push cancels the superseded run” for free — while a push to a long-lived branch queues instead, so every commit keeps a verdict. - A
timeout-minutesset to the measured maximum across the estate plus headroom. GitHub’s default is 360 minutes, and a hung job bills every one of them. Ours: 60 for a deploy, 30 for a check, 90 for a browser suite. - A
runs-oninput, so a repository with its own runner can pass a label without forking the workflow. - Deploy reusables keep
cancel-in-progress: falseon a per-stack group. Cancelling a check saves money; cancelling a CloudFormation changeset mid-flight can wedge the stack.
A script audits every repository against that contract through the API, a matching fixer applies it textually, and both repository templates ship the gate — so a repository born from a template conforms from its first pull request instead of being retrofitted a year later.
What we deliberately did not do
Honesty about the rejected optimisations matters as much as the accepted ones.
We did not collapse jobs. With 48% of billed minutes being per-job round-up, merging fifteen jobs into three is the obvious move. But six of ours are uses: calls to shared reusable workflows, and a workflow_call cannot be inlined as a step — collapsing means unwinding the shared library that keeps twenty-plus repositories consistent. Granularity buys parallelism, precise failure names and reuse. The right answer to round-up is cheaper compute, not a worse design.
We did not move the push guard. A job that blocks pushes to branches whose pull request is already merged costs 54 billed minutes a day for 3 minutes of work. Moving it to pull_request would defeat it: the job is the push guard.
We did not restructure the gate’s wait. It idles a runner for 8–22 minutes polling for the builds it dispatched. That is structural: a pull_request run must never hold a deploy role, so the gate dispatches on the branch ref and waits. The lever there is fewer gates — batching again — not a cleverer gate.
We did not clone the organisation into a second free tier. It is billing circumvention under GitHub’s acceptable use policy, and it would have broken OIDC subjects, the package scope, every shared workflow reference and cross-repository dispatch — to save the price of one allotment a month.
The results
A typical day went from 1,586 billed minutes to roughly 900, and under 600 once batching discipline holds. The documentation deploy went from twenty minutes to two. A portal-only pull request went from fifteen jobs to three. And along the way, the audit found a genuine correctness gap hiding behind all the waste: the checks were not actually required on the integration branch, so an auto-merging dependency bot had been merging pull requests before their type check finished.
That is the pattern worth taking away. Pipelines are not slow because runners are slow. They are slow because a repository grew past the capability it was built for, because a check was wired to a trigger instead of to a question, and because nobody has ever summed ceil(job_seconds / 60) and looked at the answer.
Spend an afternoon measuring yours. The findings will pay for the afternoon several times over — and the fastest pipeline is still the one that never had to run.
We help teams design CI/CD that stays fast as the estate grows — repository boundaries, shared workflow libraries, release lanes, and the guardrails that keep them honest. Get in touch if that sounds like your afternoon.