DevOpsil
CI/CD
85%
Fresh
Part 5 of 6 in CI/CD Mastery

Automated Semantic Versioning with Conventional Commits and release-please

Sarah ChenSarah Chen8 min read

The Pipeline First

Here's the GitHub Actions workflow that handles everything. Versioning, changelog, GitHub release, npm publish. Zero manual steps.

name: Release
on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write
  packages: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
      version: ${{ steps.release.outputs.version }}
    steps:
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          release-type: node
          token: ${{ secrets.GITHUB_TOKEN }}

  publish:
    needs: release-please
    if: needs.release-please.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org

      - run: npm ci
      - run: npm test
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

  docker:
    needs: release-please
    if: needs.release-please.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}
            ghcr.io/${{ github.repository }}:latest

Merge to main. release-please opens a PR with the version bump and changelog. Merge that PR. Release, npm publish, Docker image. Done.

Conventional Commits: The Contract

Automated versioning needs structure. Conventional commits give you that.

feat: add webhook retry logic
fix: handle null response from payment API
docs: update API authentication guide
chore: upgrade eslint to v9
perf: cache database connection pool
feat!: redesign authentication flow

BREAKING CHANGE: JWT tokens now use RS256 instead of HS256.

The format is simple: type(optional-scope): description.

The rules:

  • fix: bumps PATCH (1.0.0 -> 1.0.1)
  • feat: bumps MINOR (1.0.0 -> 1.1.0)
  • feat!: or BREAKING CHANGE: bumps MAJOR (1.0.0 -> 2.0.0)
  • Everything else (docs, chore, test, ci) — no version bump

That's it. Your commit messages drive your version numbers. No debates in Slack about whether this is a minor or patch release.

Enforcing Commit Format

Conventions are worthless without enforcement. Use commitlint.

npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'perf', 'test', 'chore', 'ci', 'build', 'revert'
    ]],
    'subject-max-length': [2, 'always', 100],
    'body-max-line-length': [2, 'always', 200],
  }
};

Hook it into your git workflow with Husky:

npm install --save-dev husky
npx husky init
echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg

Now bad commits fail locally. Fast feedback. No waiting for CI to tell you your commit message is wrong.

For CI, add this job:

lint-commits:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - uses: wagoid/commitlint-github-action@v6
      with:
        configFile: commitlint.config.js

Every PR gets checked. Non-conventional commits block the merge. No exceptions.

How release-please Works

release-please reads your commit history since the last release. It groups commits by type and calculates the next version.

When you push to main, it either:

  1. Creates a release PR with the bumped version and generated changelog
  2. Updates an existing release PR if one is already open

The release PR looks like this:

## [2.3.0](https://github.com/org/repo/compare/v2.2.1...v2.3.0) (2026-03-20)

### Features

