Monorepo a pipeline: jak nie utopić CI w setkach jobów

0
54
Rate this post

Nawigacja:

Dlaczego monorepo zabija prosty pipeline, a prosty pipeline zabija monorepo

Microrepo vs monorepo w CI – dwie różne gry

W microrepo pipeline jest najczęściej prosty: jedno repo, jedna aplikacja, kilka typowych kroków – lint, build, test, deploy. Nawet jeśli jobów jest kilkanaście, całość dotyczy jednego, spójnego kontekstu technologicznego i biznesowego. Jeden Dockerfile, jeden zestaw bibliotek, jeden główny zespół.

W monorepo skala jest zupełnie inna. W jednym drzewie katalogów ląduje:

  • kilka lub kilkanaście backendów (często w różnych językach),
  • kilka frontendów, czasem mobilki,
  • wspólne biblioteki (np. SDK, design system),
  • infrastruktura jako kod, pipeline’y, skrypty narzędziowe.

Każdy z tych elementów ma inny cykl życia, inne wymagania co do testów, inne SLA. Pipeline CI dla monorepo musi umieć pogodzić te światy, a do tego reagować na zmiany selektywnie. Próba przeniesienia podejścia 1:1 z microrepo prowadzi do dwóch skrajności: albo wszystko jest zbyt uproszczone i niebezpieczne, albo skrajnie szczegółowe i niemożliwe do utrzymania.

Skąd nagle biorą się setki jobów w CI

Monorepo samo w sobie nie generuje chaosu – robią to powtarzalne, nieprzemyślane decyzje. Typowy mechanizm wygląda tak: zespół zaczyna od kilku modułów, kopiuje prosty pipeline z dawnych microrepo, a potem z każdym nowym projektem dopisuje kolejne joby. Bez refaktoru, bez globalnego spojrzenia, za to z presją czasu.

Po roku okazuje się, że:

  • każdy projekt ma swój własny zestaw jobów: build-api-a, test-api-a, build-api-b itd.,
  • każdy front ma swoją kopię jobów npm/yarn, często tylko lekko zmodyfikowaną,
  • jobów jest kilkaset, z czego część jest martwa, ale nikt nie ma odwagi ich usunąć,
  • nikt tak naprawdę nie wie, które joby są krytyczne, a które historyczne.

Każda większa zmiana w shared bibliotece wymusza odpalenie ogromnej ilości zadań. Wystarczy drobny refaktor we wspólnym module, żeby kolejka CI zapchała się na godziny. Do tego dochodzi ciągłe kopiowanie konfiguracji między projektami i brak centralnych wzorców – idealny przepis na bałagan.

„Jeden wielki build-all” – zbyt prosty pipeline

Na początku monorepo często startuje z jednym, prostym pomysłem: „zróbmy build-all, test-all, żeby mieć pewność”. Na małej skali to nawet działa – trzy serwisy, jeden front, trochę testów jednostkowych. Pipeline trwa 10–15 minut, wszyscy są zadowoleni. Problem pojawia się, gdy rośnie liczba modułów i testów end-to-end.

Po kilku miesiącach ten sam pipeline wygląda już tak:

  • build wszystkich backendów mimo że dotknąłeś tylko CSS-a w jednym froncie,
  • testy wszystkich komponentów, również tych kompletnie niezwiązanych ze zmianą,
  • godzina czekania na wynik MR, bo e2e i integracje muszą „lecieć zawsze”.

Deweloperzy zaczynają omijać CI: zmniejszają liczbę MR-ów, wrzucają większe zmiany naraz („żeby nie odpalać tego potwora zbyt często”), a część rzeczy sprawdzają lokalnie. Pipeline przestaje być szybkim feedback loopem, a staje się bolesną formalnością. Z czasem organizacja zaczyna rozważać rezygnację z monorepo, chociaż prawdziwym problemem jest architektura CI.

„Job per moduł” – zbyt szczegółowy pipeline

Druga skrajność to overengineering: skoro build-all jest za wolny, to ktoś wpada na pomysł, żeby mieć osobne joby na każdy moduł, z osobnymi krokami, filtrami i zależnościami. Na papierze wygląda to atrakcyjnie: „precyzja”, „kontrola”, „skalowalność”. W praktyce szybko rodzi nowe problemy.

Przykładowe objawy:

  • konfiguracja CI liczy tysiące linii YAML, których nikt nie rozumie w całości,
  • dodanie nowego serwisu wymaga dopisania kilkunastu jobów i powiązań między nimi,
  • brak inteligentnego uruchamiania – filtrek ścieżek jest setka, ale i tak przy części zmian odpala się prawie wszystko, bo zależności nikt już nie ogarnia,
  • równoległość jest nieopanowana: dziesiątki czy setki jobów startują jednocześnie, pożerając limity runnerów.

