We moved CI to a MacBook Air
One day in October, Freesbe’s repository ran 266 CI jobs. That’s what happens when several AI coding agents work in parallel, each pushing to its own branch, each pull request running format, typecheck, tests, the web build, a desktop package, an Android build and, sometimes, a full screenshot run.
Hosted CI minutes on a private repository are billed. That one day used more than 3,000 minutes, well past the monthly free allowance, and then GitHub stopped starting jobs until the billing was sorted out. CI had become the most expensive and least reliable part of shipping.
First: stop running what doesn’t need to run
Before buying or building anything, we cut the waste:
- The Android build runs only when something that affects it changed: the phone app, the shared packages, the lockfile or the CI config. It was the biggest single job, about 8 minutes, and most pull requests never touch the phone app.
- A new push cancels the previous run on the same pull request. Nobody needs results for a commit that’s already been replaced. Runs on main are never cancelled.
- Screenshots run once a day, not on every pull request.
- Branches aren’t updated just to rerun CI. A pull request whose checks are green and that doesn’t conflict gets merged as it is.
That roughly halved the minutes. The rest moved to our own machines.
Then: self-hosted runners, by weight
GitHub Actions can send jobs to machines you run yourself. We split jobs into two weights, each with its own runner label:
- Light: format, typecheck, tests, the web build, the desktop package. A few minutes each, modest memory.
- Heavy: the Android build and the screenshot suite. Lots of memory, the Android SDK, a real browser.
Today the pool is a MacBook Air with an M-series chip, running three runners (two light, one heavy), plus a small Debian virtual machine with three light ones. The Mac turned out to be the best build machine we have: fast single-core performance, lots of memory bandwidth, silent, and already sitting on a desk.
Which machines run a job is one repository variable. If every self-hosted runner disappeared tomorrow, clearing that variable sends everything back to hosted runners, with no workflow edits.
What stays on hosted runners
Deploys. The deploy job holds the token that can change production, and we don’t want that token on a laptop, ever. So deploys run on GitHub’s own runners, in a protected environment, and nothing else does.
Our rule of thumb: self-host the work that’s expensive and harmless, and keep the work that’s cheap and dangerous on someone else’s locked-down machine.
The outage that taught us about idle timeouts
For a while the pool also included six runners on a Windows PC, inside the Windows Subsystem for Linux (WSL). One afternoon, CI simply stopped. Jobs sat in the queue, the runners showed as offline, and nothing on the PC looked wrong.
The cause was a WSL default we hadn’t thought about: when no interactive session is using the
Linux VM for a while, Windows shuts it down to save memory, background services and all. Our
runners were background services. The fix is one line in .wslconfig (vmIdleTimeout=-1), and the
lesson is broader: a build machine that something else can put to sleep isn’t a build machine.
We’ve since retired that PC from the pool.
What we turned down
- Paying for more hosted minutes. The simplest answer, and the right one for many teams. For us the bill grew with every agent we added.
- A Kubernetes runner fleet. Autoscaling runners are great at scale, and a lot of infrastructure for a small team.
- Fewer checks. Never. The answer to expensive CI is to run it smarter, not to test less.
What we’d tell another team
Measure before you move. Most of our savings came from not running jobs, not from running them somewhere cheaper. Then self-host by job weight, keep deploy credentials off your own machines, and make the fallback to hosted runners one switch away.