Automating Semantic Versioning in OSS Releases Without Manual Tagging
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-releaseorrelease-pleasefor 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 type | Example | Version bump |
|---|---|---|
fix | fix: handle null pointer in parser | PATCH |
feat | feat: add dark mode toggle | MINOR |
feat! or BREAKING CHANGE: | feat!: remove deprecated auth API | MAJOR |
chore, docs, style, test | docs: update README examples | No 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-conventionalCreate 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/githubAdd 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
commitizenreplace the freeform commit prompt with an interactive one that builds the message for you. Runnpx czinstead ofgit commit -m. - Fail fast in CI. The
commitlintsetup 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:
- Add
commitlintto 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. - Pick your tool. Use
semantic-releasefor fully automated releases on merge. Userelease-pleaseif you want a PR-based approval gate before the version is published. - Set
fetch-depth: 0in every checkout step that touches versioning or changelog tooling. - Update your CONTRIBUTING.md with examples of correctly formatted commit messages and a note about what each type triggers.
- Consider
commitizenfor 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 saveRelated Articles
Comments (0)
No comments yet. Be the first!