Automating Semantic Versioning in OSS Releases Without Manual Tagging

June 11, 2026 6 min read 38 views
Abstract pipeline diagram showing automated version tags flowing between connected nodes on a clean gradient background

You've just merged a critical bug fix, and now someone has to remember to bump the version, write the changelog entry, push a tag, and trigger the release pipeline β€” all in the right order. One missed step and your users get a broken release or, worse, no release at all. Manual versioning is a process that only works until it doesn't.

Automating semantic versioning removes that human bottleneck entirely. When your commits follow a simple convention, tooling can determine whether the next release is a patch, a minor bump, or a major one β€” then tag it, generate the changelog, and publish the package without anyone writing a number by hand.

What you'll learn

  • How Semantic Versioning (SemVer) maps to commit types
  • The Conventional Commits specification and why it matters for automation
  • How to configure semantic-release or release-please for a real project
  • How to wire everything into a GitHub Actions workflow
  • Common pitfalls and how to avoid them

Prerequisites

This guide assumes you have a Git repository hosted on GitHub (the concepts transfer to GitLab and Bitbucket with minor changes), a basic CI pipeline already running, and familiarity with package.json if you're in a Node.js project. For Python or Go projects, the same ideas apply with slightly different tooling β€” noted where relevant.

A Quick Refresher on SemVer

Semantic Versioning uses a MAJOR.MINOR.PATCH format with specific rules. A patch release (1.0.1) fixes a bug without changing the public API. A minor release (1.1.0) adds functionality in a backward-compatible way. A major release (2.0.0) introduces a breaking change.

Those three categories map almost perfectly to the information already in your commit messages β€” if your commit messages actually say what the commit does. That's the foundation this whole approach builds on.

Conventional Commits: Giving Your History a Schema

The Conventional Commits specification defines a lightweight format for commit messages that machines can parse. The structure looks like this:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

The type field drives the version decision. Common types and their version impact:

Commit typeExampleVersion bump
fixfix: handle null pointer in parserPATCH
featfeat: add dark mode toggleMINOR
feat! or BREAKING CHANGE:feat!: remove deprecated auth APIMAJOR
chore, docs, style, testdocs: update README examplesNo release

A breaking change is signaled either by appending ! after the type (feat!:) or by including a BREAKING CHANGE: footer in the commit body. Both approaches work; pick one and document it for your contributors.

Enforcing the Convention with Commitlint

Telling contributors to follow a convention is one thing. Making it impossible to merge a commit that doesn't follow it is another. commitlint runs in your CI pipeline and fails the build if a commit message doesn't conform.

Install it in a Node.js project:

npm install --save-dev @commitlint/cli @commitlint/config-conventional

Create a config file at the project root:

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
};

Add a GitHub Actions step that runs commitlint on every pull request:

name: Lint Commits
on:
  pull_request:
    branches: [main]

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}

The fetch-depth: 0 is important β€” without a full history, commitlint can't walk back to the base commit to lint the range.

Automating the Release with semantic-release

semantic-release is the most widely adopted tool in this space for JavaScript and Node.js projects. It reads your commit history since the last tag, determines the appropriate version bump, updates your changelog, publishes the package, and pushes the tag β€” all in one pipeline run.

Installation and config

npm install --save-dev semantic-release \
  @semantic-release/changelog \
  @semantic-release/git \
  @semantic-release/github

Add a release configuration to your package.json:

{
  "release": {
    "branches": ["main"],
    "plugins": [
      "@semantic-release/commit-analyzer",
      "@semantic-release/release-notes-generator",
      ["@semantic-release/changelog", {
        "changelogFile": "CHANGELOG.md"
      }],
      "@semantic-release/npm",
      ["@semantic-release/git", {
        "assets": ["package.json", "CHANGELOG.md"],
        "message": "chore(release): ${nextRelease.version} [skip ci]"
      }],
      "@semantic-release/github"
    ]
  }
}

