Last updated on
The GitHub Actions bill wasn't a runner pricing problem
Filed under Operations
This week I hit the GitHub Actions spending limit on this repo. My first instinct was to go shopping for a cheaper runner. That instinct was wrong, and the reason it was wrong is the more useful part of the story.
The problem
I run several Claude Code sessions in parallel against this repo, each isolated in its own git worktree. That setup is great for throughput and terrible for CI restraint: every push, every PR comment, every review fires a wave of GitHub Actions workflows. It finally caught up with me. The Actions budget ran out mid-week.
The obvious fix is a cheaper runner. Blacksmith has been on my radar for a while: bare-metal hardware, persistent Docker layer caching, and pricing that claims 60-75% savings over GitHub-hosted runners. I'd already filed an issue to evaluate it, so I went to actually do the migration.
It didn't get past the first step: Blacksmith requires a GitHub organization. This repo lives under my personal account. No org, no Blacksmith, full stop. Pricing and performance never even entered the conversation.
That dead end turned out to be useful, because it forced me to actually look at the usage data instead of reaching for a vendor.
What the data said
I pulled the last 30 days of workflow runs with gh api:
Seven thousand runs in a month, on a solo repo. A tighter 2.5-hour sample from the day the budget ran out told the real story:
Roughly a thousand runs in two and a half hours. Most of them finished in well under a minute, but GitHub bills per job, rounded up to the nearest minute. A 12-second job costs the same as a 55-second one. Runner price per minute was never going to move that number much; run count was the whole problem.
Digging into where the count came from turned up an amplification loop, not just heavy CI:
- The
@claudemention job booted a full runner on every comment and review, then let the action itself decide whether@claudewas actually in the text and no-op if not. Runner boot cost paid either way. - Auto-review re-ran on every single push to a PR, not just when the PR opened.
- The bot that keeps a "why can't I merge this" comment up to date was wired to refresh on three separate workflow completions per push, plus every PR review event. Every batch of inline review comments from auto-review counted as a review event too, so it kept re-triggering itself indirectly.
- None of the ten CI workflows had concurrency cancellation, so pushing a fix while a previous run was still going didn't cancel the stale run. Both just billed to completion.
None of that has anything to do with which company's hardware runs the job.
The fix
Four changes, all in workflow YAML, all runner-agnostic, shipped as one PR:
- Gate the mention job on the trigger phrase in the workflow's
if:, not inside the action. A job skipped byif:bills nothing; a job that boots and then decides to no-op still bills a minute.
jobs:
mention:
if: >-
contains(github.event.comment.body, '@claude') ||
contains(github.event.review.body, '@claude')
runs-on: ubuntu-latest- Drop
synchronizefrom the auto-review trigger. Review runs once, when the PR opens. A fresh look at later commits is one@claudemention away instead of automatic on every push. - Slim the merge-status bot's
workflow_runtrigger from three workflows to one. All the required checks kick off from the same PR event and finish close together; refreshing after the last one to usually complete covers the rest well enough for an informational comment. - Add
concurrencywithcancel-in-progress: trueto all ten CI workflows. A new push now kills the stale run for the same PR instead of letting it finish and bill anyway.
Results
I measured run rate before and after, same day, same repo, in the hours right after the fix shipped:
- Before: ~1,000 runs in 2.5 hours, roughly 400 runs/hour.
- After: 197 runs in 1 hour 40 minutes, roughly 118 runs/hour.
About a 70% drop, and none of it came from switching runners. The fix shipped in under an hour and started paying off immediately.
One of these didn't stick
A few hours after this shipped, synchronize came back.
Auto-review doesn't just leave a one-time comment. It maintains a single sticky summary on each PR, and that summary is supposed to re-verify itself against the latest commit every time the PR changes. Dropping synchronize meant it stopped doing that after the PR opened. On a later PR, a real fix landed and the sticky comment sat stale at its original verdict with no automated way to catch up. The workaround was hand-editing the bot's own already-posted comment directly, which isn't something that should have been possible to reach for in the first place, and a human had to catch it.
The cost concern was real. I'd just cut the wrong thing to fix it. Re-enabling synchronize didn't mean eating the cost again, though: a separate push-velocity gate, already built into the review pipeline for unrelated reasons, turns a rapid burst of pushes into one cheap paused response instead of a full paid review per push. That gate is what makes frequent re-review affordable now, not the trigger removal I'd shipped. Three of the four changes held. This one got replaced by a better mechanism that was already sitting there.
Learnings
Measure before you optimize. I went looking for a cheaper runner because that's the obvious lever to reach for when a bill is too high. The data said the bill wasn't about price per minute at all. It was about how many jobs were running and why. A pricing page can't tell you that; only your own usage data can.
A trigger being expensive doesn't mean it's disposable. Dropping synchronize fixed the cost and broke a piece of behavior something else depended on. Check what's actually using the thing before you cut it. Rate-limit a trigger before you remove it outright.
Automation triggering automation compounds fast. None of the individual triggers looked unreasonable in isolation. A mention job that listens for comments. A status bot that refreshes on review events. CI that reruns on push. Stacked together, one human comment could cascade into five or six billed runner-minutes without a single line of "real" work happening.
If your jobs are already fast, the lever that matters is how often they run, not how long each one takes. A workflow that finishes in ten seconds still costs a full minute, so a redundant ten-second job is exactly as expensive as a real one. Per-job minute rounding punishes job count, not job duration.
Concurrency cancellation should be a day-one default, not a retrofit. All ten CI workflows in this repo were missing it. It's a few lines of YAML, and it directly stops paying for work that's already obsolete by the time it finishes.
Blacksmith's pricing looked compelling right up until I found out personal repositories aren't supported, which ended the evaluation in the first five minutes. Cheaper-runner research is worth doing eventually, on a repo that's actually eligible, but it wasn't the fix I needed this week.
Comments
No comments yet. Be the first to comment.