Same CI I always had — now running free on my own Mac
A few weeks ago I wrote about cutting ~$500/month by turning off GitHub Actions and rebuilding the pipeline as local scripts run by a git hook. It worked. But I've replaced it, because that version had a flaw: it was a reimplementation — a second copy of my pipeline I had to keep faithful to the real one by hand.
The better idea needs no reimplementation at all. I just run my actual GitHub
Actions workflows — the same ci.yml I'd had for months — on my own machine,
using a feature GitHub has shipped the whole time: self-hosted runners.
The whole change is one line per job
A runner is just the program that executes a workflow's steps. Normally GitHub
runs it on a rented VM — the ubuntu-latest in every runs-on: line, and on a
private repo, the meter that was charging me. But you can
run that program yourself:
register your machine, it long-polls GitHub for jobs, and the steps run on your
hardware while the logs and checkmarks flow back up as if a cloud runner produced
them. GitHub doesn't bill Actions minutes for self-hosted runners. That one
fact is the whole story.
So the migration, per job, was:
runs-on: ubuntu-latest # before
runs-on: [self-hosted, macOS] # afterEverything under it — lint, typecheck, the unit suite, the E2E shards, the
deploy chain — is byte-for-byte the workflow I already had. Meanwhile all the
ci-local.js / ci-report.js / ship.js scripts from version one got
deleted. They were clever, and they were a liability: every one was a place
my "local CI" could quietly drift from what the cloud used to enforce. The
self-hosted runner has nothing to drift from. It is the pipeline.
The merge gate got more honest
In version one, GitHub gated main on a check I posted myself with a gh api
call — which meant anyone with write access could hand-post a green status
without running anything. Now the required checks are the real workflow contexts
(🧹 Lint & Typecheck, 🧪 Unit Tests, 🎭 Web E2E Tests); they only go green
when an actual run produces them. The gate is exactly as strict as when I paid
$500 a month for it — it just doesn't cost $500 a month.
I kept the cockpit I'd grown to love
This is the benefit that actually sold me over any clever local-script scheme: I didn't have to give up GitHub Actions to stop paying for it. Every part of the experience I'd spent years getting fluent in is exactly where I left it. Each PR still has its Checks tab with the same status dots; a red one still expands into the same step-by-step logs; "Re-run failed jobs" still does what it always did. The Actions tab still holds the full run history I go back and dig through. Branch protection, required checks, dependabot, the inline annotations on a failing diff — all the wiring still clicks together the way it did when GitHub was hosting the compute.
And it isn't a lookalike that behaves subtly differently under load — it is
GitHub Actions. Same YAML dialect, same marketplace actions I lean on
(actions/setup-node, cache, upload-artifact), same matrix builds, secrets,
and environments. No bespoke config to learn, no homegrown log format, no custom
dashboard that only makes sense to me. Version one asked me to trade a tool I
actually enjoy using for a pile of scripts that lived entirely in my own head.
This trades nothing: I kept the whole cockpit and just moved the engine under my
desk. My muscle memory — where to click, how to read a failed run, which log
line to trust — all still pays off.
The honest trade-offs
- It's slower and it queues. My Mac (three runner instances, for shard parallelism) is not GitHub's elastic fleet. Push a stack of branches and the jobs line up instead of all starting at once.
- It only runs while the Mac is awake. A check stuck
queuedisn't a bug — it's my laptop napping. - Some things stay in the cloud, on purpose. The tiny always-on glue (the PR-title gate, release-please, dependabot) stays on free GitHub-hosted ubuntu so it runs even when my Mac sleeps. And EAS builds, Vercel deploys, and store uploads are external services the runner talks to — never on the meter I was cutting.
I'm running CI/CD exactly like I used to: same YAML, same triggers, same green
checkmarks. The only thing that changed is the invoice — from ~$500/month to
whatever three runner processes add to my electric bill. And it's reversible in a
breath: flip the labels back to ubuntu-latest and the jobs return to GitHub's
fleet, because the workflows never changed in the first place.