The [skip ci] in the commit message tells your CI system not to trigger another pipeline run when semantic-release pushes the version bump commit back to main. Without it, you'll create an infinite loop.

The GitHub Actions release workflow

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

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      issues: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

The permissions block is required for the default GITHUB_TOKEN to write releases and push tags. If you're not publishing to npm, drop the NPM_TOKEN line and remove the @semantic-release/npm plugin.

Alternative: release-please for Multi-Language Projects

Google's release-please takes a different approach: instead of releasing immediately on merge, it opens a pull request that accumulates pending changes. When you're ready to release, you merge the PR β€” which already contains the bumped version and updated changelog. This gives you a manual approval gate without any manual version calculation.

It supports many language ecosystems out of the box: Node.js, Python, Go, Java, Rust, and others. For a Python project, a minimal release-please config looks like this:

{
  "release-type": "python",
  "package-name": "your-package-name",
  "bump-minor-pre-major": true
}

And the Actions workflow:

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

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: python
          token: ${{ secrets.GITHUB_TOKEN }}

When release-please detects conventional commits since the last release, it opens or updates a release PR titled something like chore(main): release 1.4.0. Merge it, and the tag and GitHub release are created automatically.

Keeping Contributors on Track

The tooling only works if contributors write conventional commits. A few practical ways to reinforce this:

  • Document it in CONTRIBUTING.md. Show a real example of a good commit message and explain what each type does.
  • Add a PR template. A checklist item that reads "Commit messages follow the Conventional Commits format" prompts contributors before they open the PR.
  • Use a commit message GUI. Tools like commitizen replace the freeform commit prompt with an interactive one that builds the message for you. Run npx cz instead of git commit -m.
  • Fail fast in CI. The commitlint setup above catches violations before merge, not after. Contributors get clear feedback immediately.

Common Pitfalls

Shallow clones break version detection

Most CI systems default to a shallow clone (fetch-depth: 1). Both semantic-release and commitlint need the full commit history to determine what's changed since the last tag. Always set fetch-depth: 0 in your checkout step.

The first release needs a manual nudge

If your repository has no tags yet, semantic-release will start from 1.0.0 by default. If your project is already at 0.9.3 and you want to continue from there, push an annotated tag manually before the first automated run: git tag v0.9.3 && git push origin v0.9.3. After that, automation takes over.

Squash merging discards commit types

If your repository uses squash merging and the squash commit message is just the PR title, you lose all the conventional commit metadata from individual commits. Either switch to merge commits, or enforce conventional commit format on PR titles specifically β€” GitHub uses the PR title as the squash commit message, so this can work if contributors write descriptive PR titles.

Changelog noise from chore commits

By default, chore, docs, and style commits don't appear in the changelog and don't trigger a release. If you find your changelog is cluttered, audit the commit types being used β€” someone may be misclassifying refactors as feat.

Tokens and permissions expire

A working pipeline today can break next month if a personal access token expires or a team member who created a secret leaves the organization. Use the built-in GITHUB_TOKEN where possible, and document which secrets exist and who owns them.

Wrapping Up

Once you have conventional commits enforced and a release workflow running, version numbers become a side effect of good commit hygiene rather than a chore in themselves. Here are concrete next steps to get there:

  1. Add commitlint to your existing project today. Start with a warning mode if you have a large team, then switch to hard failures after a one-week grace period.
  2. Pick your tool. Use semantic-release for fully automated releases on merge. Use release-please if you want a PR-based approval gate before the version is published.
  3. Set fetch-depth: 0 in every checkout step that touches versioning or changelog tooling.
  4. Update your CONTRIBUTING.md with examples of correctly formatted commit messages and a note about what each type triggers.
  5. Consider commitizen for new contributors who aren't familiar with the convention β€” interactive prompts reduce friction significantly.

The upfront investment is a few hours of configuration. The return is every future release running cleanly without anyone having to think about version numbers again.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.