Stel je eerst de eindsituatie voor. Je merget een branch naar main, loopt weg om koffie te halen, en tegen de tijd dat je terug bent heeft het project een nieuwe versie-tag, een verse changelog-entry, en een GitLab release met notes die uit je commits zijn geschreven. Niemand besloot “is dit 1.4.0 of 1.3.5?” omdat niemand dat hoefde. Het versienummer rolde uit het werk zelf.

Daar wil ik in deze post naartoe. Ik draai een self-hosted GitLab en ik wil niet dat versioning iets is waar ik over nadenk. Een versienummer dat iemand met de hand kiest is een klein black box: het hangt ervan af of die persoon de regels onthield, oplette, en het met iedereen eens was over wat als een breaking change telt. Ik heb liever dat het nummer een consequentie van de commits is, iets dat ik kan inspecteren en reproduceren.

Semantic versioning (semver) geeft je de regels om dat mogelijk te maken:

  • MAJOR: Breaking changes
  • MINOR: Nieuwe features, backwards compatible
  • PATCH: Bug fixes, backwards compatible

Op papier helder genoeg. Het gedoe begint zodra een mens ze onder tijdsdruk moet toepassen. “Is dit een minor of een patch?” wordt anders beantwoord afhankelijk van wie er moe is. Laten we die beslissing overdragen aan een machine die de regels altijd op dezelfde manier leest.

De kern: conventional commits

Het stuk dat dit laat werken is conventional commits, een gestandaardiseerd commit message formaat dat tooling vertelt welk soort wijziging je maakte. De commit log wordt niet langer alleen een verhaal voor mensen, maar gestructureerde input.

<type>(<scope>): <description>

[optional body]

[optional footer]

Types die ertoe doen voor versioning:

  • fix: → PATCH bump (1.0.0 → 1.0.1)
  • feat: → MINOR bump (1.0.0 → 1.1.0)
  • BREAKING CHANGE: of ! → MAJOR bump (1.0.0 → 2.0.0)

Voorbeelden:

fix(auth): correct password validation regex

feat(api): add user preferences endpoint

feat(core)!: change configuration format

BREAKING CHANGE: config.yml no longer supports legacy format

Dat is het hele concept. Alles hieronder leert tooling alleen maar hoe ze het moeten lezen.

De simpelste versie: semantic-release

semantic-release is de tool waar ik naar grijp. Begin met het absolute minimum en je snapt de bewegende delen voordat de config druk wordt. Het:

  1. Analyseert commits sinds laatste release
  2. Bepaalt de versie bump
  3. Genereert release notes
  4. Maakt Git tags aan
  5. Publiceert (npm, Docker, etc.)

Installatie

Dit is de kleinste config die iets nuttigs doet. Maak een .releaserc.json:

{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    "@semantic-release/gitlab",
    "@semantic-release/git"
  ]
}

GitLab CI configuratie

stages:
  - test
  - release

variables:
  GITLAB_TOKEN: $GITLAB_TOKEN  # CI/CD variable met api scope

release:
  stage: release
  image: node:20
  before_script:
    - npm install -g semantic-release @semantic-release/gitlab @semantic-release/changelog @semantic-release/git
  script:
    - semantic-release
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Vereiste GitLab token

Maak een Project Access Token met:

  • Role: Maintainer
  • Scopes: api, write_repository

Voeg het toe als CI/CD variable GITLAB_TOKEN (protected, masked).

Dat is alles. Met die drie bestanden op hun plek heb je al geautomatiseerde versioning. Als je hier stopt met lezen, heb je iets dat werkt. Alles voorbij dit punt maakt het passend voor een echt project.

Hoe het werkt

Wanneer je naar main pusht:

flowchart TD
    subgraph analysis["Commit Analyse"]
        A["Commits sinds v1.2.3"]
        A --> B["fix(api): handle null response"]
        A --> C["feat(ui): add dark mode"]
        A --> D["docs: update readme"]

        B --> E["→ PATCH"]
        C --> F["→ MINOR"]
        D --> G["→ (geen release)"]
    end

    E --> H["Hoogste bump: MINOR"]
    F --> H
    H --> I["Nieuwe versie: v1.3.0"]