Działa to podobnie jak zbyt drobiazgowy plan projektu: na schemacie wygląda imponująco, a w praktyce każdy change request go psuje i poprawianie trwa tygodniami.

Historia z życia: pipeline, który przestał się domykać w ciągu dnia

Wyobraź sobie zespół, który przeniósł kilkanaście microrepo do jednego monorepo. Początkowo jest euforia: prostsze dependency management, łatwiejszy refaktor wspólnych komponentów, mniej zabawy z wersjonowaniem. Pipeline zostaje praktycznie skopiowany z jednego z dawnych repo, tylko „rozszerzony” o kolejne kroki.

Po roku:

  • każdy MR na główną gałąź wywołuje pipeline trwający nawet 2–3 godziny,
  • kolejka w godzinach szczytu rośnie do kilkunastu–kilkudziesięciu oczekujących buildów,
  • merge do maina jest ograniczany do „windowów”, bo inaczej CI nie nadąża,
  • część testów wyłącza się „tymczasowo”, żeby pipeline się w ogóle kończył.

Monorepo zostaje okrzyknięte winowajcą, chociaż główny problem leży w tym, że pipeline nie urósł razem z projektem. Taki scenariusz można przewidzieć i uniknąć, o ile wcześniej rozumie się specyfikę własnego monorepo i projektuje CI świadomie.

Jak zrozumieć swoje monorepo, zanim dotkniesz YAML-a

Inwentaryzacja projektów i technologii

Zanim zaczną powstawać wymyślne konfiguracje pipeline, trzeba znać zawartość monorepo. Brzmi banalnie, ale wiele zespołów nie ma nawet prostej, aktualnej listy projektów i typów komponentów. Dobrym krokiem jest zwykła inwentaryzacja: co konkretnie siedzi w repo i jakiego rodzaju artefakty produkuje.

Minimum, które warto spisać:

  • aplikacje backendowe: język, framework, sposób budowania, typ testów,
  • frontendy: SPA, MPA, design system, personalizowane bundle,
  • wspólne biblioteki i pakiety NPM/PyPI/Maven – gdzie są i kto z nich korzysta,
  • infra as code: Terraform, Helm, Ansible, CloudFormation, Kustomize,
  • narzędzia i skrypty: generator kodu, lintery niestandardowe, pipeline scripts.

Na tej podstawie zaczyna być jasne, które części wymagają osobnych ścieżek pipeline, a które można obsłużyć wspólnym wzorcem. Frontend monolitu z setkami testów e2e będzie miał zupełnie inne wymagania niż mała, wewnętrzna biblioteka utili.

Mapowanie zależności – prosty graf, a nie od razu rakieta

Kolejny krok to zrozumienie, które moduły zależą od których. Nie chodzi o perfekcyjny, automatyczny graf z wyszukanych narzędzi, tylko o sensowny obraz w głowie zespołu. Można zacząć od prostego rysunku: węzły jako projekty, strzałki jako zależności (np. API korzysta z biblioteki common-auth).

Celem jest identyfikacja:

  • modułów rdzeniowych – zmiana tutaj wpływa na wiele innych (np. wspólne modele domenowe, auth, logging),
  • modułów peryferyjnych – zmiana dotyczy głównie jednego produktu lub komponentu,
  • cyklicznych zależności – które utrudniają selektywne budowanie.

Taki prosty graf dependency jest kluczowy dla strategii pipeline. Jeśli wiesz, że frontend A i backend B używają tej samej biblioteki „shared-types”, możesz od razu założyć, że zmiana w tej bibliotece powinna uruchamiać testy obu. Z kolei zmiana w pojedynczym mikroserwisie, który nic wspólnego z resztą nie ma, może być obsługiwana bardzo lokalnie.

Rodzaje zmian i ich wpływ na CI

Monorepo ma to do siebie, że mieszają się w nim zupełnie różne typy zmian. Nie każda zmiana jest równa innej, a pipeline musi to odzwierciedlać. W praktyce można wyróżnić kilka podstawowych kategorii:

  • zmiany lokalne – dotyczą jednego modułu lub kilku plików w jego obrębie (np. poprawka w API „orders”),
  • zmiany cross-cutting – refaktory wspólnych bibliotek, zmiany w kontraktach, modyfikacje shared tooling,
  • zmiany konfiguracyjne – np. pipeline scripts, konfiguracje infra, zmiany w manifestach deploymentu,
  • zmiany masowe mechaniczne – np. automatyczne formatowanie, podbicie wersji biblioteki w wielu projektach.

Dla każdej kategorii powinien istnieć domyślny „profil” CI: co minimalnie trzeba odpalić, a co jest opcjonalne. Przykładowo, drobna zmiana w peryferyjnym serwisie może przejść pełny zestaw testów jednostkowych i część integracyjnych tylko dla tego modułu. Z kolei zmiana w shared tooling powinna pociągnąć za sobą choćby smoke testy kilku reprezentatywnych aplikacji.

