Symptom
On a private repository, every workflow starts failing at once, including ones nothing touched. Each job fails within a few seconds with no steps run, and the annotation says:
… recent account payments have failed or your spending limit needs to be increased
Pull requests show red checks that say nothing about the code.
When it happens
- The repository is private, so jobs on GitHub-hosted runners consume the account's included minutes. Public repositories don't.
- The month's minutes are used up and no spending limit allows more. In the reference project this happened twice: on 2026-09-29, and again on 2026-10-02 when October's minutes ran out with the month's reset on 11-01. Both times after heavy use of hosted runners for regression suites.
- Self-hosted runners are not affected. Jobs with
runs-on: self-hosted (or a runner label) still run.
What to do
1. Move what must run to a self-hosted runner, chosen by a repository variable so it can be switched without editing every workflow:
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} # CI_RUNNER=self-hosted while minutes are out
2. Skip, rather than fail, the hosted-only jobs, with one switch. A job-level if: on a repository variable:
jobs:
migrations:
if: vars.HOSTED_CI != 'off' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
HOSTED_CI=off (Settings → Secrets and variables → Actions → Variables) skips the job. Unset, or any other value, runs it as before.workflow_dispatch still runs it, so a person can spend minutes on purpose.- Combine with an existing condition in parentheses:
if: (vars.HOSTED_CI != 'off' || github.event_name == 'workflow_dispatch') && github.event_name != 'schedule'. - For a workflow that picks its runner from the branch name, skip only the hosted case:
if: ${{ !(vars.HOSTED_CI == 'off' && startsWith(github.ref_name, 'suite/hosted/')) }}.
GitHub's documentation says a job skipped by its if: reports success and doesn't block a pull request even when it's a required check. That's the point of skipping instead of failing, and also the risk: the skipped check proved nothing. The reference project wrote that down next to the switch: name the suites run on the self-hosted runner in the pull request, and run release gates there.
3. Leave out on purpose the jobs whose silence would hurt more than a red mark: in the reference project, the release-artifacts workflow (a release without images should be loud) and an optional sharded regression that only runs when explicitly pushed.
4. Clear the switch when minutes are back, and put the date in the docs next to it. A forgotten HOSTED_CI=off silently turns off CI.
Check usage before it runs out
The usage endpoint (observed on 2026-09-27, gh CLI):
gh api "/users/<owner>/settings/billing/usage?year=2026&month=10"
The older /users/<owner>/settings/billing/actions answered 410 Gone that day.
Notes
- Hosted minutes go fastest on matrix jobs across operating systems (on a private repository, macOS minutes count 10× and Windows 2×). In the reference project those were moved to a weekly schedule plus manual dispatch.
- Splitting a long regression across many hosted jobs at once costs about the same total minutes as one machine. It buys wall-clock time, not minutes.
- A self-hosted runner on a laptop sleeps. In the reference project a laptop's power saving paused a run for 54 minutes. Keep it on power with sleep disabled, and give jobs a
timeout-minutes.
The full body — free, open to anyone, no key.
Source: First-hand from operating WITAN's private GitHub repository. Hosted jobs stopped with the quoted billing message on 2026-09-29 (jobs failing within seconds, no steps) and again on 2026-10-02 when October's included minutes ran out. The HOSTED_CI switch, the branch-based runner choice and the deliberate exclusions were merged on 2026-10-02 and shipped in v0.20.3; the suites moved to a self-hosted runner (a Colima VM on a MacBook) chosen by the CI_RUNNER variable on 2026-09-30. The usage endpoint and the 410 on the older one were observed on 2026-09-27. That a skipped job counts as passing for a required check is from GitHub's documentation; that the variable actually skipped each job after it was set was not re-checked for this unit. The laptop pause was on 2026-09-29.