Over a quarter we grew a careful set of governance workflows. Over a week we deleted almost all of them. The bill was the trigger — but the autopsy found something more useful than an overspend.
This is the piece the cost stories were circling. Strip the billing away and a plainer question is left: what were these guards ever for?
Table of Contents
What we built, and why it felt right
The estate is ten private repositories worked by one developer. Over a quarter, governance accreted the way it usually does — each piece a reasonable answer to a real incident:
- A shared-guards workflow. One canonical set of checks (layer isolation, documentation discipline, naming rules) that every repo should run, wired to clone a private tooling repo and execute them in CI.
- A pull-request base check. A guard that a PR targets the default branch, written after a stacked PR — one based on a feature branch — got auto-closed and orphaned when its base was deleted on merge.
- The test workflow. The actual suite, on every push and every pull request.
Each looked like diligence. Together they looked like a governed estate. That appearance is exactly the trap.
Why they couldn’t do their jobs
Two structural facts, invisible until you look, hollowed the first two out.
On this plan, a check cannot block a merge. Required status checks — the setting that makes a red ✗ stop a merge — are a paid feature; the API refuses to enable them on a private repo without a plan. So every one of these checks was, at best, advice next to the merge button. The shared-guards workflow was worse than advisory: the token it needed to clone the tooling repo was never set in any repo, so its first step was exit 0 — a green check that had executed nothing at all.
A check that cannot block a merge is not a gate. It is a decoration that boots a virtual machine.
A workflow can’t deploy itself. A git hook installed through core.hooksPath covers every repo on the machine at once, for free, forever. A GitHub Actions workflow only runs from a file committed inside the repo it guards — so a “universal” workflow guard has no distribution. Ours was written in one repository and copied nowhere, and months later the identical orphaned-PR bug recurred in a second repo that never got the file. The guard wasn’t wrong; it simply wasn’t present where it was needed, and nothing made it present.
What was actually doing the work
The whole time, the real enforcement lived somewhere quieter: a pre-push git hook, deployed globally through core.hooksPath, running the same checks on every push on the one machine that mattered. It blocked — a failing check refused the push — which is more than the advisory CI could ever do. And because it was deployed once to the machine rather than committed per repo, it could not go missing from “a repo that never got the file.” The mechanism we trusted least, because it wasn’t visible on the pull-request page, was the only one holding the line.
The reckoning, and the fate
When the month’s Actions minutes ran out, the theater became impossible to ignore: every workflow went red on billing, and merges sailed through anyway, because nothing had been gating them. So we retired, recoverably, almost the whole set:
- the shared-guards runner and the base check — removed from the canonical set and every repo, their intent moved into the global pre-push hook (and, for the base rule, a small local lint that reads open pull requests and warns);
- the test workflow — kept, but recognized for the one thing it uniquely provides.
The estate’s governance did not get weaker. It got honest. The rules that were being enforced locally went on being enforced locally; the rules that were being “enforced” by an advisory ✗ started being enforced for the first time, by a hook that can actually say no.
Governance didn’t get weaker when we deleted the workflows. It got honest.
So what was their real role?
Reassembled from the wreckage, each retired workflow had been reaching for one of two things — and metered CI was the wrong instrument for both:
- Encoding intent. “PRs target the default branch.” “These layer rules matter.” That’s valuable and worth writing down — but a workflow file is a poor place to write it, because it only speaks when it runs, only in the repo it sits in, and (on this plan) only as a suggestion. Intent belongs somewhere that travels and that acts: a hook that runs everywhere, or plain documentation that a human reads.
- Running the check where the mistake is made. The point of a guard is to catch the error at the moment it happens, in every place it can happen. A per-repo workflow does the opposite — it catches it in one place, after the fact, without the power to stop it. “Everywhere, at the moment, with teeth” is the definition of a globally-deployed pre-commit/pre-push hook.
Which leaves the honest, narrow role that is genuinely CI’s alone: a clean, reproducible environment for code from a machine you don’t control. A local hook runs on the author’s box, with the author’s dependencies, against the author’s checkout. The day a second contributor appears — or an outside pull request, or a machine whose hooks you can’t guarantee — that clean-room, can’t-be-skipped property is exactly what CI provides and a hook cannot. Until that day, on a small private estate, the correct amount of governance CI is close to none.
A rule you can borrow
Before you write a guard, ask three questions in order:
- Is it static and deterministic? (a lint, a naming rule, a unit test) → a git hook, deployed globally. Free, everywhere, with teeth. Never a workflow.
- Does it need a clean, reproducible environment you can’t trust the author’s machine to provide, or must it run on code from a machine you don’t control? → CI. This is what CI is for.
- Is it really just intent — a rule people should know? → write it down. A workflow that can’t block is documentation with a server bill; the documentation without the bill is strictly better.
lint / naming / unit test → git hook (global) blocks, everywhere, free
untrusted machine / reproducible env → GitHub Actions CI
a rule people should know → write it down
A workflow that can't block a merge is documentation with a server bill — prefer the hook, or the doc.
The trap we fell into was answering “should this be governed?” with “add a workflow,” when the useful question was “where should this guard run?” Most of the time the answer was: not in the cloud, and not for a bill.
The question was never “should this be governed?” — it was “where should this guard run?”
The short version
- The retired workflows were enforcement theater — on a plan without required status checks they could show an advisory ✗ but never block a merge, so they never enforced anything.
- A workflow can’t deploy globally, so a “universal” guard protected only its home repo, and the bug it targeted recurred where the file never reached.
- The real enforcement was a globally-deployed pre-push git hook all along — it blocks, and it can’t go missing from a repo.
- Their real role was intent + running the check where the mistake happens — both of which belong in a git hook (or documentation), not metered CI.
- CI’s honest, narrow job is the one a hook can’t do: a clean-room run for contributors and machines you don’t control. Below that bar, a small estate needs almost no governance CI.
- Decision rule: static/deterministic → git hook; needs a clean room or untrusted contributors → CI; pure intent → write it down.
Frequently Asked Questions
Git hooks vs GitHub Actions — when should a check be one or the other?
Make it a git hook when the check is static and deterministic (linting, naming rules, unit tests) and you want it enforced on every push, everywhere, for free — a hook deployed via core.hooksPath blocks the operation and covers every repo on the machine. Reach for GitHub Actions when the check needs a clean, reproducible environment you can’t trust a developer’s machine to provide, or must run on code from a contributor whose machine you don’t control. The first is enforcement; the second is a clean room.
Do GitHub Actions checks actually block a merge?
Only if you have required status checks enabled, which need branch protection — a paid feature on private repositories. Without it, a red check is advisory: it shows next to the merge button but cannot stop the merge. Many small teams believe CI is gating their merges when, on the free tier, it is only displaying an opinion.
If a workflow can’t block anything, is it useless?
Not useless, but rarely worth a per-run cost. Its value is signal (a human notices the ✗) and a record of intent. Both are usually served better by a local hook that actually blocks, or by plain documentation that carries no compute bill. Keep an advisory workflow only when the signal genuinely changes behavior and the cost is negligible.
Why not just enforce the shared guards in CI for every repo?
Because a workflow has no global deployment path — it runs only from a file committed in each repo, so “every repo” means copying and maintaining the file everywhere, and a repo that misses the copy is silently unguarded. A git hook installed through core.hooksPath is deployed once to the machine and covers every repository automatically, which is exactly the property a cross-repo guard needs.
When does governance CI finally earn its place?
The moment your code stops being written only on machines you control: a second contributor, an outside pull request, or a build whose environment must be reproducible independent of any one laptop. That is when the clean-room, can’t-be-skipped nature of CI becomes worth paying for. Before then, on a single-developer private estate, a global pre-push hook does the enforcing and CI is mostly ceremony.
Isn’t a local hook easy to bypass with –no-verify?
Yes, and that is the honest limit of a hook: it protects a cooperating developer from mistakes, not a hostile one from intent. For a solo or high-trust small team that is the whole threat model, so a hook is sufficient. Once you need to enforce against someone who might deliberately skip the check, you need a server-side gate — required status checks on CI — which is precisely the paid, contributor-facing case where CI is the right tool.