Trzy poziomy bramek jakości: hooki AI, git hooki, CI. Gwarancję daje tylko jeden

10 min. czytania

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

PoziomGdzie działaKto może go pominąćDo czego służy
Hook narzędzia AIw sesji jednego agentakażdy, kto nie używa tego agentaszybka informacja zwrotna dla agenta w trakcie edycji
Git hookna maszynach, gdzie go zainstalowanogit commit --no-verify albo brak instalacjipoprawienie stylu, zanim powstanie commit
CIprzy każdym pushu, na serwerzenikt, jeśli gałąź jest chronionagwarancja

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:

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

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:

json
"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:

makefile
# 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:

yaml
# 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: true zamiast git add . Wywołuje git add tylko na plikach, które przeszły filtr glob, i działa wyłącznie w pre-commit. Ręcznie dopisane git add -A na 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, a exec bez -T prosi 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 priority ustala kolejność (ma znaczenie tylko przy wyłączonym parallel). ESLint z --fix i Prettier z --write przepisują ten sam plik .tsx. Przy parallel: 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ęc resources/**/*.css łapie resources/css/app.css, ale nie plik leżący bezpośrednio w resources/. Jeśli to przeszkadza, ustaw glob_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:

bash
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:

toml
# 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:

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 .

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:

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ą:

bash
#!/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 check i 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

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.