Skip to main content
Matt Sears

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.

Three-panel diagram of where my CI runs. Original: the real GitHub Actions workflows run on GitHub's rented cloud runners at $489 a month. Version one: the pipeline is rewritten as local scripts on my Mac with a hand-posted merge status — about $0, but a second copy that can drift. Version two, the better fix: the exact same unchanged workflows run on my Mac registered as self-hosted runners, still gated by GitHub's real merge checks, also about $0 but with nothing to keep in sync.

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]   # after

Everything 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 queued isn'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.

Same CI I always had — now running free on my own Mac — Matt Sears