Dan doet semantic-release:

  1. Maakt CHANGELOG.md entry
  2. Commit de changelog
  3. Maakt Git tag v1.3.0
  4. Maakt GitLab Release met notes

Laag 1: een echte config

De versie van drie regels ship je, maar bij een echt project wil je controle over welke commit types een release veroorzaken en hoe de changelog leest. Hier is de config die ik daadwerkelijk draai:

{
  "branches": [
    "main",
    { "name": "beta", "prerelease": true },
    { "name": "alpha", "prerelease": true }
  ],
  "plugins": [
    ["@semantic-release/commit-analyzer", {
      "preset": "conventionalcommits",
      "releaseRules": [
        { "type": "docs", "release": false },
        { "type": "refactor", "release": "patch" },
        { "type": "perf", "release": "patch" },
        { "type": "chore", "scope": "deps", "release": "patch" }
      ]
    }],
    ["@semantic-release/release-notes-generator", {
      "preset": "conventionalcommits",
      "presetConfig": {
        "types": [
          { "type": "feat", "section": "Features" },
          { "type": "fix", "section": "Bug Fixes" },
          { "type": "perf", "section": "Performance" },
          { "type": "refactor", "section": "Refactoring" },
          { "type": "docs", "section": "Documentation", "hidden": true },
          { "type": "chore", "section": "Maintenance", "hidden": true }
        ]
      }
    }],
    ["@semantic-release/changelog", {
      "changelogFile": "CHANGELOG.md"
    }],
    ["@semantic-release/gitlab", {
      "gitlabUrl": "https://gitlab.example.com"
    }],
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
    }]
  ]
}

Het releaseRules blok is waar ik bepaal dat een refactor als patch telt en een docs commit niets ship. De presetConfig regelt hoe de changelog dingen groepeert. Lees het een keer en je ziet dat het hetzelfde idee is als de simpele versie, alleen met de knoppen blootgelegd.

Conventional commits afdwingen

De hele machine hangt af van commits die de conventie volgen. Als iemand “fixed stuff” schrijft, heeft de analyzer niets om te lezen, en blijft de versie hangen. Garbage in, geen release out. Dus dwing je het formaat af in plaats van erop te hopen.

commitlint

Maak .commitlintrc.json:

{
  "extends": ["@commitlint/config-conventional"]
}

Pre-commit hook

Met husky:

npm install --save-dev husky @commitlint/cli @commitlint/config-conventional
npx husky install
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'

GitLab CI check

Voor projecten zonder husky:

lint-commits:
  stage: test
  image: node:20
  before_script:
    - npm install -g @commitlint/cli @commitlint/config-conventional
  script:
    - |
      if [ -n "$CI_MERGE_REQUEST_IID" ]; then
        # Check commits in MR
        git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
        commitlint --from origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --to HEAD
      fi
  rules:
    - if: $CI_MERGE_REQUEST_ID

Laag 2: versioning voorbij npm

Tot nu toe ziet dit er npm-vormig uit. Het zit helemaal niet aan npm vast. Dezelfde commit-analyse kan Docker tags, monorepo releases en deployments aansturen.

Container image versioning

Voor Docker images laat je semantic-release de image taggen met de versie die het zojuist berekende:

release:
  stage: release
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - apk add --no-cache nodejs npm git
    - npm install -g semantic-release @semantic-release/exec
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - semantic-release
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Met .releaserc.json:

{
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    ["@semantic-release/exec", {
      "publishCmd": "docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:${nextRelease.version} && docker push $CI_REGISTRY_IMAGE:${nextRelease.version}"
    }],
    "@semantic-release/gitlab"
  ]
}

Nu krijgen je Docker images automatisch semantic version tags, afgeleid van precies dezelfde commits.

Monorepo versioning

Voor monorepos met meerdere packages, gebruik semantic-release-monorepo of onafhankelijke releases per package:

release-api:
  stage: release
  script:
    - cd packages/api && semantic-release
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      changes:
        - packages/api/**/*

release-web:
  stage: release
  script:
    - cd packages/web && semantic-release
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      changes:
        - packages/web/**/*

