Local-first CI: the pre-push hook is the gate and hosted CI is the mirror
My git pre-push hook is the CI gate, and the hosted runner is a mirror that deploys on merge and scans for secrets. That arrangement has been in force since 2026-07-11, when a month of GitHub Actions allowance was gone in two days and every job on the account was failing at setup in under five seconds. The cost side of that decision is its own post. This one is about the mechanism: what a hook has to do before "passes locally" is allowed to mean "may merge".
The runner is one Python script that mirrors each repository's pr-validation.yml, lane for lane, and runs it in three tiers. The pre-push hook runs the quick tier on every push: lint, the static scans for disclosure, route hygiene and workflow context, and a small slice of the unit suite, well under a minute. The full tier adds the build, the whole unit suite, the end-to-end suite, a home-page smoke test and the secret scan, and takes about eight minutes for the frontend. Every green run at either tier writes a record tagged with its tier; only a full-tier record counts at the merge. There are 1,660 records on this machine today, 311 of them full-tier: 997 for the specs repository, 322 for the frontend, 162 for the backend, 81 for the infrastructure stacks, and the rest across the smaller products.
A green run has to be bound to a commit#
The first version of local CI printed a verdict and trusted the human to act on it, which is the same trust model as a sticky note. The version that replaced it writes a JSON record when every lane in a run passes: the repository key, the exact commit SHA, the merge base it was tested against, the tier, whether the tree was dirty, the timestamp, and the Node version. The record lives outside the repository, in a directory the hooks own.
Binding to the SHA is what makes the record mean something. Push a follow-up commit and the record does not follow it; the new commit has to earn its own. Run the tier on a tree with uncommitted changes and the record says so, and the merge gate rejects it, because a record for a commit that does not contain what was tested is a lie with a timestamp. Run a full tier that skipped the build because the dependencies were missing and the writer refuses that too, on the grounds that a hollow green is worse than a red. I learned the last rule from a run that reported GO in a fresh worktree with no node_modules, and the merge it would have blessed was a build failure waiting for the deploy step.
Something has to refuse the merge#
A record nobody checks is a log. The check is a second hook, not in git but on the shell tool that my agents and I issue commands through: it intercepts the merge and deploy commands themselves, resolves the commit that command would promote, and asks the runner whether that exact SHA has a full-tier record. No full-tier record, no merge, for every repository the runner knows. It matches gh pr merge, the deploy scripts, the CDK deploy, and the bare S3 sync that used to be the whole deploy. It rolled out in observe mode first, logging its decision without blocking, until its decisions on real traffic showed it resolving the right commit, and then a sentinel file flipped it to enforce.
The guard has refused me three times this week, and each refusal was correct. Twice the record for a commit had been replaced by a quick-tier record, because I ran the full tier, then pushed, and the pre-push hook's quick run wrote its own record over the full one for the same SHA. The rule that came out of that is push first, attest second. The third refusal was a compound shell command: a merge chained behind another command, which the guard could not parse, so it fell back to the checkout's current commit and found nothing. A bare merge command, alone, from the directory holding the tested commit, is now the only form I use.
The mirror has to know when it drifts#
The one hazard that can make all of this worse than hosted CI is drift. The moment the local runner and the committed workflow disagree, a local pass stops predicting a hosted pass, and the gate is lying with a green light. So the runner hashes each repository's workflow files on every run and compares them with the hashes it last blessed. A changed workflow prints a drift warning until someone reads the new file and blesses it explicitly. The runner also prints every lane it skipped and why, so a local run's coverage is a list you can read, not a feeling.
What it skips is real. A workstation is not a clean image: it has the Node patch version I happen to have, the system packages I happen to have installed, and, this week, another session's end-to-end server already listening on port 3000. Two of my full-tier runs this week failed the end-to-end lane alone while the same spec passed in isolation seconds later, and both coincided with a neighbor holding the port. The runner cannot tell that from a real regression, so the rule until it can is one full tier at a time, machine-wide, and a skipped lane is a named gap I carry into the merge decision rather than a pass.
What stayed on the hosted runner#
Three workflows kept their place. Deploy on merge, because the artifact should be built where it lands, by a runner that is not my workstation. Rollback, because the day I need it is the day my workstation may be the problem. And the secret scan on every push to main, because it is cheap, it runs where the code is public, and a second pair of eyes on credentials costs nothing worth saving. Everything else, the lint and test and build and end-to-end lanes that used to burn the allowance on every push to every pull request, is dispatch-only now, there if a reviewer asks, silent otherwise.
The monthly bill stayed in single digits after that, and the part I did not predict is that the gate got stricter, not looser. Hosted CI passed or failed a pull request. The local gate refuses a merge, refuses a deploy, rejects a record made on a dirty tree, refuses a hollow run, and warns on a stale workflow, and a refused merge names the commit it is refusing. The allowance was what ran out. The record was what I had been missing.
Questions this post answers
- What is local-first CI?
- Running the pull-request checks on the developer's machine as the gate, with hosted CI reduced to a mirror that deploys and scans. In my setup a git pre-push hook runs a quick tier (lint, the static scans, a unit slice; well under a minute) on every push, a full tier (build, whole unit suite, end-to-end, smoke, secret scan) runs before merge, every green run writes a record tagged with its tier and commit, and a guard refuses any merge or deploy of a commit that has no full-tier record.
- Can a git pre-push hook replace GitHub Actions?
- For validation, yes, with three conditions: the local runner must mirror the workflow file and warn when the two drift, a passing run must be bound to an exact commit so a later edit cannot inherit it, and something must refuse to merge a commit without that binding. Deploy on merge, rollback, and the secret scan stay on the hosted runner because they need to run where the code lands, not where it was written.
- How do you stop a local CI run from being faked or skipped?
- Every green run writes a JSON record keyed by repository and commit SHA, tagged with its tier and with whether the tree was dirty. The merge guard, a hook on the shell tool my agents and I issue commands through, resolves the commit a pull-request merge would promote and asks for a full-tier record; no record means the code was never tested at that tier. Pushing a new commit does not inherit the old record, and the guard rejects a record made on a dirty tree, so a record cannot bless code that was never committed.
- What does local CI miss that hosted CI catches?
- Anything the workstation cannot reproduce: a clean operating system image, a different Node or Python patch version, missing system packages, and a runner nobody else is using. My local runner prints each skip as a named gap instead of a silent pass, and two of this week's false end-to-end failures were another session's run holding the same port.
Keep reading
When CI Costs More Than It Saves
GitHub Actions' default minute allowance is priced for a team that types at human speed. At agent velocity the bill breaks before the engineering does. Here is how a forced workaround, a local CI mirror plus local deploys, became the better default.
How many AI coding agents can you actually run at once?
About ten on one subscription, and the model is never what stops you. Four shared resources under the agents set the ceiling: the link to the vendor and its quota window, the git checkout, the port your tests bind, and the file every branch regenerates. What a seven-agent wave broke, with counts.
A One-Day Security Baseline for a Solo Fleet
You cannot out-staff a security team when you are the whole team. But the failures that actually end a solo operation are a short, known list, and each has a cheap defense you set up once. Here is the catastrophic floor I stood up in an afternoon.
Lesson 3: The check that fails
Third lesson in a series on running an AI-powered software team of one. A rule you write in a README gets broken by the first session that never reads it. A folder of one-script checks, run by git before every push, turns your most-repeated rule and the verify line from Lesson 1 into commands that fail. Your agent writes each check; you watch it fail once.
How this site is organized
The navigation on caskeycoding.com is a build artifact: one public content graph, several switchable views projected from it, and CI checks that keep the graph and the routes from drifting apart.
Tell Me Everything That's Wrong: Validation as a Batch Operation
Why good validation reports every problem at once instead of failing on the first one, and how to build the accumulator, phasing, and structured errors that make it work.
Follow the work
New tools and writing as they ship — pick a channel.