All writing

Every job failed in seconds and the logs were empty: it was billing

When every GitHub Actions job fails in one to three seconds with no log, GitHub has usually refused the run over billing. The reason sits in a check-run annotation. Where to find it, and what to do.

Note3 min readFacts checked

Contents

Every job in the run goes red at once, the trivial ones included, each within one to three seconds. This caught me in September, and the obvious way to debug it leads nowhere.

What it looks like

The jobs end with the conclusion failure, the same one a broken test produces, and there is no log to read.

The commands in this note run inside a clone of the repository, or anywhere with GH_REPO=owner/repo set. Replace the <…> values yourself; gh fills in {owner} and {repo} on its own.

$ gh run view <run-id> --log-failed
log not found: <job-id>

The jobs API shows that nothing ran: no runner name, no steps.

gh api "repos/{owner}/{repo}/actions/jobs/<job-id>" \
  --jq '{conclusion, runner_name, steps}'
{"conclusion":"failure","runner_name":"","steps":[]}

Where the message is

The message exists only as a check-run annotation. Two views display it. On github.com it sits under Annotations on the run's summary page. In the terminal, plain gh run view <run-id> prints it under ANNOTATIONS, then suggests --log-failed. That suggestion is the natural next step, and it is the one that comes back empty.

For scripts and agents, read the annotations directly. A job's id doubles as its check-run id:

run=<run-id>
for id in $(gh run view "$run" --json jobs \
    --jq '.jobs[] | select((.steps | length) == 0) | .databaseId'); do
  gh api "repos/{owner}/{repo}/check-runs/$id/annotations" \
    --jq '.[] | select(.annotation_level == "failure") | .message'
done

The filter on failure matters, because notices such as runner-image migration announcements arrive in the same list. What comes out:

The job was not started because recent account payments have failed or your spending limit needs to be increased. Please check the 'Billing & plans' section in your settings

What the message means

The message covers two different causes, and only one of them is about minutes.

The first is the account's payment state. A past-due payment locks an account's paid features, and according to GitHub's docs, a failed authorization hold can lock a personal account's billing and "block GitHub Actions or other free-tier access". Public repositories are no shelter: the same refusal has turned up on a public repository running a standard ubuntu-latest runner, where minutes cost nothing.

The second is minutes and money. In GitHub's current billing docs, the control behind "spending limit" is a budget. A budget with Stop usage when budget limit is reached blocks further use once it is spent; without that option, GitHub only emails you when you go over. Without a payment method on file, usage is blocked as soon as the included quota runs out.

Getting unblocked

Only someone who manages the account's billing can clear it: the owner of a personal account, or an organization owner or billing manager. The annotation's "Billing & plans" is now Billing & Licensing in settings. There, repair or add the payment method under Payment information, or raise the budget that stopped usage under Budgets and alerts. GitHub's page on unlocking a locked account says a payment can take up to 24 hours to process. Then re-run what was refused:

gh run rerun <run-id> --failed

Why private repositories hit it first

Standard GitHub-hosted runners are free in public repositories. Private repositories draw on the plan's included minutes (2,000 a month on GitHub Free) and pay the published per-minute rates after that:

RunnerUSD per minutevs Linux
Linux 2-core0.0061×
Windows 2-core0.0101.7×
macOS 3- or 4-core0.06210.3×

GitHub rounds each job up to a whole minute. A macOS minute costs about ten times a Linux minute, so once the included minutes are gone, a matrix with one job per OS, all of the same length, spends about 80% of its money on the macOS job.

Cheap mitigations

  • If the repository can be public, make it public. That takes it off the minutes meter, but a failed payment still blocks it. On the Free plan a public repository also gets rulesets and secret scanning, which a private one goes without.

  • Run the expensive operating systems on main and tags, and only Linux on pull requests. The case() expression keeps that in one job:

    on:
      pull_request:
      push:
        branches: [main]
        tags: ["v*"]
    
    jobs:
      test:
        strategy:
          matrix:
            # pull requests: Linux only; main and tags: all three
            os: ${{ fromJSON(case(github.event_name == 'pull_request', '["ubuntu-latest"]', '["ubuntu-latest", "windows-latest", "macos-latest"]')) }}
        runs-on: ${{ matrix.os }}
        steps:
          - uses: actions/checkout@v7
          - run: make test
  • Prefer fewer, longer jobs over many tiny ones. With per-job rounding, a twenty-second job is billed as a full minute.

  • Check that the included usage alerts are on, under Billing & Licensing → Budgets and alerts. They email at 90% and 100% of the plan's free allowance. If a budget is set to stop usage, turn on its threshold alerts too (75%, 90%, 100%), because that budget, not the free allowance, is what stops the jobs. Neither kind tracks the payment side.

The tell

A failure in your code leaves a trail: a step, an exit code, a line of output. A refusal from the platform leaves none, because nothing ran. When every job is red within seconds and there is no log to read, check billing before you open the YAML.