SDLC / Policy-as-Code

Wersjonowanie z automatu: SemVer i Conventional Commits

Ile razy bumpnąłeś wersję „na oko”, bo trzeba było coś wypuścić, a nikt nie pamiętał, czy ta zmiana to jeszcze 1.4.3, czy już 2.0.0? U mnie — za dużo razy, i za każdym numer wersji trochę kłamał. Bo numer wersji to najmniejszy interfejs Twojego oprogramowania. Zanim ktokolwiek zajrzy w changelog, podejmuje decyzję na podstawie trzech liczb: czy to bezpieczne, czy coś się zepsuje, czy warto. Jeśli nadaje je człowiek na oko, interfejs kłamie — a wraz z nim cała automatyzacja, która na nim polega.

Ten artykuł pokazuje, jak zamienić wersjonowanie z rytuału w funkcję: deterministyczną, wyprowadzaną wprost z historii commitów. Po drodze: dogłębna analiza problemu, standard SemVer, specyfikacja Conventional Commits i narzędzia, które spinają to w pipeline.

Teza

Wersja nie powinna być decyzją podejmowaną na końcu. Powinna być obliczana z tego, co już zapisałeś w commitach.

Problem: dlaczego ręczne wersjonowanie zawodzi

Na pierwszy rzut oka nadanie wersji to trywialna czynność — podbij liczbę, otaguj, opublikuj. W praktyce to miejsce, w którym kumuluje się dług całego procesu wydawniczego. Kilka warstw problemu:

1. Wersja zależy od wiedzy, która nie jest zapisana

Żeby poprawnie podbić wersję, trzeba wiedzieć, co zmieniło się od ostatniego wydania i czy któraś zmiana łamie kompatybilność. Ta wiedza zwykle żyje w głowach autorów i rozprasza się z każdym dniem. Po dwóch tygodniach i czterdziestu commitach nikt nie odtworzy z pamięci, czy gdzieś nie zmienił się kontrakt API.

2. Człowiek myli „dla mnie” z „dla użytkownika”

Autor zmiany ocenia ją z perspektywy implementacji („mała poprawka”), a nie konsumenta API („zmieniła się sygnatura, to breaking change”). To systematyczny błąd poznawczy — i główne źródło wersji, które obiecują kompatybilność, a jej nie dają.

3. Changelog rozjeżdża się z kodem

Ręcznie pisany changelog to osobne źródło prawdy, które trzeba pamiętać, by zaktualizować. Im więcej pośpiechu, tym większy dryf między tym, co naprawdę się zmieniło, a tym, co opisano.

4. Wydanie staje się wąskim gardłem

Skoro „wydanie” wymaga człowieka, który zbierze odpowiedzialność za numer, changelog i tag — wydania robi się rzadziej, większymi partiami. A większe partie to trudniejszy przegląd, większe ryzyko i wolniejszy feedback. Klasyczna spirala.

5. Niespójność między projektami i ludźmi

Każdy nadaje wersje trochę inaczej. W monorepo albo w organizacji z dziesiątkami usług brak jednej, egzekwowalnej reguły oznacza, że „2.3.0” w jednym repo znaczy co innego niż w drugim.

Wersjonowanie to nie problem „podbicia liczby”. To problem utrwalenia intencji zmiany w momencie jej powstania i deterministycznego wyprowadzenia z niej konsekwencji.

Rozwiązanie ma więc dwie części: standard, który nadaje liczbom znaczenie (SemVer), oraz konwencję, która utrwala intencję w commicie (Conventional Commits). Reszta to już automat.

SemVer — kontrakt w trzech liczbach

Semantic Versioning (SemVer 2.0.0) definiuje wersję jako MAJOR.MINOR.PATCH, gdzie każdy człon ma ścisłe znaczenie:

CzłonKiedy rośnieObietnica dla konsumenta
MAJORzmiana łamiąca kompatybilność„możesz musieć poprawić swój kod”
MINORnowa funkcja, wstecznie zgodna„możesz aktualizować bezpiecznie, są nowości”
PATCHpoprawka błędu, wstecznie zgodna„aktualizuj bez obaw”

Kluczowa zasada: numer to kontrakt, nie metryka aktywności. Podbicie MAJOR nie znaczy „dużo pracy”, tylko „złamaliśmy obietnicę zgodności”. Podbicie PATCH nie znaczy „mała zmiana”, tylko „nic się dla Ciebie nie zmienia poza naprawą”.

Pre-release i metadane builda

