Every chapter so far has treated GitHub Actions as free. It isn't. It's free for public repos on standard runners, which is where most of the examples in this course have lived, and that's exactly why the bill is so easy to miss: you can run this whole course's worth of workflows on a public repo and never see a number. The moment a repo goes private, and most of the work you actually get paid for is private, Actions starts spending a pot of minutes that has a bottom.
This chapter is about what happens when nobody checks where the bottom is. It happened here first, on a private repo running a Playwright suite that failed on almost every push for three weeks. It cost about 1,130 minutes to learn that lesson. You can learn it for the price of reading this chapter instead.
The buried model
Here's the shape of it, and it's worth reading slowly once because almost nobody reads it at all.
Public repositories run on standard GitHub-hosted runners for free. No minutes deducted, no meter running, full stop. Every workflow example earlier in this course, run against a public repo, costs nothing.
Private repositories get a monthly allowance instead: 2,000 minutes on the Free plan, 3,000 minutes on Pro, and 3,000 minutes on Team. Past that allowance, you pay per minute, and the rate depends entirely on which runner you asked for:
Linux (2-core, x64): $0.006 / minute
Windows (2-core, x64): $0.010 / minute
macOS (3-4 core): $0.062 / minute
macOS is roughly ten times the Linux rate for the same wall-clock minute. A ten-minute job on macos-latest costs about the same as a hundred-minute job on ubuntu-latest. If a workflow doesn't need a Mac (and most web projects don't; only iOS/macOS-specific build or notarisation steps genuinely do), the runner label in runs-on: is a cost decision disguised as a config line.
None of this is secret. It's all published on GitHub's own billing docs. It's just not somewhere a working developer passes through on the way to shipping something. You read PR diffs, workflow YAML, and Actions run logs constantly. You almost never read the billing page, because nothing routes you there.
Where the bill lives, and why "blocked" beats "billed"
The Actions minutes usage lives under your account settings, in the billing and plans section, under usage for metered products. It is genuinely a few clicks deep, and it's scoped to the account (or org) that owns the repo, not the repo itself, so if you run several private repos under one account you're watching one shared pot drain across all of them.
The detail that actually changes how you should think about this: without a valid payment method on file, GitHub does not quietly bill you for going over. It blocks Actions entirely once the included quota is used up. No overage charge, no warning banner you're likely to see mid-push, just workflows that stop running.
That single fact retroactively explains a category of incident that otherwise looks like a platform outage: a deploy pipeline that worked fine for weeks, then stopped triggering mid-month, then started again later with no code change in between. If you've ever chased that kind of stoppage by re-reading your workflow YAML for a syntax regression, checking FTP secrets, and eventually giving up and deploying by hand, the actual cause was very possibly the quota, not the pipeline. This site has that exact incident in its own history: a mid-2026 Actions stoppage forced manual tar | ssh deploys across nine separate PRs in a single day before anyone thought to check the usage page. Every one of those manual deploys was a workaround for a bill nobody had looked at.
The red-suite meter
The clearest version of "minutes quietly draining" isn't a spike, it's a habit: a workflow that runs on every single push, takes real minutes each time, and tells you nothing new when it finishes. That's what happened to a Playwright end-to-end suite on a private repo.
The suite ran 69 times over three weeks. Sixty-one of those runs failed. Total billed time: roughly 1,130 minutes, at about 16 minutes a run. In the same repo, over the same window, the fast quality gate (typecheck, lint, unit tests, build) ran 31 times, took 102 minutes total, and failed zero times. One workflow was doing the job a gate is supposed to do. The other was spending most of a Free plan's entire monthly pot to fail in the same way it had failed the day before.
It took three separate mistakes stacked on each other to get there, and each one hid the next.
The first mistake was in the test itself: a page-object navigated to /mortgage, a route that had never existed (the real route was /mortgage-calculator). Every visual-regression baseline the suite generated was, in fact, a screenshot of a 404 page, filed away and treated as a legitimate calculator state. The suite looked like it was passing something. It was passing nothing; it just hadn't been pointed at reality yet.
The second mistake showed up the moment the first one got fixed. Once the page-objects navigated to the real page, the suite started testing real behaviour for the first time, and real behaviour had real, already-catalogued debt: assertion drift against a redesign, a validation test correctly flagging a production no-op, a contrast issue on a decorative element. All genuine findings, all already tracked in the backlog. But a suite that reports the same known failures on every run stops functioning as a gate. Nobody reads a report that always says the same thing, and once nobody's reading it, it's not testing anything, it's just running.
The third mistake is the one with the price tag attached: that permanently-red suite kept running on every push, on a private repo, at 16 minutes a time, all the way through a fast iteration loop that pushed twenty-plus commits. Every one of those pushes spent a quarter of an hour confirming what the backlog already said. Concurrency cancellation, correctly configured, didn't help, because cancellation only saves you when a new push interrupts an overlapping run. Sixteen sequential minutes with nothing else in flight cancels nothing.
Diagnose it in three commands
None of this needed the billing page to surface. The Actions API tells you the whole story from the terminal, and this is the exact query that found it:
gh api "repos/OWNER/REPO/actions/runs?per_page=100" \
--jq '[.workflow_runs[] | {name, conclusion,
mins: (((.updated_at | fromdateiso8601) - (.created_at | fromdateiso8601)) / 60)}]
| group_by(.name)
| map({workflow: .[0].name, runs: length,
total_mins: (map(.mins) | add | round),
failures: (map(select(.conclusion=="failure")) | length)})'
That single call groups every run by workflow name and prints run count, total minutes, and failure count side by side. The rule for reading the output: if a workflow's failure count is close to its run count, you are not running a test, you are paying for a broken alarm.
Once that workflow is identified, two follow-up checks explain the spend:
# what triggers it: push + pull_request together is the expensive default
gh api repos/OWNER/REPO/contents/.github/workflows/<file>.yml \
--jq '.content' | base64 -d | head -20
Check whether concurrency with cancel-in-progress is set (it helps only when runs actually overlap, not when they run sequentially back to back), and check the runs-on: label for a runner more expensive than the job needs. In the case above, concurrency was already configured correctly. It didn't matter, because the problem wasn't overlapping runs, it was that every single run, overlapping or not, was billable and unnecessary.
The spend-to-information-value fix ladder
The fix here is not "delete the suite." An honestly red suite reporting real, tracked debt is worth more than a green suite quietly testing a 404; the repair that made this one truthful was some of the most valuable testing work done on the project. The fix is matching what you spend to what the run actually tells you that you didn't already know:
- Keep the fast gate on every push. It's cheap (minutes, not tens of minutes), it's reliably green, and it protects every single commit. This is chapter 5's ground and it doesn't change here.
- Move a known-red, expensive suite off per-push. Pull request, nightly schedule, or manual
workflow_dispatchwhile its failures are already catalogued. You lose nothing, because a run that repeats a known result isn't news. You get the minutes back. - Cut the timeout to just above a healthy run. A 20-minute timeout wrapped around a suite that normally finishes in eight means every hang bills you twelve minutes of dead time before it gives up.
- Check the runner label before anything else.
runs-on: macos-lateston a job that would run identically on Ubuntu is paying the roughly-tenfold rate for zero benefit. - Move the suite back to per-push once it's green. A failure is only worth 16 minutes of everyone's push budget when a failure is, once again, actual news.
That ladder is the whole discipline: spend minutes in proportion to how much you learn from the run. A red suite that never changes its answer has stopped being a gate and started being a meter, and a meter you're not reading is just a bill you haven't opened yet.
Find your own meter before it finds you
Run the three-command diagnosis against a private repo you actually maintain and decide, with numbers in front of you, whether any workflow there is a gate or a meter.
Expected behaviour
- A gh api call against repos/OWNER/REPO/actions/runs that groups results by workflow name, showing run count, total minutes, and failure count per workflow
- An identified workflow (or a clean bill of health) where failure count is a large fraction of run count
- For any workflow flagged, a check of its runs-on: label and its on: trigger block for push+pull_request overlap
- A one-line written decision: leave it per-push, move it to PR/schedule/dispatch-only, or fix the underlying test, with the reason stated
PROVE IT gh api "repos/OWNER/REPO/actions/runs?per_page=100" --jq '[.workflow_runs[] | {name, conclusion}] | group_by(.name) | map({workflow: .[0].name, runs: length, failures: (map(select(.conclusion=="failure")) | length)})'
A private repo's Actions workflows suddenly stop triggering mid-month, with no change to any workflow YAML. What's the first thing to check, based on GitHub's documented billing behaviour?
What single word describes what GitHub Actions does to a private repo's workflows once its included minutes run out, if there's no payment method on file?
A teammate says: "our E2E suite has been red for weeks, but at least it's catching real bugs, so leave it running on every push." Using the spend-to-information-value ladder from this chapter, explain what's wrong with that plan and what you'd do instead.
Show answer
A suite that reports the same known, already-catalogued failures on every push isn't providing new information each time it runs, it's repeating an answer everyone already has. That means every push is paying full price (in this case around 16 minutes of private-repo Actions minutes) for zero new signal, which is exactly the pattern that burned roughly 980 of 1,130 total minutes on 61 failed runs in the case study. The fix isn't deleting the suite (it's still finding real, valuable bugs) or leaving it on every push (that's paying repeatedly for a known answer). It's moving it off the per-push trigger to pull-request-only, a nightly schedule, or manual dispatch while it's known-red, keeping the fast gate on every push instead, and moving the suite back to per-push once it's actually green again and a failure would be news.