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:

  1. Analyzes commits since last release
  2. Determines the version bump
  3. Generates release notes
  4. Creates Git tags
  5. 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:

  1. Creates CHANGELOG.md entry
  2. Commits the changelog
  3. Creates Git tag v1.3.0
  4. 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

  1. Develop on feature branch with conventional commits
  2. MR to main — commitlint validates messages
  3. Merge to main — semantic-release determines version
  4. Tag created — triggers deployment pipeline
  5. 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:

  1. Consistency: Every release follows the same rules
  2. Documentation: Changelog is always current
  3. Traceability: Version → commits → changes
  4. Speed: No manual steps means faster releases
  5. 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.