SemVer pozwala oznaczyć wersje niestabilne i dołączyć metadane:

1.4.0-rc.1          # pre-release: 1.4.0 jeszcze nie gotowe
1.4.0-beta.2+exp.7  # pre-release + metadane builda (po +)

Wersje pre-release mają niższy priorytet niż odpowiadające im wydanie (1.4.0-rc.1 < 1.4.0). Metadane po + są ignorowane przy porównywaniu — służą tylko identyfikacji builda.

Zakresy: caret i tilde

Menedżery pakietów pozwalają deklarować, na jakie aktualizacje się godzisz. Dwa najważniejsze operatory:

ZapisZnaczenieDopuszcza
^1.4.2zgodny z MAJOR>=1.4.2 <2.0.0
~1.4.2zgodny z MINOR>=1.4.2 <1.5.0

^ (caret) to domyślny wybór w większości ekosystemów — ufasz, że MINOR i PATCH są bezpieczne. To zaufanie działa tylko wtedy, gdy autorzy uczciwie trzymają się SemVer. Stąd waga automatyzacji: jeśli bump liczy maszyna z reguł, kontrakt przestaje zależeć od dyscypliny.

Pułapka 0.x

Dla wersji 0.y.z SemVer nie gwarantuje stabilności — tu nawet MINOR może łamać kompatybilność. „Jedynka" (1.0.0) to deklaracja: „API jest stabilne, od teraz obowiązuje pełny kontrakt".

Conventional Commits — commit jako dane

SemVer mówi, co znaczą liczby. Brakuje ogniwa, które w momencie pisania kodu utrwali intencję zmiany w formie nadającej się do maszynowego odczytu. Tym ogniwem jest Conventional Commits (1.0.0) — lekka konwencja formatu komunikatu commita:

<typ>[opcjonalny zakres][!]: <opis>

[opcjonalne ciało]

[opcjonalne stopki]

Przykłady:

feat(catalog): dodaj filtr po filarze
fix(nav): popraw kontrast linku w trybie ciemnym
docs: uzupełnij sekcję deploymentu
refactor(api)!: zmień kształt odpowiedzi /tools

Najważniejsze elementy:

  • typfeat, fix, docs, refactor, perf, test, build, ci, chore i inne. Tylko dwa mają wpływ na wersję (patrz niżej).
  • zakres (scope) — opcjonalny obszar zmiany w nawiasie, np. (catalog).
  • ! — wykrzyknik przed dwukropkiem oznacza breaking change.
  • stopka BREAKING CHANGE: — alternatywny, jawny sposób oznaczenia złamania kompatybilności, z opisem migracji.
feat(api): obsługa paginacji w katalogu

BREAKING CHANGE: endpoint /tools zwraca teraz obiekt { items, next }
zamiast tablicy. Zaktualizuj klientów odczytujących odpowiedź.

Zysk jest podwójny: commit staje się czytelny dla człowieka (od razu wiadomo, co i gdzie) i parsowalny dla maszyny (typ + flaga breaking → konkretny bump SemVer).

Od commita do wersji: jak liczy się bump

Mapowanie jest proste i deterministyczne. Narzędzie patrzy na wszystkie commity od ostatniego tagu i wybiera najwyższy wymagany bump:

W commitach pojawia się…BumpPrzykład
BREAKING CHANGE lub typ!MAJOR1.4.2 → 2.0.0
przynajmniej jeden featMINOR1.4.2 → 1.5.0
tylko fix (i neutralne typy)PATCH1.4.2 → 1.4.3
tylko docs/chore/testbrak wydania

Changelog powstaje przy okazji: narzędzie grupuje commity wg typu („Features”, „Bug Fixes”, „BREAKING CHANGES”) i zapisuje je w CHANGELOG.md. Jedno źródło prawdy — historia Git — zasila i numer, i notatki, i tag.

Narzędzia: od konwencji do wydania

Ekosystem dzieli się na trzy role: egzekwowanie konwencji przy commicie, liczenie wersji i changelogu, oraz uruchamianie tego wszystkiego w pipeline.

Liczenie wersji i wydania

To serce automatyzacji — i tu kończy się różnica „narzędziowa”, a zaczyna wybór modelu wydań. Dwa dominujące podejścia realizują tę samą ideę (wersja liczona z commitów), różniąc się momentem publikacji:

Model „release PR”. release-please zbiera commity, wylicza wersję i otwiera pull request z podbiciem wersji oraz changelogiem. Publikacja następuje dopiero po scaleniu tego PR — masz więc jawny, recenzowalny artefakt, zanim cokolwiek wyjdzie na świat. To „bramka wersji” wbudowana w proces.

