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
| Level | Where it runs | Who can skip it | What it is for |
|---|---|---|---|
| AI tool hook | inside one agent session | anyone not using that agent | fast feedback to the agent while it edits |
| Git hook | on machines where it is installed | git commit --no-verify, or never installing it | fixing style before the commit exists |
| CI | on every push, on the server | nobody, if the branch is protected | the 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:
- - 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:
"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:
# 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:
# 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: trueinstead ofgit add .It runsgit addonly on the files that passed theglobfilter, and it works only inpre-commit. A hand-writtengit add -Aat 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, andexecwithout-Tasks 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
prioritysets the order (it only means something whenparallelis off). ESLint with--fixand Prettier with--writeboth rewrite the same.tsxfile. Withparallel: truethey race, and whichever writes last wins. - Globs follow gobwas rules. In lefthook's default matcher
**means one or more directories, soresources/**/*.cssmatchesresources/css/app.cssbut not a file directly inresources/. If that bites, setglob_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:
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:
# 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:
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:
{
"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:
#!/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 checkand 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
- lefthook docs:
stage_fixed,priority,parallel,glob,glob_matcher(accessed 2026-11-05) - gitleaks README: configuration and allowlists, v8.30.1 (accessed 2026-11-05)
- Claude Code docs: Hooks reference (accessed 2026-11-05)
- GitLab docs: CI/CD pipeline settings, Git shallow clone (accessed 2026-11-05)
- Git docs: githooks, pre-commit (accessed 2026-11-05)