Picture the end state first. You merge a branch to main, walk away to get coffee, and by the time you’re back the project has a new version tag, a fresh changelog entry, and a GitLab release with notes written from your commits. Nobody decided “is this 1.4.0 or 1.3.5?” because nobody had to. The version fell out of the work itself.
That’s where I want to get to in this post. I run a self-hosted GitLab and I do not want versioning to be a thing I think about. A version number that someone picks by hand is a tiny black box: it depends on whether that person remembered the rules, was paying attention, and agreed with everyone else about what counts as a breaking change. I would rather the number be a consequence of the commits, something I can inspect and reproduce.
Semantic versioning (semver) gives you the rules to make that possible:
- MAJOR: Breaking changes
- MINOR: New features, backwards compatible
- PATCH: Bug fixes, backwards compatible
Clear enough on paper. The trouble starts when a human has to apply them under deadline pressure. “Is this a minor or a patch?” gets answered differently depending on who’s tired. Let’s hand that decision to a machine that always reads the same rules the same way.
The Core Idea: Conventional Commits
The piece that makes this work is conventional commits, a standard commit message format that tells tooling what kind of change you made. The commit log stops being just a story for humans and becomes structured input.
<type>(<scope>): <description>
[optional body]
[optional footer]
Types that matter for versioning:
fix:→ PATCH bump (1.0.0 → 1.0.1)feat:→ MINOR bump (1.0.0 → 1.1.0)BREAKING CHANGE:or!→ MAJOR bump (1.0.0 → 2.0.0)
Examples:
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
That’s the whole concept. Everything below is just teaching tools how to read it.
The Simplest Version: semantic-release
semantic-release is the tool I reach for. Start with the bare minimum and you’ll understand the moving parts before the config gets busy. It:
- Analyzes commits since last release
- Determines the version bump
- Generates release notes
- Creates Git tags
- Publishes (npm, Docker, etc.)
Installation
This is the smallest config that does something useful. Create a .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 Configuration
stages:
- test
- release
variables:
GITLAB_TOKEN: $GITLAB_TOKEN # CI/CD variable with 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"
Required GitLab Token
Create a Project Access Token with:
- Role: Maintainer
- Scopes: api, write_repository
Add it as CI/CD variable GITLAB_TOKEN (protected, masked).
That’s it. With those three files in place you already have automated versioning. If you stop reading here, you have something that works. Everything past this point is making it fit a real project.
How It Works
When you push to main:
flowchart TD
subgraph analysis["Commit Analysis"]
A["Commits since 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["→ (no release)"]
end
E --> H["Highest bump: MINOR"]
F --> H
H --> I["New version: v1.3.0"]
Then semantic-release:
- Creates
CHANGELOG.mdentry - Commits the changelog
- Creates Git tag
v1.3.0 - Creates GitLab Release with notes
Layer 1: A Real Config
The three-line version ships, but on a real project you’ll want control over which commit types release and how the changelog reads. Here’s the config I actually run:
{
"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}"
}]
]
}
The releaseRules block is where I decide that a refactor counts as a patch and a docs commit ships nothing. The presetConfig controls how the changelog groups things. Read it once and you’ll see it’s the same idea as the simple version, just with the knobs exposed.
Enforcing Conventional Commits
The whole machine depends on commits following the convention. If someone writes “fixed stuff” the analyzer has nothing to read, and the version stalls. Garbage in, no release out. So you enforce the format rather than hope for it.
commitlint
Create .commitlintrc.json:
{
"extends": ["@commitlint/config-conventional"]
}
Pre-commit Hook
Using 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
For projects without 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
Layer 2: Versioning Beyond npm
So far this looks npm-shaped. It isn’t tied to npm at all. The same commit analysis can drive Docker tags, monorepo releases, and deployments.
Container Image Versioning
For Docker images, let semantic-release tag the image with the version it just computed:
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"
With .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"
]
}
Now your Docker images get semantic version tags automatically, derived from the exact same commits.
Monorepo Versioning
For monorepos with multiple packages, use semantic-release-monorepo or independent 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/**/*
Each package maintains its own version, scoped to the files that changed.
Integration with GitOps
This is the part I care about most, because it closes the loop. After semantic-release creates a tag, that tag can trigger a deployment:
deploy:
stage: deploy
script:
- |
# Update GitOps repo with new version
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]+$/
The tag triggers the deploy job, which updates the GitOps repository with the new version. A commit message ends up driving what runs in the cluster, and Git stays the single source of truth from message to deployment.
Handling Breaking Changes
Breaking changes are the one case where I want a human to slow down and write a sentence, so the format makes room for it:
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
The ! after the scope or BREAKING CHANGE: in the footer triggers a MAJOR bump. Document the breaking change in the commit body and it lands in the changelog, so the person hitting the breakage gets the migration note for free.
Pre-release Versions
For beta/alpha releases:
{
"branches": [
"main",
{ "name": "beta", "prerelease": true },
{ "name": "alpha", "prerelease": true }
]
}
Commits to the beta branch create versions like 1.3.0-beta.1, so you can ship something to early testers without bumping the stable line.
The Full Picture: My Workflow
- Develop on feature branch with conventional commits
- MR to main: commitlint validates messages
- Merge to main: semantic-release determines version
- Tag created: triggers deployment pipeline
- GitOps updated: ArgoCD deploys new version
Put the layers together and this is what a release looks like end to end:
No manual version bumps. No forgotten changelog entries. The “is this a minor or patch?” debate is gone because the rules live in a config file instead of in someone’s head. That’s the same coffee-break version tag I described at the top, now with the whole chain behind it.
Troubleshooting
“No release” when expecting one
Check commit types. Only fix, feat, and breaking changes trigger releases by default.
“Permission denied” on push
Ensure GITLAB_TOKEN has write_repository scope and the associated user can push to protected branches.
Duplicate releases
Add [skip ci] to the release commit message (already in the config above).
Why I Bother
People sometimes read this setup as laziness, like I just don’t want to type a version number. The real reason is that I trust a config file more than I trust my own memory at the end of a long day. Here’s what that buys me:
- Consistency: every release follows the same rules, whether I’m sharp or half asleep
- Documentation: the changelog is always current because it’s a byproduct, not a chore
- Traceability: version maps back to commits maps back to changes, no detective work
- Speed: no manual steps means a release is a merge, nothing more
- Low friction: I think about the code, the pipeline thinks about the version
There’s a cost, and I want to be straight about it. You’re trading freedom for discipline. Everyone has to write commits in a specific format, the tooling adds moving parts, and the first time the token scope is wrong you’ll spend an afternoon on it. For a quick throwaway script that’s overkill. For anything I want to run for years and understand later, the version number stops being a decision and starts being a record of what actually happened. That trade is worth it to me.