Model „publish-on-merge”. semantic-release idzie krok dalej: po merge do gałęzi wydawniczej sam analizuje commity, ustala wersję, generuje changelog i — przez bogaty system wtyczek — publikuje artefakt (npm, GitLab/GitHub Releases, obraz OCI itd.) oraz odkłada tag. Brak ręcznego kroku to najkrótsza droga od merge do wydania; kontrolę przenosisz wtedy na etap PR z kodem (bo to on jest ostatnią bramką przed main).

semantic-release wyróżnia się dodatkowo kanałami wydawniczymi: z main publikujesz wydania stabilne, z next/beta/alpha — pre-release (1.5.0-beta.1), a z gałęzi 1.x — wydania utrzymaniowe. To kompletny, deklaratywny model cyklu życia wersji.

Ten sam wzorzec mają inne narzędzia: changesets (świetny do monorepo — intencję zapisuje się w osobnych plikach, nie tylko w commitach) czy git-cliff (sam generator changelogu). Różnią się ergonomią, nie ideą.

release-please kontra semantic-release

Oba liczą wersję z Conventional Commits, ale realizują dwie różne filozofie wydań. Najważniejsza różnica to rozdzielenie „przygotowania” od „publikacji”: release-please je rozdziela (najpierw PR z wersją, publikacja dopiero po jego scaleniu), a semantic-release scala w jeden, automatyczny krok po merge.

Wymiarrelease-pleasesemantic-release
Modelrelease PR → publikacja po scaleniupublish-on-merge (od razu po merge)
Bramka wersjiwbudowana — osobny PR z wersją i changelogiembrak osobnej; bramką jest PR z kodem
Kontrola momentu wydaniawysoka (mergujesz, gdy chcesz wydać)niska (wydanie = skutek merge’a)
Publikacja artefaktównie publikuje sama (robi tag/release; publish dokładasz)publikuje przez wtyczki (npm, Releases, registry…)
ChangelogCHANGELOG.md widoczny w release PRgenerowany przy wydaniu (wtyczka)
Pre-release / kanałypodstawowerozbudowane (next/beta/alpha, gałęzie utrzymaniowe)
Monoreponatywne (wiele pakietów, osobne release PR-y)słabiej, wymaga dodatków
Konfiguracjamanifest + plik konfiguracyjny.releaserc + dobór wtyczek
Krzywa wejścianiskaśrednia (wtyczki, tokeny, uprawnienia)

Co z tego wynika w praktyce:

  • Kontrola czasu i treści wydania. release-please daje „poczekalnię”: kilka feature’ów może czekać w jednym release PR, a Ty decydujesz, kiedy je wydać i możesz jeszcze dopieścić notatki. semantic-release wydaje, gdy tylko zmiana wejdzie na gałąź — świetne do ciągłego dostarczania, mniej wygodne, gdy chcesz „zebrać” wydanie albo zsynchronizować je z czymś poza repo.
  • Co znaczy „wydanie”. release-please domyślnie kończy na tagu i wpisie release — samą publikację artefaktu (npm, obraz) dokładasz osobnym krokiem. semantic-release traktuje publikację jako część procesu: jego siłą jest ekosystem wtyczek, który w jednym przebiegu zaktualizuje changelog, opublikuje pakiet, utworzy release i odłoży tag (atomowo — albo wszystko, albo nic).
  • Audyt i zgodność. Jawny release PR to naturalny punkt przeglądu i zatwierdzenia („czy na pewno 2.0.0?”) — bywa wymagany przy regulacjach. W modelu publish-on-merge ten przegląd musi wydarzyć się wcześniej, na PR z kodem, bo po merge nie ma już ręcznego hamulca.
  • Pre-release i utrzymanie wielu linii. Jeśli prowadzisz równolegle 1.x, 2.x i kanał beta, rozbudowany model gałęzi semantic-release jest tu znacząco wygodniejszy.
  • Monorepo. Przy wielu pakietach w jednym repo release-please ma przewagę (osobne wersje i release PR-y per pakiet); semantic-release wymaga obejść, a często lepszym wyborem bywa wtedy changesets.

W skrócie: wybierz release-please, gdy cenisz jawną bramkę wersji, kontrolę momentu wydania i monorepo; wybierz semantic-release, gdy chcesz maksymalnej automatyzacji „od merge do opublikowanego artefaktu” i bogatych kanałów wydawniczych.

Egzekwowanie konwencji