SLA dla buildów i testów – czasy, które naprawdę mają znaczenie

Projektowanie pipeline bez określonych oczekiwań czasowych kończy się przypadkowym kompromisem: „jak się uda, to dobrze, jak nie – trudno”. W monorepo trzeba świadomie ustalić, ile może trwać:

  • pipeline na PR / Merge Request – czas do pierwszego sensownego feedbacku dla dewelopera,
  • pipeline na mainie – ile może trwać „pełniejsza” weryfikacja po merge’u,
  • nightly / długie testy – ile czasu mają testy regresyjne, pełne e2e, statyczna analiza bezpieczeństwa.

Przykładowy zestaw założeń:

  • PR: pierwsze wyniki do 10–15 minut (szybkie testy, linters, basic build),
  • main: pełniejsza weryfikacja do 40–60 minut, ale może równolegle do innych zmian,
  • nightly: może trwać 2–3 godziny, ale nie blokuje pracy programistów.

Dopiero mając takie SLA, da się sensownie podejmować decyzje: które testy lądują na ścieżce PR, a które są zepchnięte do nightly. Czy e2e lecą zawsze, czy tylko na mainie. Czy security scan jest blokujący, czy ostrzegawczy.

Kto naprawdę korzysta z pipeline’u

Pipeline monorepo służy kilku grupom, które mają różne priorytety:

  • deweloperzy – potrzebują szybkiego feedbacku, jasnych błędów i przewidywalnych czasów,
  • QA – zależy im na szerokim pokryciu testami, powtarzalności i raportowaniu jakości,
  • menedżerowie – patrzą na stabilność releasów, ilość awarii, tempo dostarczania,
  • FinOps / ops – interesuje ich koszt utrzymania runnerów, czas trwania jobów, zużycie zasobów.

Dobry pipeline to kompromis między tymi perspektywami. Jeśli będzie przesadnie „bezpieczny” i dłużący się, deweloperzy zaczną go omijać. Jeśli będzie zbyt pobłażliwy, QA i menedżerowie będą tonąć w regresjach. Jeśli będzie bez limitów i optymalizacji, FinOps przeciąłby go w pół przy pierwszym raporcie kosztów.

Lotniczy widok przemysłowego portu w Lubmin nad wybrzeżem
Źródło: Pexels | Autor: Jan

Modele pipeline dla monorepo – od prostego do sprytnego

Trzy podstawowe modele: build-all, modularny statyczny, modularny dynamiczny

Większość pipeline’ów dla monorepo da się zaklasyfikować do jednego z trzech modeli. Różnią się stopniem inteligencji i nakładem pracy potrzebnym do zbudowania oraz utrzymania.

1. Build-all (wszystko zawsze)
Najprostsze podejście: każdy commit odpala budowanie i testowanie całego monorepo. Mało konfiguracji, niewiele magii, łatwo ogarnąć, co się dzieje. Niestety skalowanie jest dramatyczne – czas rośnie wprost proporcjonalnie do wielkości projektu i ilości testów.

2. Modularny statyczny (stałe joby per moduł)
Repo jest podzielone na moduły (np. katalogi /services/api-a, /services/api-b, /frontend/app1 itd.), a dla każdego modułu istnieje stały zestaw jobów. Warunki uruchomienia są określone statycznie (np. na podstawie ścieżek). Każdy job wie, które katalogi go interesują.

3. Modularny dynamiczny (joby generowane na podstawie zmian)
Tu logika idzie krok dalej. Zamiast mieć sztywno wypisane joby dla każdego modułu, konfiguracja jest generowana dynamicznie. Na przykład specjalny krok „planner” analizuje diff i tworzy zestaw modułów do zbudowania, które są następnie przekazywane jako matrix do CI. Pipeline „dopasowuje się” do konkretnych zmian.

Zalety i ograniczenia poszczególnych modeli

Każdy z opisanych modeli ma swoje naturalne środowisko życia. Zamiast wybierać „najbardziej zaawansowany”, lepiej dobrać pipeline do dojrzałości monorepo i zespołu. Inaczej skończy się jak z przedszkolakiem wsadzonym na motocykl – technicznie się da, ale wszyscy boją się wyniku.

Build-all sprawdza się:

  • na początku drogi z monorepo, gdy repo jest jeszcze relatywnie małe,
  • w zespołach, które dopiero uczą się wspólnego CI i potrzebują prostoty,
  • gdy ograniczenia infrastruktury są luźne (dużo runnerów, niski koszt).

