Before Optimizing CI Cost, Read the Billing-Usage API
Promoted from the always-loaded ci-cost-read-the-billing-api.md portfolio
rule, whose stub keeps the gate line.
Any "make CI cheaper / faster" task starts with a guess about where the cost is, and across a portfolio that guess is reliably wrong. The intuition follows where the interesting work is (the private repos, the big builds); the actual minutes follow how often a workflow fires, which is dominated by automation nobody thinks of as expensive. GitHub will tell you exactly, per repo, per month, per SKU — ask it first, then optimize.
When to Use This Skill
| Use this skill when... | Skip when... |
|---|---|
| A runner-tier, concurrency, caching, or scheduling change is pitched as a cost or speed win | The target is a single known-slow workflow you are already measuring directly |
| About to remove a workflow "because it must be expensive" | |
| A bill moved and the cause isn't obvious — the per-repo × per-month breakdown localizes it in one call | |
| Adding automation, to decide whether it gets its own job |
The endpoint (the obvious one is gone)
gh api "/users/<user>/settings/billing/usage"
/users/<user>/settings/billing/actions— the endpoint most examples and most recall still name — now returns HTTP 410 "This endpoint has been moved." That 410 is easy to misread as "no billing data available on this plan" and skip the measurement entirely.
Org equivalent of the working endpoint: /orgs/<org>/settings/billing/usage.
Aggregate before reading; the raw response is one row per repo × month × SKU:
gh api "/users/<u>/settings/billing/usage" --jq '[.usageItems[] | select(.product=="actions" and .unitType=="Minutes")] | group_by(.repositoryName) | map({repo:.[0].repositoryName, minutes:(map(.quantity)|add), net:(map(.netAmount)|add)}) | sort_by(-.minutes)'
Reading it correctly
-
The filter takes the bare repo name, not
owner/repo.select(.repositoryName == "<owner>/<repo>")returns[], which is indistinguishable from "this repo bills nothing";"<repo>"returns the rows. The owner-qualified filter has reported nothing for a repo billing thousands of minutes that month, and a cost argument was nearly built on that empty result. Control-test an empty billing filter against a repo you know is active before believing it. -
netAmount, notgrossAmount, is the spend. Public-repo minutes are free and unlimited, so their rows readgrossAmount == discountAmountandnetAmount == 0. A repo showing a large gross may be costing nothing — but it's still where a runner-tier change would pay off once charges begin, so read both: gross for exposure, net for current spend. -
Group by SKU too (
Actions LinuxvsActions Linux SlimvsActions macOS 3-core) — it reveals which tiers are already in use and theirpricePerUnit, so the cheaper-runner arithmetic needs no lookup. -
Read the monthly trend, not just the total. A flat annual figure hides an exponential; the decision usually turns on the slope.
-
The default response is windowed — the obvious call will not give you the trend. A bare
/orgs/<org>/settings/billing/usagereturns only a slice (observed 2026-08: Jan–Feb only, for a repo with a full year of activity), and nothing in the payload says it was truncated. Build the trend from explicit per-month calls and let the months you asked for be the months you got:for m in $(seq 1 12); do gh api "/orgs/<org>/settings/billing/usage?year=2026&month=$m" --jq "[.usageItems[] | select(.product==\"actions\" and .repositoryName==\"<repo>\")] | map(.quantity) | add // 0" ; done -
Read the outcome distribution, not just the minutes. Minutes tell you something is expensive; outcomes tell you whether you are buying anything. An E2E suite billing close to an hour per run while
cancel-in-progresskilled it about halfway through succeeded a few percent of the time. "Expensive" argues for a cheaper runner; "expensive and almost never succeeding" argues for deleting or fixing the workflow. Same bill, different decision. Pull conclusions alongside cost:gh run list -R <owner>/<repo> --workflow <name> -L 400 --json conclusion --jq 'group_by(.conclusion) | map({(.[0].conclusion // "null"): length}) | add' -
/actions/workflows/<id>/timingis empty for public repos — billable ms is only populated where minutes are billed. Approximate per-workflow cost instead:gh run list -R <owner>/<repo> -L 400 --json workflowName,startedAt,updatedAtthen group durations by
workflowName. Good enough to rank; don't quote it as billing truth — run wall-clock includes queue and finalisation time that is not billed, so it over-counts (measured ~16% above the billing API for the same repo and period). Rank with wall-clock; quote the API.
Minutes are billed per job, rounded up
"GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute" — Actions runner pricing.
Per job, not per run: a workflow with three four-second jobs bills three
minutes. Linux 2-core is $0.006/min, Linux Slim $0.002/min. The billing rows
show the rounding directly — the quantity for Actions Linux and
Actions Linux Slim is always a whole number.
Two consequences when adding automation rather than optimizing it:
- Fold trivial work into a job that already runs. A seconds-long check (for example, renumbering a docs file) belongs in an existing docs job rather than a workflow of its own, which would bill a whole minute per trigger.
- A job skipped by an
if:bills nothing; a job that starts and exits early still bills its rounded minute. When the point is to not pay, put the condition on the job — or on the expensive step, which also skips the model spend of an LLM-backed check.
Why it changes the answer
In a portfolio runner audit, the plan was to move cheap jobs to a 1-CPU runner, and the expected win sat in the private repos with real pipelines. The billing API said one repo — a public plugin collection with no build step worth mentioning — was the large majority of standard-Linux minutes, driven purely by scheduled audits and PR automation. Every private repo that would have been optimized first was ≈0 minutes. The month-over-month trend (roughly tripling over a quarter, with the first net charges arriving) was what justified acting at all. Without that one query the work would have landed almost entirely in the wrong repos.
Related
offload-to-deterministic-substrate.md(in~/.claude/rules/) — one API call beats re-deriving cost from run logs by hand, every time.code-quality-plugin:debugging-methodology§ failure point — same law applied to runtime: measure the thing, don't reason about it. Here the "failure point" is the invoice.github-actions-plugin:multirepo-ci-cd— the portfolio-sweep mechanics you'll use once the billing data has told you which repos to sweep.