Pipeline tej strony w GitLabie przez 17 miesięcy uruchamiał przy każdym pushu Larastana, Pinta, Prettiera, tsc i ESLinta, i żadne z nich nie mogło wywrócić builda. Każda linia kończyła się na || true, dopisanym w commicie z czerwca 2025 o tytule „Update CI/CD”. Jeden z kroków nie przeszedłby nawet bez tego: composer pint --test woła skrypt Composera, który nie istnieje, więc przy każdym uruchomieniu wypisywał Command "pint" is not defined., kończył się kodem 1, a job i tak przechodził.
W tym samym czasie na moim laptopie siedział .git/hooks/pre-commit, który przed każdym commitem odpalał pełnego PHPStana, migracje i cały zestaw testów Pest. Nie było go w repozytorium, więc nikt inny go nie miał, a hasło roota MySQL było w nim wpisane na sztywno.
Bramka, która działała wszędzie, niczego nie sprawdzała, a bramka, która sprawdzała wszystko, działała na jednej maszynie. Tak to się zwykle psuje, i bierze się to z traktowania trzech różnych mechanizmów jak jednego.
Trzy poziomy, trzy różne obietnice
| Poziom | Gdzie działa | Kto może go pominąć | Do czego służy |
|---|---|---|---|
| Hook narzędzia AI | w sesji jednego agenta | każdy, kto nie używa tego agenta | szybka informacja zwrotna dla agenta w trakcie edycji |
| Git hook | na maszynach, gdzie go zainstalowano | git commit --no-verify albo brak instalacji | poprawienie stylu, zanim powstanie commit |
| CI | przy każdym pushu, na serwerze | nikt, jeśli gałąź jest chroniona | gwarancja |
Tylko ostatni wiersz egzekwuje coś innego niż konfiguracja programisty. Dwa pierwsze to udogodnienia. Sprawiają, że bramka w CI rzadziej się wywraca, i tylko tyle wolno im robić. Jeśli reguła jest ważna, najpierw ląduje w CI, a hooki jedynie obniżają koszt jej spełnienia.
Najpierw poziom 3: CI musi umieć się wywrócić
Poprawka to pięć linii w .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
Dwie z tych linii miały poza || true drugi problem: npm run format to prettier --write, a npm run lint to eslint . --fix. Job w CI, który poprawia pliki w checkoutcie runnera i potem ten checkout wyrzuca, zgłasza sukces dla kodu, którego nikt nie sformatował. CI sprawdza, nigdy nie poprawia. Obok skryptów poprawiających dodałem w package.json wersje tylko sprawdzające:
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"lint:check": "eslint .",
Pierwsze uczciwe uruchomienie obyło się bez dramatu. Larastan, Pint i tsc były czyste. ESLint miał 54 ostrzeżenia i zero błędów, a przy samych ostrzeżeniach kończy się kodem 0, więc ta bramka przechodzi, a ostrzeżenia zostają widoczne w logu. Padł tylko Prettier, na sześciu plikach .tsx. Właśnie dlatego przez 17 miesięcy nikt tego nie zauważył: prawie wszystko było w porządku, więc maskowanie nigdy niczego widocznie nie ukryło. Po sformatowaniu tych sześciu plików pipeline merge requesta z twardymi bramkami przeszedł za pierwszym razem.
Ostatni element poziomu 3 to jedna lokalna komenda, która wykonuje te same kroki, żeby na pytanie „czy CI przejdzie?” dało się odpowiedzieć przed pushem. W tym projekcie wszystko działa w Dockerze, więc to target w make:
# Wszystkie bramki w trybie sprawdzania, te same kroki co w CI
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
Ta sama komenda trafia do reguł dla agentów (CLAUDE.md, AGENTS.md), więc agent AI uruchamia bramkę z CI, zanim powie, że skończył, zamiast zgadywać, który podzbiór się liczy.
Poziom 2: lefthook na plikach w stage'u, w Dockerze
Stary hook pre-commit miał dobre narzędzia i zły zakres. Hook, który przy każdym commicie zajmuje minutę, uczy ludzi --no-verify. Pre-commit powinien dotykać tylko plików w stage'u, poprawiać to, co da się poprawić, i blokować tylko to, czego się nie da. Wybrałem lefthooka, bo to jedna binarka i jeden plik YAML w repozytorium:
# Szybkie sprawdzenia tylko na plikach w stage'u. Pełna bramka to `make check`, gwarancją jest CI.
# Narzędzia poprawiające dodają do stage'a tylko zmienione pliki (stage_fixed), nigdy całego drzewa.
# Bez `parallel`: eslint --fix i prettier --write piszą do tych samych plików, więc idą
# po kolei według priority, a ostatnie słowo w formatowaniu ma prettier.
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
Cztery szczegóły, na których łatwo się wyłożyć:
stage_fixed: truezamiastgit add .Wywołujegit addtylko na plikach, które przeszły filtrglob, i działa wyłącznie wpre-commit. Ręcznie dopisanegit add -Ana końcu hooka dodaje wszystko, co akurat leży w drzewie roboczym. W tym repozytorium byłby to nieśledzony szkic, który celowo zostawiłem poza commitem.docker compose exec -T. Git uruchamia hooki bez terminala, aexecbez-Tprosi o TTY. Ścieżki w{staged_files}są względne wobec katalogu głównego repozytorium, który jest też katalogiem roboczym kontenera, więc działają po obu stronach.- Kolejność, nie równoległość. Lefthook domyślnie uruchamia komendy po kolei, a
priorityustala kolejność (ma znaczenie tylko przy wyłączonymparallel). ESLint z--fixi Prettier z--writeprzepisują ten sam plik.tsx. Przyparallel: trueścigają się i wygrywa ten, który zapisze ostatni. - Globy działają według reguł gobwas. W domyślnym silniku lefthooka
**oznacza jeden lub więcej katalogów, więcresources/**/*.cssłapieresources/css/app.css, ale nie plik leżący bezpośrednio wresources/. Jeśli to przeszkadza, ustawglob_matcher: doublestar.
lefthook install zapisuje .git/hooks/pre-commit, a istniejący hook przemianowuje na pre-commit.old, więc stary, wolny hook zszedł z drogi bez kasowania.
Zanim mu zaufałem, sprawdziłem go na celowych błędach. Źle sformatowany plik PHP wszedł do stage'a, a wyszedł z declare(strict_types=1) i formatowaniem Pinta, z powrotem w stage'u, i commit przeszedł. Plik z fałszywym kluczem dostępowym AWS zatrzymał commit z kodem wyjścia 1.
Każdy programista i tak musi sam zainstalować lefthooka i gitleaksa na hoście, i nic tego nie wymusza. To w porządku. To poziom 2.
gitleaks: skan historii, który znalazł 476 sekretów
Hook pre-commit skanuje tylko to, co commitujesz. Zanim dodałem ten sam skaner do CI, puściłem go po całej historii z oficjalnego obrazu:
docker run --rm -v "$PWD":/repo -w /repo ghcr.io/gitleaks/gitleaks:v8.30.1 \
git --redact --no-banner .
Wynik: 578 przeskanowanych commitów, leaks found: 476. Wszystkie z reguły generic-api-key. 472 to pola translation_key w manifestach treści bloga: UUID-y stojące obok słowa „key”, czyli dokładnie to, czego ta reguła szuka. Pozostałe cztery to fałszywy STRIPE_SECRET w skillu dla agenta, który pokazuje scommitowany .env jako zły przykład.
Skan sekretów dodany do CI bez wcześniejszego sprawdzenia wywraca się więc przy pierwszym uruchomieniu i w ciągu tygodnia ktoś go wyłącza. Rozwiązaniem jest allowlista, która wąsko nazywa każdy fałszywy alarm:
# Wszystkie wbudowane reguły zostają, wyciszamy dwa znane fałszywe alarmy.
[extend]
useDefault = true
[[allowlists]]
description = "Klucze tłumaczeń postów to UUID-y, nie dane dostępowe"
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 = "Dokumentacja skilli pokazuje fałszywy .env jako zły przykład"
targetRules = ["generic-api-key"]
paths = ['''^\.(claude|agents|cursor|github)/skills/.*\.md$''']
Z tym plikiem jako .gitleaks.toml ten sam skan historii zwraca no leaks found, a plik w stage'u z fałszywym kluczem AWS obok linii z translation_key nadal jest łapany: jedno trafienie, kod wyjścia 1. Allowlista celuje w jedną regułę i jeden kształt linii, a nie w całe katalogi z treścią. Job w CI jest krótki. GIT_DEPTH: 0 ma znaczenie, bo nowe projekty w GitLabie klonują się z głębokością 20 i gitleaks widziałby tylko te 20 commitów:
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 .
Job trafił do mojego pipeline'u w tym samym commicie co allowlista, a build ma go w needs, więc znaleziony sekret blokuje też budowanie paczki release. Dlaczego skaner sekretów w ogóle powinien być w pipelinie, opisałem w tekście jak nie dać się zhakować.
Poziom 1: hook Claude Code, który formatuje w trakcie pisania
Hook narzędzia AI odpala się wewnątrz agenta, po każdym wywołaniu narzędzia. To najszybsza dostępna pętla informacji zwrotnej i zarazem najwęższa: działa tylko w tym narzędziu i tylko dla edycji, które robi agent. Oto hook PostToolUse, który uruchamia Pinta na każdym pliku PHP zapisanym przez Claude Code, w .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pint-on-edit.sh"
}
]
}
]
}
}
Skrypt czyta JSON zdarzenia ze stdin. Dla narzędzi plikowych tool_input.file_path jest zawsze ścieżką bezwzględną:
#!/usr/bin/env bash
# Uruchamia się po każdym Edit lub Write wykonanym przez Claude Code. Formatuje tylko pliki PHP.
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 nie mógł sformatować $relative" >&2
exit 2
fi
Kod wyjścia 2 w PostToolUse nie cofnie edycji, bo narzędzie już się wykonało. Wysyła za to stderr z powrotem do agenta, więc plik, który się nie parsuje, zostaje zgłoszony w tej samej turze, a nie dopiero przy commicie. Na tym polega cała wartość poziomu 1: agent przestaje produkować pracę, którą poziom 2 musiałby sprzątać. Nic to nie daje koledze, który używa Codexa albo Cursora, ani kodowi pisanemu ręcznie.
Kiedy tego nie stosować
- Nie masz CI. Nie zaczynaj od git hooków. Najpierw dodaj job w CI. Hook bez bramki po stronie serwera przerzuca problem na tego, kto zapomniał go zainstalować.
- Testy w pre-commicie. Mój stary hook puszczał 217 testów przy każdym commicie. Testy należą do
make checki CI. W hooku zostaw tylko szybkie narzędzia działające na pojedynczych plikach. - Reguła, której nikt nie naprawi. 54 ostrzeżenia ESLinta celowo zostały ostrzeżeniami. Zamiana ich na błędy w tej samej zmianie sprawiłaby, że pierwszy uczciwy pipeline byłby czerwony z powodów niezwiązanych z poprawką.
- Jednoplikowe skrypty i repozytoria na chwilę. Tam trzy poziomy bramek kosztują więcej, niż oszczędzają.
Jeśli pipeline Twojego zespołu jest zawsze zielony i nie masz pewności, czy w ogóle umie się wywrócić, mogę go przejrzeć i ustawić te bramki. Zobacz moją ofertę.
Źródła
- Dokumentacja lefthooka:
stage_fixed,priority,parallel,glob,glob_matcher(dostęp: 2026-11-05) - README gitleaksa: konfiguracja i allowlisty, v8.30.1 (dostęp: 2026-11-05)
- Dokumentacja Claude Code: Hooks reference (dostęp: 2026-11-05)
- Dokumentacja GitLaba: ustawienia pipeline'ów, Git shallow clone (dostęp: 2026-11-05)
- Dokumentacja Gita: githooks, pre-commit (dostęp: 2026-11-05)