Elk package behoudt zijn eigen versie, gescoped op de files die wijzigden.

Integratie met GitOps

Dit is het deel waar ik het meest om geef, want het sluit de cirkel. Nadat semantic-release een tag aanmaakt, kan die tag een deployment triggeren:

deploy:
  stage: deploy
  script:
    - |
      # Update GitOps repo met nieuwe versie
      cd gitops-repo
      kustomize edit set image myapp:${CI_COMMIT_TAG}
      git commit -am "Deploy ${CI_COMMIT_TAG}"
      git push
  rules:
    - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/

De tag triggert de deploy job, die de GitOps repository updatet met de nieuwe versie. Een commit message stuurt uiteindelijk aan wat er in het cluster draait, en Git blijft de single source of truth van message tot deployment.

Breaking changes afhandelen

Breaking changes zijn het ene geval waar ik wil dat een mens afremt en een zin schrijft, dus het formaat maakt er ruimte voor:

feat(api)!: change authentication to OAuth2

BREAKING CHANGE: Basic auth is no longer supported.
Users must migrate to OAuth2. See migration guide at /docs/oauth-migration

De ! na de scope of BREAKING CHANGE: in de footer triggert een MAJOR bump. Documenteer de breaking change in de commit body en hij landt in de changelog, zodat degene die tegen de breakage aanloopt de migratie-note gratis meekrijgt.

Pre-release versies

Voor beta/alpha releases:

{
  "branches": [
    "main",
    { "name": "beta", "prerelease": true },
    { "name": "alpha", "prerelease": true }
  ]
}

Commits naar de beta branch maken versies zoals 1.3.0-beta.1, zodat je iets aan vroege testers kunt geven zonder de stabiele lijn te bumpen.

Het complete plaatje: mijn workflow

Zet de lagen bij elkaar en zo ziet een release er van begin tot eind uit:

  1. Ontwikkel op feature branch met conventional commits
  2. MR naar main: commitlint valideert messages
  3. Merge naar main: semantic-release bepaalt versie
  4. Tag aangemaakt: triggert deployment pipeline
  5. GitOps updated: ArgoCD deployt nieuwe versie

Geen handmatige versie bumps. Geen vergeten changelog entries. Het “is dit een minor of patch?” debat is weg omdat de regels in een config bestand wonen in plaats van in iemands hoofd. Dat is dezelfde koffiepauze-versietag die ik bovenaan beschreef, nu met de hele keten erachter.

Troubleshooting

“No release” terwijl je er een verwacht

Check commit types. Alleen fix, feat, en breaking changes triggeren standaard releases.

“Permission denied” bij push

Zorg dat GITLAB_TOKEN write_repository scope heeft en de geassocieerde user naar protected branches kan pushen.

Dubbele releases

Voeg [skip ci] toe aan de release commit message (al in de config hierboven).

Waarom ik de moeite neem

Mensen lezen deze setup soms als luiheid, alsof ik gewoon geen versienummer wil typen. De echte reden is dat ik een config bestand meer vertrouw dan mijn eigen geheugen aan het eind van een lange dag. Dit is wat het me oplevert:

  1. Consistentie: elke release volgt dezelfde regels, of ik nu scherp ben of half in slaap
  2. Documentatie: de changelog is altijd actueel omdat het een bijproduct is, geen klusje
  3. Traceerbaarheid: versie wijst terug naar commits wijst terug naar wijzigingen, geen speurwerk
  4. Snelheid: geen handmatige stappen betekent dat een release een merge is, niets meer
  5. Lage frictie: ik denk na over de code, de pipeline denkt na over de versie

Er zit een prijs aan, en daar wil ik eerlijk over zijn. Je ruilt vrijheid in voor discipline. Iedereen moet commits in een specifiek formaat schrijven, de tooling voegt bewegende delen toe, en de eerste keer dat de token scope verkeerd staat ben je er een middag mee kwijt. Voor een snel wegwerpscriptje is het overkill. Voor iets dat ik jaren wil draaien en later wil begrijpen, stopt het versienummer een beslissing te zijn en wordt het een verslag van wat er werkelijk gebeurde. Die ruil is het me waard.