# Wersjonowanie z automatu: SemVer i Conventional Commits

> Wersja to interfejs komunikacyjny, nie kosmetyka. Jak SemVer i Conventional Commits zamieniają historię commitów w automatyczne, przewidywalne wydania — i gdzie kończy się sens automatu.

URL: https://eiac.dev/blog/wersjonowanie-semver-conventional-commits
Filar: SDLC / Policy-as-Code
Data: 2026-02-16
Tagi: semver, conventional-commits, release, automation, semantic-release, gitlab

---

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.

<div class="callout">
<strong>Teza</strong>
<p>Wersja nie powinna być decyzją podejmowaną na końcu. Powinna być <em>obliczana</em> z tego, co już zapisałeś w commitach.</p>
</div>

## 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](https://semver.org) (SemVer 2.0.0) definiuje wersję jako `MAJOR.MINOR.PATCH`, gdzie każdy człon ma ścisłe znaczenie:

| Człon | Kiedy rośnie                     | Obietnica dla konsumenta                      |
| ----- | -------------------------------- | --------------------------------------------- |
| MAJOR | zmiana łamiąca kompatybilność    | „możesz musieć poprawić swój kod"             |
| MINOR | nowa funkcja, wstecznie zgodna   | „możesz aktualizować bezpiecznie, są nowości" |
| PATCH | poprawka 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:

```text
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:

| Zapis    | Znaczenie      | Dopuszcza        |
| -------- | -------------- | ---------------- |
| `^1.4.2` | zgodny z MAJOR | `>=1.4.2 <2.0.0` |
| `~1.4.2` | zgodny 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.

<div class="callout">
<strong>Pułapka 0.x</strong>
<p>Dla wersji <code>0.y.z</code> SemVer <em>nie</em> gwarantuje stabilności — tu nawet MINOR może łamać kompatybilność. „Jedynka" (<code>1.0.0</code>) to deklaracja: „API jest stabilne, od teraz obowiązuje pełny kontrakt".</p>
</div>

## 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](https://www.conventionalcommits.org) (1.0.0) — lekka konwencja formatu komunikatu commita:

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

[opcjonalne ciało]

[opcjonalne stopki]
```

Przykłady:

```text
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:

- **typ** — `feat`, `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.

```text
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ę…       | Bump         | Przykład        |
| ------------------------------ | ------------ | --------------- |
| `BREAKING CHANGE` lub `typ!`   | MAJOR        | `1.4.2 → 2.0.0` |
| przynajmniej jeden `feat`      | MINOR        | `1.4.2 → 1.5.0` |
| tylko `fix` (i neutralne typy) | PATCH        | `1.4.2 → 1.4.3` |
| tylko `docs`/`chore`/`test`…   | brak 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](/katalog/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](/katalog/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](https://opencontainers.org/) 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.

| Wymiar                   | release-please                                           | semantic-release                                          |
| ------------------------ | -------------------------------------------------------- | --------------------------------------------------------- |
| Model                    | release PR → publikacja po scaleniu                      | publish-on-merge (od razu po merge)                       |
| Bramka wersji            | wbudowana — osobny PR z wersją i changelogiem            | brak osobnej; bramką jest PR z kodem                      |
| Kontrola momentu wydania | wysoka (mergujesz, gdy chcesz wydać)                     | niska (wydanie = skutek merge’a)                          |
| Publikacja artefaktów    | nie publikuje sama (robi tag/release; publish dokładasz) | publikuje przez wtyczki (npm, Releases, registry…)        |
| Changelog                | `CHANGELOG.md` widoczny w release PR                     | generowany przy wydaniu (wtyczka)                         |
| Pre-release / kanały     | podstawowe                                               | rozbudowane (`next`/`beta`/`alpha`, gałęzie utrzymaniowe) |
| Monorepo                 | natywne (wiele pakietów, osobne release PR-y)            | słabiej, wymaga dodatków                                  |
| Konfiguracja             | manifest + plik konfiguracyjny                           | `.releaserc` + dobór wtyczek                              |
| Krzywa wejścia           | niska                                                    | ś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](/katalog/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](/katalog/gitea) odpalisz ją jako Gitea Actions, na [GitLab](/katalog/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](/katalog/dagger), a jeśli wolisz dedykowany silnik — [Woodpecker](/katalog/woodpecker). Dla bezpieczeństwa łańcucha dostaw warto podpisywać tagi/commity wydań, np. bezkluczowo przez [gitsign](/katalog/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.

```yaml
# .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.

```yaml
# .gitlab-ci.yml
release:
  image: node:20
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  script:
    - npx semantic-release
```

```json
// .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:

<figure>
<svg viewBox="0 0 720 132" role="img" aria-label="Cykl wydania: z commitów automat liczy wersję i changelog, po bramce powstaje tag i release">
  <g fill="none" stroke="currentColor" stroke-width="1.5">
    <rect x="16" y="46" width="150" height="40" rx="20"/>
    <rect x="220" y="42" width="200" height="48" rx="6"/>
    <rect x="556" y="42" width="148" height="48" rx="6" stroke="var(--color-rust)"/>
  </g>
  <g stroke="currentColor" stroke-width="1.5">
    <line x1="166" y1="66" x2="214" y2="66"/>
    <line x1="420" y1="66" x2="550" y2="66"/>
  </g>
  <g fill="currentColor">
    <path d="M214 62 L220 66 L214 70 Z"/>
    <path d="M550 62 L556 66 L550 70 Z"/>
  </g>
  <g font-family="'Space Mono', monospace" font-size="12" fill="currentColor" text-anchor="middle">
    <text x="91" y="70">feat · fix · feat!</text>
  </g>
  <g font-family="'Space Grotesk', system-ui, sans-serif" font-size="12.5" fill="currentColor" text-anchor="middle">
    <text x="320" y="62">automat: liczy wersję</text>
    <text x="320" y="78">i changelog</text>
    <text x="630" y="70" fill="var(--color-rust)">tag vX.Y.Z + release</text>
  </g>
  <g font-family="'Space Mono', monospace" font-size="10" fill="var(--color-muted)" text-anchor="middle">
    <text x="485" y="56">release PR → review</text>
    <text x="485" y="88">albo: merge → publish</text>
  </g>
</svg>
<figcaption>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.</figcaption>
</figure>

Walidację konwencji dokładasz jako wcześniejszą bramkę (commitlint / [MegaLinter](/katalog/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](/blog/policy-as-code-dla-zespolow): 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](/katalog/release-please) (model „release PR"), czy [semantic-release](/katalog/semantic-release) (model „publish-on-merge") — zamienia tę historię w deterministyczny numer, changelog i tag. Platforma, na której to działa ([Gitea](/katalog/gitea), [GitLab](/katalog/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ć. :)