Główne ograniczenia są przewidywalne: czas i koszt rosną liniowo z rozmiarem monorepo. W którymś momencie nawet drobny fix w README odpala pół godziny buildów. Wtedy naturalnym krokiem jest przejście w stronę modularności.

Modularny statyczny jest rozsądnym kompromisem:

  • katalogi czy „projekty” w repo mają stałe, czytelne zasady budowania,
  • łatwo wytłumaczyć nowej osobie: „jeśli dotkniesz /services/payments, odpalą się joby X i Y”,
  • warunki typu paths czy rules w YAML-u da się utrzymać ręcznie.

Problem pojawia się, gdy liczba modułów idzie w dziesiątki, a zależności są gęste. YAML zaczyna przypominać pajęczynę warunków, a każda zmiana w strukturze repo wymaga korekty w kilku miejscach. Tu właśnie modularny dynamiczny zaczyna mieć sens.

Modularny dynamiczny błyszczy:

  • gdy modułów jest dużo, a ich zestaw się zmienia (dodawanie, usuwanie usług),
  • przy skomplikowanych zależnościach (feature flagi, różne warianty buildów),
  • gdy zespół ma już komfort z automatyzacją i skryptami generującymi konfigurację.

Tutaj największą ceną jest złożoność początkowa: trzeba napisać i utrzymać logikę „plannera”, który decyduje, co zbudować. Dochodzi też aspekt zaufania – deweloperzy muszą wierzyć, że CI uruchomi „właściwe” rzeczy, choć nie widzą ich wprost w YAML-u.

Kiedy zmieniać model, zamiast łatać stary

Przeskok między modelami pipeline’u rzadko dzieje się z dnia na dzień. Zwykle najpierw dokleja się kolejne warstwy do istniejącej konfiguracji, aż przychodzi moment, gdy łatwiej przepisać, niż dalej łatać. Kilka sygnałów, że ten moment się zbliża:

  • czas trwania pipeline’u rośnie szybciej niż aplikacje – dochodzą nowe joby pomocnicze, raporty, skany,
  • każda zmiana w strukturze katalogów kończy się serią poprawek w CI,
  • zespół boi się dotykać plików YAML, bo „znowu wszystko padnie”.

Praktycznym podejściem jest budowa nowego modelu równolegle. Przez jakiś czas stare CI i nowe działają obok siebie (np. nowe jest oznaczone jako eksperymentalne), aż zespół nabierze pewności, że wybór był słuszny. Jeden zespół backendowy, z którym pracowałem, przez dwa sprinty utrzymywał „stary” build-all oraz nowy, dynamiczny pipeline odpalany tylko na wybranych gałęziach. Dopiero po porównaniu czasów i awaryjności zdecydował się na pełną migrację.

Łączenie modeli w jednym monorepo

Monorepo nie musi stosować jednego, czystego modelu. Często w praktyce wychodzi mieszanka:

  • build-all dla bardzo małych, rzadko dotykanych części (np. prosty CLI, który buduje się w minutę),
  • modularny statyczny dla stabilnych modułów o przewidywalnej strukturze,
  • modularny dynamiczny dla dużych, podlegających szybkim zmianom domen.

Taki hybrydowy układ jest zdrowszy niż próba „zdynamicznienia” wszystkiego. Część projektów po prostu nie potrzebuje tej inteligencji. Pipeline powinien być adekwatny do ryzyka i kosztu, a nie maksymalnie wyrafinowany wszędzie.

Selektywne budowanie i testowanie: jak zapobiec lawinie jobów

Strategie na poziomie plików i ścieżek

Najbardziej oczywista selekcja to patrzenie na zmienione pliki. Większość systemów CI ma mechanizmy typu only:changes, paths lub rules:exists/changes. W monorepo taki filtr staje się pierwszą linią obrony przed odpalaniem zbędnych jobów.