* add webhook retry logic (#142)
* support bulk user import via CSV (#138)

### Bug Fixes

* handle null response from payment API (#141)
* fix race condition in queue consumer (#139)

### Performance Improvements

* cache database connection pool (#140)

Merge that PR. release-please creates the GitHub release, tags the commit, and your publish job fires.

Configuration: release-please-config.json

For monorepos or custom behavior, add a config file:

{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "release-type": "node",
  "bump-minor-pre-major": true,
  "bump-patch-for-minor-pre-major": true,
  "include-component-in-tag": false,
  "changelog-sections": [
    { "type": "feat", "section": "Features" },
    { "type": "fix", "section": "Bug Fixes" },
    { "type": "perf", "section": "Performance" },
    { "type": "deps", "section": "Dependencies" },
    { "type": "docs", "section": "Documentation", "hidden": true },
    { "type": "chore", "section": "Miscellaneous", "hidden": true }
  ]
}

bump-minor-pre-major keeps you on 0.x during early development. Features bump patch, not minor, until you hit 1.0.0. Smart default for new projects.

Hide docs and chore from changelogs. Nobody reads those in release notes.

Monorepo Setup

Multiple packages, independent versioning. release-please handles it.

{
  "packages": {
    "packages/api": {
      "release-type": "node",
      "component": "api"
    },
    "packages/web": {
      "release-type": "node",
      "component": "web"
    },
    "packages/shared": {
      "release-type": "node",
      "component": "shared"
    }
  }
}

Commits scoped to a package only bump that package:

feat(api): add rate limiting middleware
fix(web): correct dark mode toggle state

Each package gets its own release PR, version, and changelog. Independent release cycles. No more "bump everything because the API changed."

GitLab CI Alternative

Not on GitHub? Here's the GitLab equivalent using semantic-release:

release:
  stage: deploy
  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"
// .releaserc.json
{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    ["@semantic-release/npm", { "npmPublish": true }],
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json"],
      "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
    }],
    "@semantic-release/gitlab"
  ]
}

Same concept, different tool. Commits drive versions. Automation does the rest.

Pre-1.0 Strategy

Starting at v1.0.0 on day one is wrong. Use 0.x.y during early development when the API is still unstable.

0.1.0 — First feature release
0.2.0 — Second feature release (breaking changes okay)
0.15.3 — Still iterating, still pre-stable
1.0.0 — Public API is stable, breaking changes require a major bump

release-please handles this with bump-minor-pre-major:

{
  "bump-minor-pre-major": true,
  "bump-patch-for-minor-pre-major": true
}

During 0.x, feat: commits bump patch (0.1.0 -> 0.1.1) and feat!: bumps minor (0.1.0 -> 0.2.0). This prevents you from reaching v47.0.0 before your first stable release.

Graduate to 1.0.0 deliberately. It's a commitment to backwards compatibility.

Handling Breaking Changes Gracefully

Breaking changes are inevitable. Communicate them clearly.

feat!: redesign authentication flow

BREAKING CHANGE: JWT tokens now use RS256 instead of HS256.
All existing tokens are invalidated. Clients must re-authenticate.

Migration steps:
1. Update the client SDK to v2.x
2. Regenerate service account tokens
3. Deploy the new auth service before updating clients

The BREAKING CHANGE footer appears in the generated changelog. Users see exactly what changed and how to migrate. No guessing.

Deprecation Before Removal

For libraries and APIs, deprecate before removing:

v1.5.0 — feat: add new auth method, deprecate old method
v1.6.0 — docs: document migration from old to new auth
v2.0.0 — feat!: remove deprecated auth method

Give users at least one minor version cycle to migrate. release-please tracks the deprecation notices in changelogs automatically.

Docker Image Tagging with Semver

Semantic versioning extends beyond npm packages. Apply it to container images:

# In the release workflow, after release-please creates the tag
docker:
  needs: release-please
  if: needs.release-please.outputs.release_created == 'true'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - uses: docker/metadata-action@v5
      id: meta
      with:
        images: ghcr.io/${{ github.repository }}
        tags: |
          type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
          type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
          type=semver,pattern={{major}},value=${{ needs.release-please.outputs.version }}

    - uses: docker/build-push-action@v6
      with:
        push: true
        tags: ${{ steps.meta.outputs.tags }}

A release of v2.3.1 produces three tags: 2.3.1, 2.3, and 2. Users pinning to 2 get all compatible updates. Users pinning to 2.3.1 get exactly that version.

Troubleshooting

Problem: release-please isn't creating a release PR. Fix: Check that commits use conventional format. Run git log --oneline and verify prefixes. Non-conventional commits are invisible to release-please.

Problem: Version bumps are wrong (patch instead of minor). Fix: Ensure you're using feat: for features and fix: for fixes. A common mistake is writing update: add new endpoint — release-please doesn't recognize update: as a type.

Problem: Changelog is too noisy with chore/docs commits. Fix: Configure changelog-sections to hide non-user-facing changes:

{
  "changelog-sections": [
    { "type": "feat", "section": "Features" },
    { "type": "fix", "section": "Bug Fixes" },
    { "type": "perf", "section": "Performance" },
    { "type": "chore", "hidden": true },
    { "type": "docs", "hidden": true },
    { "type": "test", "hidden": true }
  ]
}

Problem: Monorepo packages version independently but you want lockstep. Fix: Use linked-versions in release-please to group packages:

{
  "group-pull-request-title-pattern": "chore: release ${component}",
  "linked-versions": {
    "group1": {
      "packages": ["packages/api", "packages/web"],
      "version": "1.0.0"
    }
  }
}

The Result

Before: someone decides it's release day. They manually bump package.json, write changelog entries from memory, tag the commit, create a GitHub release, copy-paste notes, publish to npm. Takes 30 minutes. Gets skipped when people are busy.

After: merge to main. Everything happens. Every time. Version numbers communicate meaning. Changelogs write themselves. Docker images get proper tags. Monorepo packages release independently.

If it's not automated, it doesn't exist.

Best Practices Checklist

  • Use conventional commits from day one — retrofitting is painful
  • Enforce commit format with commitlint in both local hooks and CI
  • Start at 0.1.0 and graduate to 1.0.0 deliberately
  • Hide chore, docs, and test commits from changelogs
  • Use linked-versions in monorepos when packages must release together
  • Tag Docker images with semver, not just commit SHAs
  • Document breaking changes with migration steps in the commit body
  • Run release-please only on main — feature branches don't need releases

Version your software like you mean it.

Share:
Sarah Chen
Sarah Chen

CI/CD Engineering Lead

Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.

Related Articles