Version numbers shouldn’t be a decision. They should be a consequence of the changes you made.
Semantic versioning (semver) has clear rules:
- MAJOR: Breaking changes
- MINOR: New features, backwards compatible
- PATCH: Bug fixes, backwards compatible
But manually deciding “is this a minor or patch?” is error-prone and inconsistent. Let’s automate it.
The Core Idea: Conventional Commits
The magic ingredient is conventional commits — a standardized commit message format that tells tooling what kind of change you made.
<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
Setting Up semantic-release
semantic-release is the standard tool for this. It:
- Analyzes commits since last release
- Determines the version bump
- Generates release notes
- Creates Git tags
- Publishes (npm, Docker, etc.)
Installation
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).
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
Complete .releaserc.json
Here’s my production configuration:
{
"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}"
}]
]
}
Enforcing Conventional Commits
Automation only works if commits follow the convention. Enforce 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
Container Image Versioning
For Docker images, use semantic-release to tag images:
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.
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.
Integration with GitOps
After semantic-release creates a tag, trigger 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.
Handling Breaking Changes
Breaking changes deserve special attention:
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 breaking changes in the commit body — they appear in the changelog.
Pre-release Versions
For beta/alpha releases:
{
"branches": [
"main",
{ "name": "beta", "prerelease": true },
{ "name": "alpha", "prerelease": true }
]
}
Commits to beta branch create versions like 1.3.0-beta.1.
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
No manual version bumps. No forgotten changelog entries. No arguments about “is this a minor or patch?”
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 This Matters
Automated versioning isn’t about being lazy. It’s about:
- Consistency: Every release follows the same rules
- Documentation: Changelog is always current
- Traceability: Version → commits → changes
- Speed: No manual steps means faster releases
- Reduced friction: Developers focus on code, not process
Version numbers should tell a story. Automated semantic versioning ensures that story is accurate, consistent, and always up to date.
