Last updated on
What actually ate the GitHub Actions minutes
Filed under Operations
A GitHub Actions usage screen told me I'd spent $38.11 of a $50 budget. I wrote a whole paragraph explaining why that must be Claude Code's metered usage from the auto-review bot: plausible, specific, and wrong. Pulling the billing data took one API call and blew the theory up in about ten seconds.
This is the story of chasing that number down to its real source, and the five-line fix (that turned into a four-commit fix) it led to.
The wrong theory
The repo runs a Claude-powered PR reviewer through GitHub Actions: auto-review on every PR, an @claude mention responder, a thread-engagement job. It's easy to look at a dollar figure next to "Actions" and assume it's tracking that: Anthropic API calls metered by the turn.
Except claude.yml authenticates with a subscription OAuth token, not a metered API key. Whatever that $38 was, it almost certainly wasn't Claude.
I only found that out by asking GitHub directly instead of guessing:
That's real GitHub Actions runner-minute billing. This repo's plan includes roughly 3,130 free minutes a month; this billing period had already burned through 9,534. Everything past the free allowance bills at $0.006/minute, and doing the math lands right on $38.
So: not an AI cost problem. A volume-and-duration problem, on infrastructure that had been sitting there the whole time.
Where the time actually went
With real numbers instead of vibes, the next step was a per-workflow breakdown: sample recent run durations, multiply by run count, rank them. Two workflows stood out for very different reasons. Claude, because a full auto-review turn is inherently expensive — that's just what the work costs. And Shell, because nothing about what it does should take that long.
Shell runs shellcheck, shfmt, and a bats test suite with coverage, the same kind of check that finishes in seconds everywhere else in the pipeline. Instead it averaged 150 seconds a run. The culprit was sitting in plain sight in the workflow file:
- name: Install kcov (from source)
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq cmake g++ pkg-config libcurl4-openssl-dev libelf-dev libdw-dev binutils-dev libiberty-dev
cd /tmp && git clone --depth 1 --branch v42 https://github.com/SimonKagstrom/kcov.git
cd kcov && mkdir build && cd build && cmake .. && make && sudo make install
kcov --versionkcov is pinned to an exact version and built from source, because no apt package tracks that pin. Every single run, every PR, every push to main, hundreds of times a month, cloned the same repo at the same tag and compiled the same C++ project from scratch. The binary at the end was byte-for-byte identical to the one from the run before it.
The fix, and the two bugs it took to get there
The obvious move: cache the compiled binary, keyed on the version pin, and only rebuild when that pin changes.
The first attempt cached /usr/local/bin/kcov directly. It looked fine: actionlint was happy, the workflow ran, the job passed. It just never cached anything.
actions/cache restores as the unprivileged runner user. /usr/local/bin is root-owned. The restore step silently failed, fell through to a full rebuild, and the job passed anyway, looking like a working cache while costing exactly the same as before. If I hadn't gone back and read the raw step log instead of just checking green or red, this would have shipped as a no-op that quietly kept billing the same minutes forever, just with an extra cache step bolted on for decoration.
The real fix restores into a user-writable path instead, then elevates only the one copy that needs it:
- name: Cache kcov binary
id: kcov-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/kcov-bin/kcov
key: kcov-bin-v2-${{ runner.os }}-${{ env.KCOV_VERSION }}
- name: Build kcov from source
if: steps.kcov-cache.outputs.cache-hit != 'true'
run: |
cd /tmp && git clone --depth 1 --branch v42 https://github.com/SimonKagstrom/kcov.git
cd kcov && mkdir build && cd build && cmake .. && make && sudo make install
mkdir -p ~/.cache/kcov-bin
cp /usr/local/bin/kcov ~/.cache/kcov-bin/kcov
- name: Install cached kcov binary
if: steps.kcov-cache.outputs.cache-hit == 'true'
run: sudo install -m 0755 ~/.cache/kcov-bin/kcov /usr/local/bin/kcovThat surfaced the second bug. GitHub Actions cache keys are immutable: once something is saved under a key, that entry is permanent, even if it's broken. The first attempt's failed save had already claimed kcov-Linux-42 with an unrestorable tarball, so reusing that key meant the fixed workflow would keep colliding with a cache entry that could never work. The only way out was a new key entirely, kcov-bin-v2- instead of kcov-. Not really a version bump so much as a generation bump: the cached path had changed, not the pinned tool.
One more small thing: adding a fresh uses: line woke up a Semgrep rule that flags mutable action tags. actions/cache@v4 isn't pinned to a commit, and the repo requires it to be. Small and mechanical, but a good reminder that "just add a caching step" still has to clear the same supply-chain bar as everything else.
What the fix bought
Re-running the same commit twice, once to populate the new cache and once to prove it, settled the question:
Roughly 45% off this one workflow, and it repeats every run, from here on, for a workflow that fires 500+ times a month. On its own that's a few dollars. As one line item in a repo that was 3x over its free minutes, it's a real chunk of an overage that a vendor switch wouldn't have touched at all.
The real lesson
None of this needed a different CI provider or an architecture change. It needed someone to read a step log instead of trusting a green checkmark, and to notice that a "pinned, built from source" code comment had quietly meant "recompiled from source, every run, forever" the whole time.
The broader thing worth keeping, though, is the first ten seconds of this whole story: I had a specific, plausible, wrong explanation for where the money was going, and the only reason I didn't act on it was that pulling the real number was one API call away. A couple of hours later, that same instinct, go check instead of extrapolating, is what caught a cache that looked like it was working and wasn't.
Comments
No comments yet. Be the first to comment.