Back to blog
November 1, 2026 9 min read

My Git Hooks Can't Stop a Bad Merge

Husky catches my mistakes before they leave my machine, but GitHub never sees it run. Milan Jovanović's local CI setup with gh signoff made me rethink what my hooks are for.
GitGitHubCI/CDHuskyDeveloper Tools
Share:
My Git Hooks Can't Stop a Bad Merge

I use Husky on my npm packages. Before code leaves my machine, it runs lint, tests, and type checks, and it catches plenty of dumb mistakes before anyone else sees them.

Then I read Milan Jovanović's post, I Turned Off CI for Pull Requests. He removed cloud CI from pull requests on his Katabench repo and still blocks merges on a green check. This is the line I kept thinking about:

So the hook catches my mistakes early, but it can't protect the branch.

What a hook can't do

A Git hook is a script that runs at a fixed point on one machine, and it has a few limits:

  • git push --no-verify skips it. With Husky, so does setting HUSKY=0.
  • A commit made in GitHub's web editor never touches it.
  • Hooks live outside the commit history. Husky wires them up through the prepare script, so they only exist in a clone after someone has run the install.
  • GitHub receives commits and no record of what ran before the push. A commit that passed every check looks the same as one that skipped them all.

So my hooks help me, and they do nothing for the branch. Protecting the branch takes a rule on GitHub's side.

Why Milan moved CI off pull requests

Milan's Actions bill was growing and waiting on CI was slowing him down. Every pull request ran the .NET build, the tests, and the container image builds, and the same steps ran again on every merge to main.

In the 19 days before the change, that workflow ran 588 times for pull requests, at about 2.7 minutes each. By my math, that's around 1,590 runner minutes in under three weeks.

He got the idea from DHH, who wrote in 2024 that 37signals was moving CI back to developer machines. HEY's remote CI run took about five and a half minutes, and DHH's Linux desktop ran the same checks in under half that time. DHH also said this doesn't fit a codebase the size of Shopify or GitHub.

Milan's setup has four parts:

  1. A local script runs the same checks his CI did.
  2. The script ends with gh signoff, which posts a pass mark on the commit.
  3. A GitHub ruleset won't merge without that mark.
  4. Cloud CI runs after each merge to main, on a clean machine.

Since June 28, he has merged 833 pull requests with zero cloud CI runs on any of them.

What a signoff is

GitHub can require named status checks before a merge. A status check is a pass or fail mark on one commit. We usually see them posted by workflow jobs, but anyone with push access can post one through the API without running anything.

gh-signoff is a GitHub CLI extension from Basecamp that does exactly that. It runs no tests. It posts a success status named signoff on your current commit, and by default it refuses if you have uncommitted or unpushed changes.

Two commands set it up:

gh extension install basecamp/gh-signoff
gh signoff install

install creates a ruleset that requires the status and blocks force pushes and branch deletion. Because a status belongs to one commit, pushing another commit means signing off again.

Milan also turns on GitHub's "Require branches to be up to date before merging" option. In the ruleset, that's the strict flag:

{
  "type": "required_status_checks",
  "parameters": {
    "required_status_checks": [{ "context": "signoff" }],
    "strict_required_status_checks_policy": true
  }
}

With it on, if main moves after you sign off, you rebase, run the checks again, and post a new signoff. The checks always ran on the code that gets merged. According to the gh-signoff README, the ruleset it writes is non-strict, so you have to turn this on yourself if you want it.

The ruleset is also where a free plan can stop you. When I queried the rules for this site's repo, which is private on a free account, the API answered:

Upgrade to GitHub Pro or make this repository public to enable this feature.

What it would look like with Husky

Milan's script is for .NET. If I tried this on one of my packages, I'd swap in my checks and put the script at ci/local-ci.sh:

#!/bin/sh
set -eu

if [ -n "$(git status --porcelain)" ]; then
  echo "commit or stash your changes first"
  exit 1
fi

marker="$(git rev-parse --git-dir)/ci-passed"

if [ "$(cat "$marker" 2>/dev/null)" = "$(git rev-parse HEAD)" ]; then
  echo "gate already passed for $(git rev-parse --short HEAD)"
else
  pnpm lint
  pnpm exec tsc --noEmit
  pnpm test
  git rev-parse HEAD > "$marker"
fi

if [ "${1:-}" = "--signoff" ]; then
  gh signoff
fi

set -eu stops the script at the first failure, so gh signoff can only run after every check has passed. The marker file records the last commit that passed, which lets the signoff step skip a second run of the same checks.

The git status block at the top is my addition. The marker stores a commit hash, but the checks run against whatever is in your working folder. Without that block, you could pass with an uncommitted fix in place, throw the fix away, and sign off on a commit that was never tested in that state.

The hook goes in .husky/pre-push:

sh ci/local-ci.sh

Milan commits a .githooks folder and runs git config core.hooksPath .githooks once per clone. Husky's prepare script takes care of that, and it runs a hook file even if the file isn't marked executable.

The hook can't do the signoff, because gh signoff won't post a status for a commit GitHub doesn't have yet. So it takes two steps:

git push                     # the hook runs the checks
sh ci/local-ci.sh --signoff  # finds the marker and posts the status

The last piece is the workflow file. Milan removed the pull_request trigger and kept these:

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'
  workflow_dispatch:

His Monday run scans his container images for new CVEs, even in weeks when nothing merged. A package with no images could use the same slot for pnpm audit.

What you give up

Milan is upfront about the costs, and they're worth reading before you copy any of this.

The clean machine only shows up after the merge. The cloud run can't block a bad signoff, because it starts once the code has already landed. If something slipped through, main goes red and you fix it right away.

Your laptop is also a single environment, with your caches and your SDK. That matters more for a package than for an app. A package might promise support for several Node versions and operating systems, and my machine only tests one of each. The full matrix could still run on main, but you'd find out about a Windows failure after merging.

A signoff only proves that someone with push access posted it. Anyone with that access can type gh signoff without running a single check, and repo admins can bypass the ruleset. Milan's coding agents have instructions forbidding --no-verify and any signoff outside the script, and he says a team has to agree on the same rule. If you need a result nobody on the team can fake, you need cloud CI on the pull request.

Pull requests from forks can't sign off at all, because posting a status takes push access. Milan's advice for open source is to keep cloud CI for outside contributors and use signoff for the core team.

Strict mode also adds work. With several branches open, every merge to main can send the others back through a rebase and a new signoff.

Who should try it

Milan himself writes that running CI in the cloud on every pull request "is still a good default." His switch made sense because his suite finishes in a few minutes locally and he's a small operation, just him and his coding agents.

For public npm packages, the math is different. Standard GitHub-hosted runners are free in public repositories, so there's no bill to cut, and only the waiting is left to save. Any package that takes outside contributions needs cloud CI on pull requests anyway.

Private repos are where the cost argument applies, as long as your plan includes rulesets.

Whether or not I ever turn off pull request CI, I now think of my hooks as an early warning, and I look for what actually enforces the rule. On this site, the answer turned out to be nothing. .husky/pre-commit runs lint-staged and a workflow runs biome ci on pull requests, but master has no branch protection, so GitHub would let me merge a pull request with a failed lint run.

Thanks for reading!

If you found this helpful, consider sharing it with others.