LFD140 od Linux Foundation: dobry model governance na GitHubie, ale bramki z kursu przepuszczają to, co mają blokować
Ukończyłem LFD140 „GitHub for Open Standards Development" od The Linux Foundation. Kurs uczy, jak przenieść zasady grupy roboczej do repozytorium: kto zatwierdza zmianę normatywną, którą gałęzią idzie specyfikacja do ratyfikacji, gdzie zapisać sprzeciw i jak wymusić to ochroną gałęzi i CI. Ten model uważam za dobry i wart przeniesienia także poza fundacje open source. Przykładowe bramki GitHub Actions z materiałów kursu uruchomiłem jednak lokalnie, na odtworzonym checkoucie pull requesta. Z pięciu skryptów żaden nie działa poprawnie w podanej postaci. Dwie kontrole CHANGELOG przepuszczają zmianę w specyfikacji bez wpisu, dwie bramki blokują PR-y, które powinny przejść, a krok sprawdzający etykietę przechodzi zawsze. Próg „dwóch zatwierdzeń" liczy recenzje zamiast recenzentów. Poniżej opisuję, co z kursu warto wziąć, co w przykładach nie działa i jak wyglądają poprawione wersje.
Teza
Kurs ma rację co do kierunku. Reguły zarządcze da się zapisać w repozytorium i egzekwować technicznie, zamiast pilnować ich na spotkaniach. Sami autorzy kursu w ćwiczeniu 7.2 każą na koniec zrobić testowy PR, który dowiedzie, że nowe zabezpieczenia blokują merge. Ich własne przykłady takiego testu nie przechodzą.
Wniosek jest szerszy niż ten jeden kurs. Automatyzacja governance to kod, a kod, którego nikt nie widział w stanie czerwonym, niczego nie dowodzi. Harry Percival pisze o testach: „I'm always suspicious of a test I haven't seen fail" (Percival, „Test-Driven Development with Python", s. 4). Bramka w CI, która zawsze jest zielona, wygląda tak samo jak bramka, która działa.
Czego uczy kurs
Materiał z ćwiczeniami ma 95 stron (wersja LFD140-v10.01.2025). Każdy rozdział kończy się zadaniem do „planu governance", który na końcu ocenia się samodzielnie według rubryki. Najcenniejsze elementy:
Cykl życia specyfikacji w pięciu fazach. Proposal, Drafting, Review & Consensus, Ratification, Publication & Maintenance. Do każdej fazy trzeba przypisać konkretny artefakt w repo: issue, PR, protokół, tag, wpis w CHANGELOG. Bez tego nie wiadomo, gdzie szukać dowodu, że decyzja zapadła.
Role i macierz uprawnień. Chair, Editor, Contributor, Observer. Kurs wymaga, żeby każda reguła w macierzy miała „dowód w GitHubie", czyli konkretną etykietę, nazwę status checku albo plik. Reguła, której nie da się wskazać w repo, istnieje tylko na papierze.
Gałęzie jako stany decyzyjne. Gałęzie robocze wi/* trafiają do dev, z dev do ip-review, gdzie treść jest zamrożona na czas przeglądu praw własności intelektualnej, a na końcu do main z ratyfikowaną treścią i tagiem wydania. Gałąź mówi tu, na jakim etapie formalnym jest tekst, a nie tylko, nad czym ktoś pracuje.
Treść normatywna oddzielona od nienormatywnej. Nagłówki oznaczone jako normatywne, słowa kluczowe z RFC 2119, metadane YAML ze statusem dokumentu. Dzięki temu prosty skrypt w CI może sprawdzić strukturę, a czytelnik od razu wie, co jest wymaganiem.
Sprzeciwy, CLA kontra DCO, erozja governance. Sprzeciw dostaje etykietę blokującą i musi zostać formalnie rozstrzygnięty. Kurs porównuje CLA (umowa podpisywana poza Gitem, także przez pracodawcę) z DCO (Signed-off-by: w każdym commicie). Ćwiczenie 9.3 dotyczy erozji governance: PR-ów bez recenzentów, braku aktywności Chaira i Editorów, zarzuconego cyklu wydań, nieaktualnego CONTRIBUTING. Na koniec są szablony: snapshot governance, ścieżka archiwizacji i plan wdrożenia na 90 dni.
Słaby punkt: część praktyczna, czyli przykładowe workflow. Rozdział 7 nazywa je „Governance Automation Pattern Library", a dodatek 6 przedstawia jako „real-world GitHub Actions examples".
Jak testowałem
Nie zakładałem repozytorium na GitHubie. Odtworzyłem lokalnie to, co dostaje job uruchomiony na zdarzeniu pull_request:
- repozytorium „origin" z gałęzią
maini gałęziami PR-ów; - commit scalający, taki jak
refs/pull/N/merge, bo napull_requestzmiennaGITHUB_SHAwskazuje właśnie na niego; - workspace pobrany z głębokością 1, bo
actions/checkoutma domyślniefetch-depth: 1, czyli pobiera jeden commit (actions/checkout); - drugi wariant z pełną historią, odpowiednik
fetch-depth: 0; - skrypty z kursu przepisane 1:1 i uruchamiane przez
bash -e, bo takiej powłoki GitHub używa dlarun, gdyshellnie jest podany (docs.github.com).
Ten ostatni szczegół ma znaczenie. bash -e nie włącza pipefail, więc wynik potoku git diff | grep to wynik grep. Jeśli git diff padnie, grep dostaje puste wejście, a skrypt idzie dalej. Dopiero jawne shell: bash daje bash --noprofile --norc -eo pipefail.
Przypadki testowe:
| PR | Zmienione pliki | Oczekiwany wynik |
|---|---|---|
| PR1 | spec/core.md bez CHANGELOG | blokada |
| PR2 | spec/core.md + CHANGELOG.md | przejście |
| PR3 | tylko README.md | przejście |
Części opartych na API GitHuba (recenzje, etykiety) nie uruchamiałem na GitHubie. Logikę liczenia zatwierdzeń wydzieliłem do funkcji i sprawdziłem w Node na danych testowych. Wszystko, czego nie uruchomiłem, oznaczam w tekście jako analizę kodu.
Co nie działa w przykładach z kursu
1. Kontrola CHANGELOG przepuszcza wszystko przy domyślnym checkoucie
Wzorcowa odpowiedź do ćwiczenia 7.1 („What good looks like") wygląda tak:
- uses: actions/checkout@v3
- name: Enforce Governance Rules
run: |
if git diff --name-only origin/main...HEAD | grep -q 'spec/.*\.md'; then
if ! git diff --name-only origin/main...HEAD | grep -q 'CHANGELOG.md'; then
echo "::error::Missing changelog update!"
exit 1
fi
fi
Przy domyślnym fetch-depth: 1 w workspace nie ma origin/main. Wynik dla PR1, czyli zmiany w specyfikacji bez CHANGELOG:
fatal: ambiguous argument 'origin/main...HEAD': unknown revision or path not in the working tree.
exit=0
Git zgłasza błąd, grep -q nie znajduje niczego, warunek if jest fałszywy i krok kończy się sukcesem. Bramka jest zielona dla każdego PR-a. Wersja z ćwiczenia (porównanie base.sha z github.sha) zachowuje się tak samo, tylko z innym komunikatem: fatal: bad object f3c25a41…, exit=0. Commita bazowego po prostu nie ma w płytkim klonie.
Kontrola CHANGELOG z dodatku 6 (przykład 3) zawiera dokładnie tę samą linię git diff origin/main...HEAD | grep -q przy checkout@v3, więc trafia w ten sam błąd. Tego przykładu nie uruchamiałem osobno. Dodatkowo ustawia wyjście przez ::set-output, które GitHub oznaczył jako przestarzałe w październiku 2022 i od tamtej pory odkłada usunięcie (github.blog).
Przy pełnej historii obie wersje działają poprawnie: PR1 dostaje exit=1, PR2 exit=0. Logika jest więc dobra, brakuje jednej linii fetch-depth: 0. To właśnie najgorszy rodzaj błędu: bramka nie zgłasza żadnego problemu w UI, tylko zawsze jest zielona.
2. Pattern 2 blokuje każdy PR, także poprawny
git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep 'changelog.md' || \
(echo "Missing changelog entry." && exit 1)
Tu błąd działa w drugą stronę. Przy płytkim klonie git diff pada, grep nic nie znajduje i || kończy krok z exit 1. Przy pełnej historii PR2, który ma poprawny wpis w CHANGELOG.md, też dostaje exit=1. Powód: grep 'changelog.md' rozróżnia wielkość liter, a cały kurs, w tym jego struktura repozytorium, posługuje się nazwą CHANGELOG.md.
3. „Governance File Protection" blokuje także PR, który zmienia tylko README
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E 'governance|CODEOWNERS|status.yaml')
if [[ "$CHANGED" ]]; then
echo "::error::This PR requires explicit approval from CODEOWNERS."
exit 1
fi
W kursie ten workflow nie ma kroku checkout. Uruchomiony w ten sposób kończy się exit=1. Po dodaniu checkoutu z pełną historią PR3, który zmienia tylko README.md, też dostaje exit=1, bez żadnego komunikatu. Pod bash -e przypisanie CHANGED=$(…) dziedziczy kod wyjścia potoku, czyli grep. Gdy żaden plik governance się nie zmienił (albo git diff padł), grep zwraca 1 i powłoka kończy skrypt, zanim dojdzie do if. Bramka blokuje więc każdy PR.
Gdyby nawet działała zgodnie z intencją, nadal nie sprawdzałaby tego, co obiecuje nazwa. Komunikat mówi o wymaganej zgodzie CODEOWNERS, ale skrypt żadnej zgody nie sprawdza. Kończy się błędem za każdym razem, gdy plik governance się zmieni, więc takiego PR-a nie da się scalić nawet po zatwierdzeniu.
Bramka, która blokuje wszystko, nie wzmacnia governance. Zespół wyłączy ją albo nauczy się mergować z pominięciem ochrony. Administrator może wyłączyć ochronę gałęzi, wepchnąć zmianę i włączyć ją z powrotem, a ślad zostaje tylko w audit logu (Kaufmann, Bos, de Vries, „GitHub Actions in Action", s. 211). Kontrola, która działa tylko wtedy, gdy ktoś czyta log, jest wykrywaniem po fakcie, a nie zapobieganiem.
4. Wymóg etykiety „normative" też bez checkoutu
Przykład 1 z dodatku 6 wywołuje actions/labeler@v4, a potem git diff origin/main...HEAD, ale nie ma kroku checkout. Wynik przy PR1:
warning: Not a git repository. Use --no-index to compare two paths outside a working tree
exit=0
Uruchomiłem tylko ten krok run. Poprzedzający go actions/labeler@v4 w kursie nie ma pliku konfiguracyjnego .github/labeler.yml. Z kodu akcji wynika, że bez tego pliku sama akcja kończy job błędem. Tego nie uruchamiałem. Wynik całego workflow zależy więc od tego, czy repo przypadkiem ma konfigurację labelera: bez niej job jest czerwony z niewłaściwego powodu, z nią krok z etykietą przechodzi.
Sam checkout z fetch-depth: 0 też nie wystarczy. gh pr view jest wywoływane bez zmiennej GH_TOKEN, więc według analizy kodu gh się nie uwierzytelni, grep -q nic nie znajdzie, a skrypt zablokuje każdy PR ze zmianą w spec/, także oznaczony etykietą. Trzeci problem sprawdziłem już jednym echo i grep: grep -q 'normative' na JSON-ie z etykietami pasuje także do etykiety non-normative.
5. Liczenie zatwierdzeń liczy recenzje, nie recenzentów
Pattern 1 i ćwiczenie 7.1 sprawdzają próg dwóch zatwierdzeń dla zmian normatywnych tak:
const approved = reviews.filter(r => r.state === 'APPROVED').length;
Wynik porównałem z poprawioną logiką (pokazaną niżej) na pięciu zestawach recenzji:
| Scenariusz | Kurs | Poprawiona |
|---|---|---|
| ta sama osoba zatwierdza dwa razy | 2 | 1 |
| zatwierdzenie, potem prośba o zmiany od tej samej osoby | 2 | 1 |
| dwa zatwierdzenia starszego commita | 2 | 0 |
| zatwierdzenie od osoby spoza projektu | 2 | 1 |
| dwa ważne zatwierdzenia aktualnej wersji | 2 | 2 |
Kurs w każdym przypadku widzi spełniony próg. Do tego dochodzą trzy problemy, które wynikają z analizy kodu i dokumentacji, a nie z uruchomienia:
- Wyzwalacze. Wersja z ćwiczenia 7.1 działa na
on: [pull_request], czyli domyślnie tylko naopened,synchronizeireopened(docs.github.com). Jeśli PR zostanie otwarty bez etykietynormative-change, krok jest pomijany, a job kończy się sukcesem. Dodanie etykiety później nie uruchamia workflow ponownie, więc zielony wynik zostaje. Żaden z wariantów nie reaguje napull_request_review, więc po zebraniu zatwierdzeń czerwony check nie zmieni się sam. - Pominięty job jest zielony. Pattern 1 sprawdza etykietę w
ifna poziomie joba. Dokumentacja mówi wprost: „A job that is skipped will report its status as "Success". It will not prevent a pull request from merging, even if it is a required check" (docs.github.com). - Paginacja.
listReviewszwraca domyślnie 30 recenzji na stronę (docs.github.com). Przy długiej dyskusji późniejsze recenzje, także zmiany decyzji, wypadają z obliczeń.
6. CODEOWNERS nie zrobi „Editor i Chair"
Odpowiedź wzorcowa do ćwiczenia 5.1 bierze linię /spec/ @chairs @editors i stwierdza, że wymagani recenzenci to „@chairs and @editors", a próg „1 Editor approval and 1 Chair approval" wymusi ochrona gałęzi. Dokumentacja GitHuba mówi co innego: przy włączonym „Require review from Code Owners" wystarczy zatwierdzenie od dowolnego właściciela z linii (docs.github.com). Książka „GitHub Actions in Action" potwierdza to samo: „only one code owner needs to approve, even if you configure a team" (s. 212). Sama linia @chairs @editors daje więc „Editor lub Chair". Ochrona gałęzi ustala liczbę zatwierdzeń, ale nie ich skład według ról.
Zespoły w CODEOWNERS zapisuje się w formacie @org/team-name i muszą mieć jawne prawo zapisu do repozytorium. @chairs bez organizacji oznacza użytkownika o nazwie chairs.
7. „Tag and Sign" nie podpisuje
Pattern 3 nazywa krok „Tag and Sign", ale wykonuje git tag -a, czyli tag z adnotacją, bez podpisu (to byłoby git tag -s). Numer wersji z workflow_dispatch jest wstawiany do skryptu przez ${{ github.event.inputs.release_version }} bez walidacji. GitHub zaleca przekazywanie takich wartości przez zmienną środowiskową, żeby nie stały się częścią generowanego skryptu (docs.github.com). Tego przykładu nie uruchamiałem.
Poprawione wersje
Najpierw ustawienia, potem workflow
Najważniejsza poprawka nie jest w YAML-u. Kurs sam to mówi w ćwiczeniu 7.2: bez ochrony gałęzi workflow niczego nie blokuje. W ochronie main (albo w rulesecie):
- wymagany PR i minimalna liczba zatwierdzeń;
- Require review from Code Owners, bo bez tej opcji właściciel dostaje tylko powiadomienie, a merge nie jest blokowany („GitHub Actions in Action", s. 212);
- Dismiss stale approvals, czyli zatwierdzenia unieważniane po nowym commicie;
- wymagane status checki wpisane z dokładnymi nazwami jobów;
- egzekwowanie wobec administratorów albo udokumentowane wyjątki;
- właściciel dla samego
CODEOWNERSi katalogu.github/. Dokumentacja zaleca trzymać CODEOWNERS w.githubi przypisać go właścicielowi repozytorium. Inaczej zmiana w CODEOWNERS może poluzować wszystkie pozostałe reguły.
To zastępuje „Governance File Protection" w całości. Natywny mechanizm sprawdza dokładnie to, czego tamten skrypt nie sprawdzał.
/.github/ @org/chairs
/governance/ @org/chairs
/status.yaml @org/chairs
/specs/ @org/editors @org/chairs
Ostatnia linia daje „Editor lub Chair". Jeśli reguła wymaga obu ról, potrzebny jest dodatkowy check sprawdzający członkostwo zatwierdzających w zespołach. Do tego trzeba tokenu z dostępem do zespołów organizacji. Tego wariantu nie budowałem.
Workflow
name: governance
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
pull_request_review:
types: [submitted, dismissed]
permissions:
contents: read
pull-requests: read
jobs:
changelog-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # bez tego nie ma historii do porównania
persist-credentials: false
- name: CHANGELOG przy zmianie specyfikacji
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
changed=$(git diff --name-only "$BASE_SHA...$HEAD_SHA")
if grep -qE '^specs?/.*\.md$' <<<"$changed" && ! grep -qx 'CHANGELOG.md' <<<"$changed"; then
echo "::error::Zmiana w spec/ wymaga wpisu w CHANGELOG.md."
exit 1
fi
normative-review-check:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const pr = context.payload.pull_request;
if (!pr.labels.some(l => l.name === 'normative-change')) {
core.info('Brak etykiety normative-change: próg 2 zatwierdzeń nie dotyczy tego PR.');
return;
}
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner, repo: context.repo.repo,
pull_number: pr.number, per_page: 100,
});
const latest = new Map();
for (const r of reviews) {
if (r.state === 'COMMENTED' || r.state === 'PENDING') continue;
latest.set(r.user.login, r); // API zwraca recenzje chronologicznie
}
const approvals = [...latest.values()].filter(r =>
r.state === 'APPROVED' &&
r.commit_id === pr.head.sha && // tylko zatwierdzenia aktualnej wersji
r.user.login !== pr.user.login &&
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(r.author_association)
).length;
if (approvals < 2) {
core.setFailed(`Zmiana normatywna wymaga 2 zatwierdzeń aktualnej wersji, jest ${approvals}.`);
}
Decyzje projektowe:
- Brak
ifna poziomie joba. Decyzja „bramka nie dotyczy tego PR" zapada wewnątrz kroku. Pominięty job raportuje sukces, więc gdybychangelog-checkbył pomijany na zdarzeniach recenzji, każda recenzja nadpisywałaby czerwony wynik zielonym „skipped". set -euo pipefaili lista zmian w zmiennej. Jeśligit diffpadnie, krok kończy się błędem, a nie cichym sukcesem. Sprawdziłem to na płytkim klonie:exit=128ifatal: Invalid symmetric difference expression. Bramka zawodzi wtedy w bezpieczną stronę.- Porównanie trzema kropkami (
BASE...HEAD) liczy zmiany od wspólnego przodka, więc commity, które w międzyczasie trafiły namain, nie mieszają się do listy zmian PR-a. - Wartości z kontekstu przez
env, nie wklejane do skryptu. - Bez
pull_request_targeti bez checkoutu kodu z PR-a w jobie z recenzjami. Job czyta tylko dane z API, a token ma prawa do odczytu. Workflow uruchamiane na PR-ach wykonują kod autora PR-a. Poprawiając bramkę, łatwo otworzyć nową dziurę, przechodząc na wyzwalacz z szerszym tokenem.
Wyniki lokalne dla changelog-check z pełną historią: PR1 exit=1, PR2 exit=0, PR3 exit=0, czyli zgodnie z oczekiwaniami. Logika liczenia zatwierdzeń daje wyniki z prawej kolumny tabeli powyżej.
Słaby punkt poprawionej wersji: filtr author_association odrzuca osoby spoza projektu, ale nie sprawdza prawa zapisu. To warstwa uzupełniająca natywny próg zatwierdzeń, a nie jego zamiennik. Nie sprawdziłem też na GitHubie, jak wymagany check zachowuje się, gdy ten sam job raportuje raz z pull_request, a raz z pull_request_review. Przed wdrożeniem trzeba to zweryfikować testowym PR-em, dokładnie tak, jak każe kurs w ćwiczeniu 7.2.
Czy warto
Tak, ale jako kurs o projektowaniu procesu, a nie o GitHub Actions. Model faz, ról, macierzy „decyzja – kto – próg – dowód w repo", sprzeciwów i erozji governance jest uporządkowany i gotowy do przeniesienia. Przyda się każdemu, kto prowadzi wewnętrzny standard: specyfikację API, politykę bezpieczeństwa albo wzorce architektury.
Kodu z kursu nie kopiowałbym bez testu. Wszystkie usterki opisane wyżej mają wspólną cechę: zielony check, który niczego nie sprawdza, wygląda w UI tak samo jak dobry. Kurs uczy, że reguła bez dowodu w repo nie jest regułą. Do tego trzeba dodać: dowód w repo, którego nikt nie widział w stanie czerwonym, nie jest dowodem.