Three levels of quality gates: AI hooks, git hooks, CI. Only one is a guarantee

11 min read

This site's GitLab pipeline ran Larastan, Pint, Prettier, tsc and ESLint on every push for 17 months, and none of them could fail the build. Each line ended in || true, added in a commit from June 2025 called "Update CI/CD". One of them could not have passed even without it: composer pint --test calls a Composer script that does not exist, so it printed Command "pint" is not defined. on every run, exited 1, and the job still passed.

At the same time my laptop had a .git/hooks/pre-commit that ran full PHPStan, migrations and the whole Pest suite before every commit. It was not in the repository, so nobody else had it, and it had the MySQL root password typed into it.

So the gate that ran everywhere checked nothing, and the gate that checked everything ran on one machine. That is the usual way it goes wrong, and it comes from treating three different mechanisms as if they were the same thing.

Three levels, three different promises

LevelWhere it runsWho can skip itWhat it is for
AI tool hookinside one agent sessionanyone not using that agentfast feedback to the agent while it edits
Git hookon machines where it is installedgit commit --no-verify, or never installing itfixing style before the commit exists
CIon every push, on the servernobody, if the branch is protectedthe guarantee

Only the last row is enforced by something other than a developer's setup. The first two are conveniences. They make the CI gate fail less often, and that is all they are allowed to do. If a rule matters, it lives in CI first, and the hooks only make it cheaper to satisfy.

Level 3 first: make CI able to fail

The fix was a five-line diff in .gitlab-ci.yml:

diff
-    - composer larastan || true
-    - composer pint --test || true
+    - composer larastan
+    - ./vendor/bin/pint --test
     - ./vendor/bin/pest --log-junit=junit.xml --parallel
@@
-    - npm run format || true
-    - npm run types || true
-    - npm run lint || true
+    - npm run format:check
+    - npm run types
+    - npm run lint:check

Two of those lines had a second problem besides || true: npm run format is prettier --write and npm run lint is eslint . --fix. A CI job that fixes files in the runner's checkout and then throws the checkout away reports success for code that was never formatted. CI checks; it never fixes. I added a check-only script next to the fixing one in package.json:

json
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"lint:check": "eslint .",

The first honest run was undramatic. Larastan, Pint and tsc were already clean. ESLint had 54 warnings and no errors, and ESLint exits 0 on warnings, so that gate passes and the warnings stay visible in the log. Only Prettier failed, on six .tsx files. That is exactly why nobody noticed for 17 months: almost everything was fine, so the masking never visibly hid anything. After the six files were formatted, the merge request pipeline with the hard gates passed on its first run.

The last piece of level 3 is one local command that runs the same steps, so "will CI pass?" is answerable before pushing. In this project everything runs in Docker, so it is a make target:

makefile
# Run every quality gate in check mode, the same steps CI runs
check: setup-test-db
	docker compose exec -T app composer larastan
	docker compose exec -T app vendor/bin/pint --test
	docker compose exec -T app npm run format:check
	docker compose exec -T app npm run types
	docker compose exec -T app npm run lint:check
	docker compose exec -T app php artisan test --compact --parallel

The same command goes into the agent rules (CLAUDE.md, AGENTS.md), so an AI agent runs the CI gate before it says it is done, instead of guessing which subset matters.

Level 2: lefthook on staged files, inside Docker

The old pre-commit hook had the right tools and the wrong scope. A hook that takes a minute per commit teaches people --no-verify. A pre-commit hook should touch only the staged files, fix what can be fixed, and block only on what cannot. I used lefthook because it is one binary with a YAML file in the repository:

yaml
# Fast checks on staged files only. The full gate is `make check`, and CI is the guarantee.
# Fixers re-stage only the files they changed (stage_fixed), never the whole working tree.
# No `parallel`: eslint --fix and prettier --write touch the same files, so they run in
# priority order and prettier has the last word on formatting.
pre-commit:
  commands:
    pint:
      glob: "*.php"
      run: docker compose exec -T app vendor/bin/pint {staged_files}
      priority: 1
      stage_fixed: true
    eslint:
      glob: "*.{js,jsx,ts,tsx}"
      run: docker compose exec -T app npx eslint --fix {staged_files}
      priority: 2
      stage_fixed: true
    prettier:
      glob: "resources/**/*.{js,jsx,ts,tsx,css}"
      run: docker compose exec -T app npx prettier --write --ignore-unknown {staged_files}
      priority: 3
      stage_fixed: true
    gitleaks:
      run: gitleaks git --pre-commit --staged --redact --verbose --no-banner
      priority: 4

Four details that are easy to get wrong:

  • stage_fixed: true instead of git add . It runs git add only on the files that passed the glob filter, and it works only in pre-commit. A hand-written git add -A at the end of a hook stages whatever else was lying in your working tree. In this repo that would have been an untracked draft I deliberately kept out of the commit.
  • docker compose exec -T. Git runs hooks without a terminal, and exec without -T asks for a TTY. Paths in {staged_files} are relative to the repository root, which is also the container's working directory, so they resolve on both sides.
  • Order, not parallelism. Lefthook runs commands sequentially by default, and priority sets the order (it only means something when parallel is off). ESLint with --fix and Prettier with --write both rewrite the same .tsx file. With parallel: true they race, and whichever writes last wins.
  • Globs follow gobwas rules. In lefthook's default matcher ** means one or more directories, so resources/**/*.css matches resources/css/app.css but not a file directly in resources/. If that bites, set glob_matcher: doublestar.

