Przejdź do głównej zawartości

LFD140 od Linux Foundation: dobry model governance na GitHubie, ale bramki z kursu przepuszczają to, co mają blokować

· 14 min aby przeczytać
Przemysław Majdak
Full-Stack Developer, Automation Engineer & Web Security Specialist

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ą main i gałęziami PR-ów;
  • commit scalający, taki jak refs/pull/N/merge, bo na pull_request zmienna GITHUB_SHA wskazuje właśnie na niego;
  • workspace pobrany z głębokością 1, bo actions/checkout ma domyślnie fetch-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 dla run, gdy shell nie 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:

PRZmienione plikiOczekiwany wynik
PR1spec/core.md bez CHANGELOGblokada
PR2spec/core.md + CHANGELOG.mdprzejście
PR3tylko README.mdprzejś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:

ScenariuszKursPoprawiona
ta sama osoba zatwierdza dwa razy21
zatwierdzenie, potem prośba o zmiany od tej samej osoby21
dwa zatwierdzenia starszego commita20
zatwierdzenie od osoby spoza projektu21
dwa ważne zatwierdzenia aktualnej wersji22

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 na opened, synchronize i reopened (docs.github.com). Jeśli PR zostanie otwarty bez etykiety normative-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 na pull_request_review, więc po zebraniu zatwierdzeń czerwony check nie zmieni się sam.
  • Pominięty job jest zielony. Pattern 1 sprawdza etykietę w if na 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. listReviews zwraca 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 CODEOWNERS i katalogu .github/. Dokumentacja zaleca trzymać CODEOWNERS w .github i 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 if na poziomie joba. Decyzja „bramka nie dotyczy tego PR" zapada wewnątrz kroku. Pominięty job raportuje sukces, więc gdyby changelog-check był pomijany na zdarzeniach recenzji, każda recenzja nadpisywałaby czerwony wynik zielonym „skipped".
  • set -euo pipefail i lista zmian w zmiennej. Jeśli git diff padnie, krok kończy się błędem, a nie cichym sukcesem. Sprawdziłem to na płytkim klonie: exit=128 i fatal: 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 na main, nie mieszają się do listy zmian PR-a.
  • Wartości z kontekstu przez env, nie wklejane do skryptu.
  • Bez pull_request_target i 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.