On a Tuesday morning GitHub refused to start a single Actions job for our organization. The annotation said our payments had failed or our spending limit needed raising. Neither was true: nothing was past due, the card on file had just been authorized, and there was no spending limit to raise. Support did not answer that day.
We decided to read it the charitable way. GitHub was gently telling us we had outgrown its runners and should go find our own. So we did, that night.
A hold only touches GitHub's machines
A billing hold refuses jobs on GitHub-hosted runners. It does not touch runners you bring yourself. We found that out by registering a runner on a lab Mac and watching a probe job run while every hosted job in the org sat refused. So the fastest way out was not to argue with billing. It was to stop asking GitHub for machines.
"Bring your own runner" used to mean a weekend of infrastructure. It does not anymore.
The presets exist
There is a whole category of providers that plug into Actions as self-hosted runners: install a GitHub App, change one label in your workflow, and each job gets an ephemeral virtual machine that is destroyed when the job ends. We picked Blacksmith for Linux: the cheapest per minute of the ones we compared, one microVM per job, and the same base image as GitHub's runners, so the tools our jobs relied on were already there. For the Apple lanes we already owned a lab Mac, so it runs the iOS and macOS suites for nothing per minute.
A switch, not a migration
The change we are proudest of is how small it is. Every Linux and Apple job that feeds our required gate reads its runner from a repository variable, with the old GitHub label as the fallback:
runs-on: ${{ vars.LINUX_CI_RUNS_ON || 'ubuntu-latest' }}
Set the variable and the jobs move. Delete it and they move back, no pull request required. A small guard test fails the build if a Linux gate job ever drifts off the variable.
We did not move everything. Product deploys, release promotions, and anything holding a static cloud key stayed on the machines we already trusted, because for those jobs the runner is part of the trust boundary. That hardening happens on our schedule, not on the night of an outage.
Provider side: about ten minutes of engineer time and one pull request, which validated itself because a pull request runs its own workflow. Mac side: one evening.
The numbers
The last twenty hosted runs on our main branch against the first runs on the new fleet:
| Job | Hosted | New fleet |
|---|---|---|
| Frontend test and lint | 13.5 min | 6.1 min |
| Backend test, 8 cores | 11.6 min | 5.0 min |
| Visual regression | 2.1 min | 0.9 min |
| iOS unit tests | 14 min | 2 to 4 min |
| Whole gate | about 14 min | about 7 min |
Billed Linux minutes for the same job set fell from 46.7 to 21.8 per run. With the lower per-minute price, that projects our Linux CI bill from roughly $730 a month to $350 to $400; we will check it against the first full invoice. macOS had been the biggest line at roughly $1,600 a month gross, and it also ate most of our included minutes, since macOS counts ten to one against them. It now runs on a Mac we already own.
One caveat: the speed-up is real for CPU-bound suites with warm caches; our sub-minute guard jobs did not get faster. Measure billed minutes, not the marketing number.
The short version: the gate finishes in about half the time (13.8 to 7 minutes, measured), and the Linux and macOS suites that cost roughly $2,600 a month gross on hosted runners are projected to cost $350 to $400 on the new fleet, about 85 percent less. The Windows lane and a handful of deploy and promotion jobs stay hosted for now and sit outside that number.
If you run a small team
- Put the switch in before you need it. A variable-driven label with a hosted fallback costs nothing and turns the next outage into a ten-minute flip.
- Read what a provider's migration wizard will do before you click. Ours would have hardcoded provider labels into every workflow, deploys included, with no fallback.
- Keep credential-bearing jobs on machines you already trust, and harden how they receive their keys before you move them.
GitHub's hold was still in place when we merged the last of it. We never did hear back. It just did not matter anymore.