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:
- Analyseert commits sinds laatste release
- Bepaalt de versie bump
- Genereert release notes
- Maakt Git tags aan
- 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:
- Maakt
CHANGELOG.mdentry - Commit de changelog
- Maakt Git tag
v1.3.0 - 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:
- Ontwikkel op feature branch met conventional commits
- MR naar main: commitlint valideert messages
- Merge naar main: semantic-release bepaalt versie
- Tag aangemaakt: triggert deployment pipeline
- 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:
- Consistentie: elke release volgt dezelfde regels, of ik nu scherp ben of half in slaap
- Documentatie: de changelog is altijd actueel omdat het een bijproduct is, geen klusje
- Traceerbaarheid: versie wijst terug naar commits wijst terug naar wijzigingen, geen speurwerk
- Snelheid: geen handmatige stappen betekent dat een release een merge is, niets meer
- 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.