Automatyczne liczenie wersji działa tylko wtedy, gdy commity faktycznie trzymają się konwencji. Dlatego warto ją wymusić: lokalnie (hook pre-commit / commitlint) i w CI. Wygodnie zrobić to przez MegaLinter, który potrafi uruchomić walidację komunikatów commitów obok reszty linterów — jednym krokiem w pipeline.

Uruchamianie w pipeline

Cała mechanika żyje w repozytorium i CI — i jest przenośna między platformami. Na Gitea odpalisz ją jako Gitea Actions, na GitLab jako pipeline w .gitlab-ci.yml, a ten sam automat zadziała też na innym CI. Do przenośnych, kontenerowych kroków pasuje Dagger, a jeśli wolisz dedykowany silnik — Woodpecker. Dla bezpieczeństwa łańcucha dostaw warto podpisywać tagi/commity wydań, np. bezkluczowo przez gitsign.

Pipeline w praktyce

Ten sam wzorzec, dwie platformy i dwa modele wydań.

Gitea Actions + release-please (model „release PR”). Przy pushu do main powstaje lub aktualizuje się release PR; po jego scaleniu — tag i wydanie.

# .gitea/workflows/release.yml
name: release
on:
  push:
    branches: [main]
jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: node # albo: simple, python, go…

GitLab CI + semantic-release (model „publish-on-merge”). Po merge do main job sam liczy wersję, tworzy changelog, tag i release.

# .gitlab-ci.yml
release:
  image: node:20
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  script:
    - npx semantic-release
// .releaserc.json
{
  "branches": ["main", { "name": "beta", "prerelease": true }],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    "@semantic-release/gitlab",
    "@semantic-release/git"
  ]
}

Oba modele sprowadzają się do tego samego cyklu:

feat · fix · feat! automat: liczy wersję i changelog tag vX.Y.Z + release release PR → review albo: merge → publish
Ten sam cykl w obu modelach: z commitów automat liczy wersję i changelog, a po bramce (release PR albo merge) powstaje tag i wydanie.

Walidację konwencji dokładasz jako wcześniejszą bramkę (commitlint / MegaLinter), a podpisywanie tagów jako krok po wydaniu. Każdy element jest deklaratywny i wersjonowany — wydania przestają być czynnością, a stają się właściwością repozytorium.

Pułapki i dobre praktyki

  • Squash vs merge. Jeśli scalasz PR-y przez squash, to tytuł squasha musi być zgodny z Conventional Commits — bo to on trafia do historii main. Wymuś walidację tytułu PR.
  • 0.x to nie wymówka. Brak stabilności w 0.y.z nie znaczy, że można ignorować breaking changes — oznaczaj je, by changelog był uczciwy, i świadomie zaplanuj 1.0.0.
  • Nie kłam typem. feat użyty do poprawki zawyża wersje i psuje zaufanie do ^. Konsekwencja w typach jest ważniejsza niż ich liczba.
  • Monorepo. Przy wielu pakietach w jednym repo rozważ narzędzie świadome granic pakietów (np. changesets) albo release-please w trybie wielopakietowym — inaczej jeden feat podbije wszystko.
  • Człowiek w pętli. Automat ma przygotować wydanie (PR/draft), nie publikować bez kontroli. To ta sama zasada, co przy każdym automacie treści i infrastruktury — zgodna z podejściem Policy-as-Code: reguły egzekwuje maszyna, decyzję zatwierdza człowiek.

Podsumowanie

Ręczne wersjonowanie zawodzi, bo wymaga, by człowiek odtworzył i poprawnie ocenił wiedzę, której nigdzie nie zapisał. SemVer nadaje trzem liczbom znaczenie kontraktu; Conventional Commits utrwalają intencję zmiany w chwili jej powstania; automat — czy to release-please (model „release PR”), czy semantic-release (model „publish-on-merge”) — zamienia tę historię w deterministyczny numer, changelog i tag. Platforma, na której to działa (Gitea, GitLab czy inna), jest tu wymienna.

Najważniejsze nie jest jednak narzędzie, lecz podejście: wersja przestaje być decyzją podejmowaną w pośpiechu na końcu, a staje się obliczalną konsekwencją tego, jak pracujesz. Czyli dokładnie tym, czym numer wersji być powinien. Weź to na warsztat na jednym repo — raz skonfigurowany automat sprawia, że o wersjonowaniu po prostu przestajesz myśleć. A to jedna z przyjemniejszych rzeczy, jakie możesz sobie sprawić. :)