How I cut ~$500/month by replacing GitHub Actions with git hooks and a coding agent
In May my GitHub Actions bill was $489.85. For a pre-launch side project (Lightwork) with zero users — and roughly two-thirds of it was one workflow, the CI run that fires on every push. June was on the same pace. I was paying about $500 a month, ~$6,000 a year, to check code nobody was using yet.
So I turned the cloud pipeline off and moved it onto a machine I already own. Here's the actual setup, and why it now costs me close to nothing.
What replaced it
No new platform — just three plain pieces:
- A script that is the pipeline.
ci:localruns lint → typecheck → unit → (optionally) end-to-end, fail-fast. Its header comment literally reads "This IS the new CI." - A git hook that enforces it. A
pre-pushhook runs that script on every push; apre-commithook scans for secrets and runs typecheck + lint. The discipline a cloud runner used to impose now lives in the hook. - A coding agent as the operator. Claude Code runs the gate, reads the failures, fixes them, and re-runs — then ships. It's the same agent that burns down my backlog, now pushing through the same hook every other commit does.
Running the checks vs. certifying a merge
Those are two different jobs, so there are two commands — and that split is worth being precise about, because it's where the "but how does GitHub still gate the merge?" question lives.
ci:localruns the checks. This is the pipeline above: lint, typecheck, unit, and — when I ask for it — the slow end-to-end suite. It's what I run constantly while working, and what thepre-pushhook fires automatically. For speed, the inner loop and the hook skip E2E by default. It's pure local: no network, and it posts nothing anywhere.ci:reportcertifies a merge. It's a thin wrapper aroundci:localthat forces the full suite — E2E included — and then posts alocal-cistatus back to GitHub through the API.
That second command exists for one reason: GitHub still won't let me merge
until a check named local-ci is green. The trick is that this keeps working
with no cloud CI at all — here's how.
The main branch has a branch-protection rule that lists local-ci as a
required status check. And a "status check" is less magic than it sounds:
it's just a named pass/fail flag pinned to a specific commit. GitHub lets
anything with write access attach one through its
commit-status API — it
doesn't care whether the flag came from an Actions runner or from my laptop.
So ci:report runs the full suite and, if everything passes, posts a
local-ci = success status to the commit via that API (a one-line
gh api call). Branch protection sees its required check is green and
un-greys the merge button; until that status exists and is green, the merge
stays blocked. The cloud used to be the thing posting that flag; now my
machine is.
So ci:local answers "is my work good?" (constantly, fast, local-only);
ci:report answers "certify this commit for the merge button" (once, full
suite, then posts the status GitHub is waiting on). The compute moved to
my desk, but the merge gate stayed on the server — I just produce the
required status locally now instead of renting a runner to produce it. (And
the old cloud workflows are disabled at the API level, not deleted, so
flipping the whole thing back is a single command if the math ever changes.)
Why this works now and didn't before
Local CI was always possible — npm test runs on a laptop. Actions wasn't
selling compute; it was selling consistency. A tired human won't run the
full matrix before every push, won't remember the deploy sequence at 11pm,
won't wait for three shards. So you rent runners to enforce it. That's also
why local CI always lost: the failure mode was never the test runner, it was
the human operator skipping steps.
A coding agent doesn't skip steps. It runs the same checks with the same indifference on the thousandth push as the first. The consistency I was renting from the cloud is now just how the agent works — locally, for free.
"But that's just a slow pre-push hook"
This is the objection everyone lands on: you've swapped a cloud bill for a
multi-minute hook that blocks every git push. Hard pass. Fair. Two
answers.
The push hook isn't the full suite. On push it runs the fast gate — lint,
typecheck, unit — and skips the slow end-to-end tests. Those run in the
deliberate ci:report step, when I'm actually certifying a merge, not on
every push. Docs-only changes skip even that. So the everyday push isn't a
coffee break.
And the thing waiting usually isn't me. The agent pushes, the agent waits, the agent reads what's red and fixes it — unattended. A slow gate only wrecks your flow if a human is sitting there watching it, and the whole premise is that a human mostly isn't. "Runs without you having to babysit it" was cloud CI's entire pitch; that property didn't go away, it just moved to the agent. The wall-clock still gets spent — it's simply not spent out of my attention.
That's also the honest boundary. If you push by hand all day, your suite genuinely takes 20+ minutes, or you need cloud parallelism across a big team, keep the runners — this isn't for you. It works for me because the agent eats the wait, not because waiting stopped existing.
How a deploy actually runs now
Same principle as the tests: one command per target, run from the terminal
(by me or the agent), with nothing firing automatically. They're all
npm run ship:*, and each runs the deploy guard before it touches the
network.
- Backend —
ship:rulesandship:functionsrun the Firebase CLI locally to push Firestore rules/indexes and Cloud Functions. - Web — Vercel's git auto-deploy is turned off (
deploymentEnabled: false), so a push never ships the site.ship:webbuilds the production bundle locally and uploads it prebuilt with the Vercel CLI. The site deploys only when I run that command. - Mobile, JS-only (OTA) —
ship:otarunseas updatelocally: it bundles the JavaScript and publishes the over-the-air update. For any change that doesn't need a new native binary, that's the entire release, straight from my machine.
And the honest exception:
- Native app-store builds still run in the cloud.
ship:storekicks offeas buildon Expo's build service, not my laptop — producing signed iOS/Android binaries needs that infrastructure, and claiming otherwise would be a lie. The important part for this story: EAS is billed separately from GitHub Actions, so it was never part of the ~$500 I was cutting. What moved local is the GitHub Actions compute — the test/lint/build/deploy-orchestration churn that fired on every push. App-store binaries were always going to build on someone's cloud; this change didn't touch that, and never claimed to.
Store listings get the same hand-on-the-wheel treatment: a command drafts the
"What's new" copy locally and pushes it to a draft store version only —
it never auto-submits. I still tap "Submit for review" myself. Cutting a
release is local too: merging the release PR tags the version, and then I run
the prod ship:* sequence — nothing deploys off the tag on its own.
The deploy guard is where the safety lives
Bringing deploys local is only defensible because every deploy goes through one guarded wrapper that aborts before any network call unless:
- the resolved project, bundle id, and OAuth config all match the intended target;
- the target is test by default — production demands a typed confirmation;
- the working tree is clean and prod ships only from
mainor a release tag; - every required secret is present (checked by name; values are never printed).
A human skips guards when they're inconvenient. The wrapper doesn't.
The honest part
This flipped for me, right now: pre-launch, zero users, one operator. And it's a hybrid, not a purity play — the merge gate still lives on GitHub, and a human with write access could technically hand-post a green status; truly spoof-proof enforcement still wants the cloud as the producer. A team can run this too, but it takes shared setup so every contributor's agent enforces the same checks.
For a solo, pre-launch, agent-operated repo, though, the trade was easy: the bill for checking the code had passed the bill for writing it — so I handed the checking to the same thing already doing the writing, and ~$500 a month went with it.