Kilka praktycznych zasad:

  • grupuj ścieżki logicznie, nie „katalogowo” – raczej services/payments/** niż dziesiątki pojedynczych folderów wewnątrz,
  • ignoruj globalne, niskiego ryzyka zmiany (np. zmiany w docs/ nie muszą uruchamiać testów backendu),
  • traktuj pliki konfiguracji narzędzi jako „rozszerzony kod” – modyfikacja .eslintrc czy pytest.ini może wymagać szerszego zakresu testów.

Prosty przykład: job budujący frontend odpala się tylko, gdy zmienią się pliki w frontend/ albo w katalogu shared/ui/. Jeśli commit dotyka tylko services/billing/, front jest omijany. Niby drobnostka, ale przy kilkudziesięciu MR-ach dziennie robi się z tego konkretna oszczędność.

Selekcja po typie zmiany, a nie tylko po lokalizacji

Sama lokalizacja pliku nie zawsze wystarczy. Zmiana w tym samym katalogu może być niewinna (komentarz w testach) lub krytyczna (zmiana kontraktu API). Tu przydaje się kategoryzacja zmian, o której była mowa wcześniej – pipeline może reagować inaczej na:

  • zmiany w kodzie produkcyjnym,
  • zmiany w testach,
  • zmiany w konfiguracji builda/deploymentu.

Przykład z życia: zespół wprowadził prostą regułę – jeśli tylko pliki w katalogach **/tests/** się zmieniły, pipeline na PR odpalał skrócony zestaw jobów (tylko testy i lint dla danego modułu, bez pełnego builda kontenerów). Wystarczyła jedna funkcja w skrypcie „plannera”, która analizowała listę plików z git diff i ustawiała flagę tests_only=true. Efekt: testy mogły być poprawiane i rozszerzane bez karania deweloperów pełnym, ciężkim pipeline’em.

Selektywne testy oparte o graf zależności

Gdy mamy choćby prosty graf zależności między modułami, można pójść krok dalej: uruchamiać testy nie tylko dla zmienionego projektu, ale też dla tych, które z niego korzystają. To właśnie różnica między „zbuduj wszystko, co się zmieniło” a „zbuduj to, na co ta zmiana realnie wpływa”.

W praktyce wygląda to tak:

  1. Planner identyfikuje zmienione moduły na podstawie ścieżek.
  2. Dla każdego modułu wykonuje przejście po grafie zależności – np. idąc „w górę” do konsumentów.
  3. Tworzy listę modułów do zbudowania i przetestowania (oryginały + konsumenci).

Jeśli backend „orders” korzysta z biblioteki shared-auth, a frontend „admin-panel” też ją używa, zmiana w shared-auth odpala testy trzech modułów. Zmiana w samym „orders” – tylko jego. Kluczowe jest, żeby graf był choćby przybliżony i w miarę aktualny. Nie trzeba na start perfekcji – lepszy prosty graf uzupełniany z czasem niż brak grafu i strzelanie w ciemno.

Warstwy testów: szybkie, średnie, ciężkie

Selektywność nie dotyczy tylko tego, które moduły testujemy, ale też jakimi testami. Dobrze jest zorganizować testy w trzy warstwy:

  • szybkie – unit, podstawowe linty, minimalny build; czas rzędu sekund–kilku minut,
  • średnie – testy integracyjne, kontraktowe, smoke e2e dla krytycznych scenariuszy,
  • ciężkie – pełne e2e, testy performance, długie security scany.

Pipeline może składać te warstwy w różne kombinacje:

  • na PR – szybkie + fragment średnich,
  • na mainie – szybkie + pełne średnie + wybrane ciężkie,
  • nightly – cały komplet, łącznie z najbardziej wymagającymi.

W monorepo ta warstwowość ratuje przed lawiną jobów. Zamiast dla każdego modułu uruchamiać „całe testy”, definiujemy konkretną paczkę: np. fast_suite i full_suite. Joby akceptują parametr określający, który zestaw uruchomić. Dzięki temu logika w YAML-u jest prosta, a decyzja o tym, „jak mocno” testować, zapada w jednym miejscu – w plannerze lub w warstwie reguł.

Feature flagi w CI – kiedy odpuszczać pełne sprawdzenie

Nie każda zmiana musi przechodzić ten sam, surowy proces. Czasem przydaje się możliwość świadomego „poluzowania” pipeline’u w kontrolowany sposób. Kilka popularnych mechanizmów:

  • specjalne etykiety na MR (np. ci:light albo ci:full) – interpretowane przez planner,
  • prefixy w nazwach gałęzi (np. spike/...),
  • ręczne przełączniki w interfejsie CI (manual jobs, które mogą zostać pominięte).

Na przykład: MR oznaczony etykietą ci:light przechodzi tylko przez szybkie testy, a pełny zestaw odpali się dopiero po merge’u na mainie. Działa to dobrze w przypadku eksperymentów, spike’ów czy zmian dev-tools, które mają niższe ryzyko. Oczywiście trzeba tu zdrowego rozsądku i jasnych zasad, żeby nie skończyło się nadużywaniem „lekkiej” ścieżki.

Kaskadowe uruchamianie: nie testuj wszystkiego na raz

Selektywność może dotyczyć też kolejności. Zamiast równolegle odpalać 40 jobów, które po 5 minutach obleje podstawowy lint, lepiej zbudować prostą kaskadę:

  1. faza „fast-fail”: lint + najprostsze testy jednostkowe,
  2. jeśli przejdą – dopiero wtedy uruchamiane są cięższe joby integracyjne,
  3. faza e2e startuje dopiero, gdy wcześniejsze etapy są zielone.

Nie chodzi o to, by produkować kolejne zależności między jobami i wydłużać ogólny czas, ale żeby drogie joby w ogóle nie wystartowały, gdy podstawa jest czerwona. W praktyce daje się to dobrze połączyć z matrixami jobów: jedna macierz dla szybkich testów, druga – z większym fanfarami – odpalana tylko przy zielonym wyniku pierwszej.

Dynamiczne generowanie pipeline: kiedy warto pozwolić CI pisać CI

Planner jako pierwszy job w pipeline

Dynamiczny pipeline zwykle zaczyna się od jednego, specjalnego joba – nazwijmy go „plannerem” albo „dispatcherem”. Jego jedyne zadanie to:

  • przeczytać kontekst (diff, etykiety, gałąź, zmienne środowiskowe),
  • zbudować listę modułów, które trzeba zbudować i przetestować,
  • określić profile testów (fast/full, PR/main),
  • wygenerować strukturę dalszych jobów – jako matrix, artefakt lub dynamiczny config.

Technicznie planner może być zwykłym skryptem w Pythonie, Node, Go – czymkolwiek, co wygodnie operuje na plikach i JSON-ie. Rezultatem jego działania jest najczęściej:

  • plik z listą modułów i parametrami (np. plan.json) zapisany jako artefakt,
  • zestaw zmiennych środowiskowych (np. MODULES_TO_BUILD),
  • albo dodatkowy plik konfiguracyjny CI, który późniejsze etapy wczytują.

Reszta pipeline’u staje się wtedy dość generyczna: ma job „build-module”, „test-module”, „deploy-module”, które po prostu iterują po planie. To odróżnia dynamiczne podejście od statycznego YAML-a pełnego skopiowanych bloków.

Źródła prawdy dla plannera

Planner musi wiedzieć, jakie moduły istnieją i jak ze sobą współpracują. Tu pojawia się pytanie: skąd bierze tę wiedzę? Typowe opcje to:

  • plik opisowy w repo (np. modules.yaml, projects.json) utrzymywany przez zespół,
  • konwencje katalogowe – np. każdy moduł ma w swoim katalogu module.config,
  • wykorzystanie istniejących narzędzi (Nx, Lerna, Bazel, Pants), które już budują graf zależności.

Najbardziej elastyczny jest osobny plik opisowy, ale wymaga dyscypliny – dodanie nowego modułu oznacza zmianę nie tylko w kodzie, ale też w „manifestach” monorepo. Z kolei oparcie się tylko na konwencjach katalogowych jest wygodne na start, lecz trudniejsze przy bardziej skomplikowanych relacjach (np. moduł logiczny rozsiany po kilku katalogach).

Dynamiczny config CI: include, matrix, templating

Łączenie statycznego szkieletu z dynamicznym mięsem

Dynamiczny config rzadko zastępuje cały YAML od zera. Częściej działa jak „wkładka” w dość stabilny szkielet pipeline’u. Dobrze jest rozdzielić:

  • części stałe – bezpieczeństwo, skan licencji, formatowanie, publikacja artefaktów globalnych,
  • części zmienne – budowanie i testy konkretnych modułów, eksperymentalne ścieżki.

Szkielet może być jednym, prostym plikiem CI, który:

  • definiuje globalne etapy (prepare, build, test, deploy),
  • ma kilka „kotwic” na dynamiczne include’y lub macierze jobów,
  • uruchamia plannera jako pierwszy krok w fazie prepare.

Planner produkuje wtedy albo dodatkowy plik z definicją jobów, który zostaje dołączony dyrektywą include, albo dane, z których CI składa macierz. W narzędziach typu GitHub Actions będzie to najczęściej matrix generowana na podstawie JSON-a; w GitLab CI – dynamiczny include pliku z jobami lub rules wyliczane z artefaktu. Efekt jest taki, że ręcznie utrzymujesz kilka stron czytelnego YAML-a, a resztę dopisuje „automat”.

Generowanie jobów per moduł vs per warstwa

Dynamiczny pipeline można układać przynajmniej na dwa sposoby:

  • per moduł – każdy moduł dostaje swój zestaw jobów (build, test, deploy),
  • per warstwa – istnieje kilka generycznych jobów typu „build-backend”, „test-frontend”, a moduły są jedynie parametrem.

W podejściu „per moduł” config bywa bardziej rozbudowany, ale łatwiej go śledzić w logach: widać wyraźnie build-orders, test-orders, build-payments. To przydaje się przy rozbudowanych systemach z niezależnymi zespołami.

Model „per warstwa” upraszcza YAML i ułatwia refaktoryzację. Masz jeden job build-backend uruchamiany w macierzy po wszystkich zmienionych usługach. Diagnostyka jest ciut mniej oczywista (więcej parametrów, czasem gęstsze logi), ale zmian w konfiguracji jest dużo mniej.

Często kończy się hybrydą: per moduł dla krytycznych serwisów (gdzie przydaje się pełna kontrola), per warstwa dla reszty. Planner decyduje, czy dany moduł jest „specjalny” i jaki typ jobów dla niego wygenerować.

Szablony jobów i małe DSL-e CI

Gdy pipeline zaczyna żyć własnym życiem, surowy YAML okazuje się mało wygodny. Pomaga wprowadzenie lekkiego DSL-a – małego języka opisującego to, czego oczekujesz od CI, zamiast trzymać całą logikę w jednym pliku konfiguracyjnym.

Przykładowo: w pliku modules.yaml możesz dodać prosty opis:

modules:
  orders-backend:
    type: backend
    language: python
    tests: [unit, integration]
  admin-frontend:
    type: frontend
    language: typescript
    tests: [unit, e2e]

Planner tłumaczy potem ten opis na konkretne joby:

  • backend Pythona dostanie job z pytest i skanem dependency,
  • frontend TypeScript – build webpacka i jest + testy e2e.

Szablony jobów trzyma się w osobnych plikach (lub w sekcjach z anchors), a planner decyduje, który szablon wstrzyknąć i z jakimi parametrami. Dzięki temu dodanie nowego modułu bywa kwestią dodania kilku linii w modules.yaml, bez dotykania głównej definicji CI.

Jak nie zrobić z plannera drugiego monolitu

Łatwo przesadzić: planner rośnie, obsługuje wszystkie wyjątki świata, w środku siedzi logika domenowa („jeśli serwis X to użyj tej bazy, ale tylko w środy”) i nagle debugowanie CI przypomina eksplorację starej katedry – pięknie, ale strach dotknąć.

Kilka prostych zasad trzyma go w ryzach:

  • planner robi decyzje, nie wykonuje pracy – nie buduje, nie testuje; tylko mówi, co ma się wydarzyć,
  • logika specyficzna dla technologii zostaje przy jobie – planner wie, że „frontend = typ X”, ale nie zna szczegółów komendy builda,
  • warstwy reguł są czytelnie rozdzielone: osobno mapowanie „zmiana → moduły”, osobno „moduły → typ jobów”, osobno „typ joba → konkretne parametry.

Dobrą praktyką jest też własny, mały zestaw testów jednostkowych dla plannera. Brzmi poważnie, ale to po prostu kilka przypadków typu „zmieniam plik X, oczekuję planu Y”. W jednym z zespołów prosty test „diff → plan” wykrył przypadek, gdy nowy katalog nie został wciągnięty do grafu zależności i produkcja przestała się budować dopiero po merge’u.

Obserwowalność pipeline’u: logi, metryki, ślady

Monorepo z dynamicznym CI bez porządnej obserwowalności przypomina rozjazd torów bez semaforów – pociągi jeżdżą, dopóki coś się nie zderzy. Warto potraktować pipeline jak aplikację i dać sobie podstawowe narzędzia do patrzenia mu na ręce.

Najpierw logowanie: planner nie powinien milczeć. Dobrze, jeśli wypisuje podsumowanie w stylu:

Detected changes in: [frontend, shared-auth]
Selected modules to build: [frontend, shared-auth, admin-panel]
Test profile: fast

Takie logi bardzo ułatwiają rozmowę „dlaczego ten job się nie uruchomił?”. Zamiast grzebać w kodzie plannera, widać czarno na białym, jakie przyjął założenia.

Drugi element to metryki. Nawet prosty eksport:

  • liczby uruchamianych jobów na MR,
  • średniego czasu trwania poszczególnych faz,
  • procenta jobów odpalonych „na darmo” (zero zmian w modułach) – jeśli potrafisz to policzyć,

daje szybką informację, czy kolejne „optymalizacje” naprawdę coś przynoszą. W jednej z firm okazało się, że najbardziej boli nie ilość jobów, tylko to, że cała flota e2e startowała od razu, bez kaskady fast-fail – po zmianie kolejności średni czas feedbacku na PR spadł o połowę, choć liczba jobów się nie zmieniła.

„Czarne skrzynki” w monorepo – co z modułami legacy

W każdym większym monorepo istnieją kawałki, których nikt już dobrze nie rozumie. Stary serwis, nieczytelny build, testy, których nikt nie odważył się ruszyć. Próba wpięcia ich w sprytny, dynamiczny pipeline bywa jak podpinanie zabytkowego auta do nowoczesnej ładowarki.

Rozsądnym podejściem jest oznaczenie takich części jako „czarne skrzynki”. Dla nich obowiązuje prosty, może nawet zbyt ostrożny zestaw zasad:

  • każda zmiana w katalogu legacy odpala pełny, dość konserwatywny pipeline,
  • selektywne reguły i sprytne przycinanie jobów wchodzą do gry dopiero, gdy moduł zostanie „odkryty na nowo”,
  • planner traktuje je jako osobny typ – np. legacy_module: true – i używa osobnego szablonu jobów.

Z czasem, przy pracach utrzymaniowych, można przenosić moduły z kategorii legacy do „normalnych”, dodając opisy do grafu zależności i dopinając je do standardowych reguł selekcji. Taki stopniowy proces jest zwykle bezpieczniejszy niż próba „przepiszmy wszystko na raz”.

Synchronizacja zmian w monorepo i konfiguracji CI

W monorepo szczególnie boli sytuacja, gdy nowy moduł trafia do kodu, ale pipeline jeszcze o nim „nie wie”. Objawia się to brakiem builda, brakiem testów, czasem cichym pominięciem całego kawałka systemu. Da się to ograniczyć, spinając zmiany w kodzie z modyfikacjami plików opisowych.

Dobrym mechanizmem jest walidacja w stylu „schema check”. Jeśli moduły są opisane w modules.yaml, można mieć mały skrypt, który:

  • szuka nowych katalogów z charakterystycznym plikiem (np. package.json, pyproject.toml),
  • sprawdza, czy odpowiadający im wpis istnieje w manifestach,
  • jeśli nie – wywala pipeline już w fazie prepare z jasnym komunikatem.

Analogicznie da się wymusić, by zmiana w module wymagającym szczególnej opieki (np. część krytyczna dla bezpieczeństwa) zawsze dotykała też odpowiednich plików CI – choćby przez prosty check „jeśli zmieniono katalog X, oczekujemy zmian w pliku Y”.

Skalowanie pipeline’u na wiele ekip

Monorepo zwykle oznacza, że kilka zespołów dotyka wspólnej konfiguracji CI. Jeśli każdy zacznie do niej dopisywać swoje wyjątki, skończy się „konfiguracyjnym spaghetti”. Da się tego uniknąć, wprowadzając coś w rodzaju kontraktów między zespołami a platformą CI.

Taki kontrakt może wyglądać prosto:

  • zespół utrzymuje opis swoich modułów w dedykowanym pliku (np. team-orders-modules.yaml),
  • ten plik ma jasno zdefiniowane pole ci_profile, dependencies, tier (np. critical/normal/low),
  • centralny planner mówi tylko „akceptuję taki format” i robi z niego plan jobów.

Dzięki temu zmiany lokalne (np. zespół chce dodać dodatkowy lint do swojego modułu) dzieją się w obrębie ich plików, bez dotykania głównego pipeline’u. Platformowy zespół CI skupia się na utrzymaniu plannera i szablonów, a nie na rozwikływaniu dziesiątek warunków if team == "X".

Bezpieczeństwo i compliance w dynamicznym CI

Gdy CI zaczyna generować samo siebie, pojawia się naturalne pytanie: gdzie jest granica zaufania? Zwykle pipeline ma uprawnienia do publikowania artefaktów, wypychania obrazów do registry czy wręcz do deploymentu na produkcję. To oznacza, że sposób jego generowania nie może być w pełni dowolny.

Bezpieczne wzorce są dość przewidywalne:

  • planner jest częścią repo i przechodzi code review jak zwykły kod,
  • pipeline może być dynamiczny w „środku”, ale wejściowe i wyjściowe etapy są raczej stałe (np. bezpieczeństwo, podpisywanie artefaktów, release),
  • reguły, które pozwalają pominąć istotne joby (np. security scan), są ograniczone tylko do określonych gałęzi lub uprawnień.

Można też dodać prosty „strażniczy” job, który weryfikuje, że wygenerowany plan spełnia minimalne wymagania – np. każda zmiana w katalogu security-sensitive/ musi mieć w planie job skanujący, niezależnie od tego, co na ten temat sądzi autor MR-a.

Eksperymentowanie z pipeline’em bez blokowania zespołu

Przy dynamicznych pipeline’ach pojawia się pokusa ciągłego „dokręcania śrubek”. Dobrze, jeśli takie eksperymenty nie paraliżują codziennej pracy. Pomagają mechanizmy, które znamy z feature flag w aplikacjach.

Kilka prostych sztuczek:

  • zmienne środowiskowe sterujące trybem plannera (CI_EXPERIMENTAL_PLANNER=true),
  • porównywanie dwóch planów – „nowego” i „starego” – bez odpalania drugiego zestawu jobów, tylko logowanie różnic,
  • czasowe włączenie nowej strategii tylko dla wybranego projektu lub gałęzi.

W jednym z zespołów nowy algorytm selekcji modłów działał przez kilka dni w trybie „shadow”: planner liczył nowy plan, ale pipeline wciąż wykonywał stary. Różnice między planami logowano i po tygodniu było jasne, że nowy algorytm nie gubi krytycznych scenariuszy – można go było włączyć bez lęku.