lefthook install writes .git/hooks/pre-commit and renames an existing hook to pre-commit.old, so the old slow hook was out of the way without deleting it.

I tested it on deliberate failures before trusting it. A badly formatted PHP file went in staged and came out with declare(strict_types=1) and Pint's formatting, re-staged, and the commit went through. A file with a fake AWS access key stopped the commit with exit code 1.

Every developer still has to install lefthook and gitleaks on the host, and nothing enforces that. That is fine. It is level 2.

gitleaks: the history scan that found 476 secrets

The pre-commit hook scans only what you are committing. Before adding the same scanner to CI, I ran it over the full history with the official image:

bash
docker run --rm -v "$PWD":/repo -w /repo ghcr.io/gitleaks/gitleaks:v8.30.1 \
  git --redact --no-banner .

Result: 578 commits scanned, leaks found: 476. All of them were the generic-api-key rule. 472 were translation_key fields in my blog content manifests: UUIDs that sit next to the word "key", which is exactly what that rule looks for. The other four were a fake STRIPE_SECRET in an agent skill that shows a committed .env as a bad example.

So a CI secret scan added without looking first fails on its first run and gets switched off within a week. The fix is an allowlist that names each false positive narrowly:

toml
# Keep every built-in rule, then silence two known false positives.
[extend]
useDefault = true

[[allowlists]]
description = "Post translation keys are UUIDs, not credentials"
targetRules = ["generic-api-key"]
regexTarget = "line"
regexes = ['''translation_key"?:\s*"?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}''']

[[allowlists]]
description = "Agent skill docs show a fake .env as a bad example"
targetRules = ["generic-api-key"]
paths = ['''^\.(claude|agents|cursor|github)/skills/.*\.md$''']

With that as .gitleaks.toml, the same history scan reports no leaks found, and a staged file with a fake AWS key next to a translation_key line is still caught: one finding, exit 1. The allowlist targets one rule and one line shape, not whole directories of content. The CI job is short; GIT_DEPTH: 0 matters because new GitLab projects clone with a depth of 20, and gitleaks would only see those 20 commits:

yaml
secrets:gitleaks:
  stage: test
  image:
    name: ghcr.io/gitleaks/gitleaks:v8.30.1
    entrypoint: [""]
  variables:
    GIT_DEPTH: 0
  cache: []
  script:
    - gitleaks git --redact --no-banner --verbose .

The job went into my pipeline in the same commit as the allowlist, and build lists it under needs, so a found secret also stops the release package from being built. Why a secret scanner belongs in the pipeline at all is covered in how not to get hacked.

Level 1: a Claude Code hook that formats as the agent writes

An AI tool hook fires inside the agent, after each tool call. That makes it the fastest feedback loop available, and the narrowest one: it runs only in that tool, only for edits the agent makes. Here is a PostToolUse hook that runs Pint on every PHP file Claude Code writes, in .claude/settings.json:

json
{
    "hooks": {
        "PostToolUse": [
            {
                "matcher": "Edit|Write",
                "hooks": [
                    {
                        "type": "command",
                        "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pint-on-edit.sh"
                    }
                ]
            }
        ]
    }
}

The script reads the event JSON from stdin. tool_input.file_path is always absolute for file tools:

bash
#!/usr/bin/env bash
# Runs after every Edit or Write that Claude Code makes. Formats PHP files only.
file=$(jq -r '.tool_input.file_path // empty')
[[ "$file" == *.php ]] || exit 0

relative=${file#"$CLAUDE_PROJECT_DIR"/}
cd "$CLAUDE_PROJECT_DIR" || exit 0

if ! docker compose exec -T app vendor/bin/pint "$relative" > /dev/null; then
    echo "Pint could not format $relative" >&2
    exit 2
fi

Exit code 2 on PostToolUse cannot undo the edit, because the tool already ran. It sends stderr back to the agent, so a file that does not parse gets reported in the same turn instead of at commit time. That is the whole value of level 1: the agent stops producing work that level 2 has to clean up. It does nothing for a colleague using Codex or Cursor, and nothing for code typed by hand.

When not to use this

  • No CI at all. Do not start with git hooks. Add the CI job first; a hook without a server-side gate only moves the problem to whoever forgot to install it.
  • Tests in pre-commit. My old hook ran 217 tests per commit. Tests belong in make check and CI. Put only fast, file-scoped tools in the hook.
  • A rule nobody will fix. 54 ESLint warnings stayed warnings on purpose. Turning them into errors in the same change would have made the first honest pipeline red for reasons unrelated to the fix.
  • One-file scripts and throwaway repos. Three levels of gates cost more than they save there.

If your team's pipeline is green all the time and you are not sure it can fail, I can audit it and set these gates up. See what I offer.

Sources

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.