Semantic Versioning, Conventional Commits, and Release Please
Quick answer
Eliminate version debates and manual changelogs — conventional commit messages drive automated version bumps, and Release Please creates release PRs with CHANGELOG.md entries automatically.
- Semantic Versioning
- Conventional Commits
- Enforcing Conventional Commits
- CHANGELOG.md Structure
- Release Please
beginner · 40 min
Before you begin
- Git basics — commits, branches, tags
- A GitHub repository
Semantic Versioning, Conventional Commits, and Release Please
Two recurring sources of team friction: "what version should this be?" and "who writes the changelog?" Both have well-established, automatable answers.
Semantic Versioning defines version numbers that communicate meaning. Conventional Commits structures commit messages so tooling can read that meaning. Release Please reads the commit log and creates release PRs automatically, complete with a generated CHANGELOG.
Semantic Versioning
A SemVer version has three parts: MAJOR.MINOR.PATCH
1.4.2
│ │ └── PATCH — backwards-compatible bug fix
│ └──── MINOR — new functionality, backwards compatible
└────── MAJOR — breaking change, incompatible with previous API
Rules
- Increment PATCH for bug fixes that don't change the API
- Increment MINOR for new features that don't break existing users (reset PATCH to 0)
- Increment MAJOR for changes that break existing users (reset MINOR and PATCH to 0)
0.x.y— initial development, anything may change at any time (no stability promise)1.0.0— your first stable, public API
Pre-release versions
1.0.0-alpha.1 # Early development, not feature-complete
1.0.0-beta.2 # Feature-complete, may have bugs
1.0.0-rc.1 # Release candidate, bug fixes only
1.0.0 # Stable release
Pre-release versions have lower precedence than the release: 1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0
Build metadata
1.0.0+build.123
1.0.0+git.abc1234
Build metadata is ignored for version precedence. Two versions that differ only in build metadata are equivalent.
Version ranges in package managers
| Range | npm/semver | Meaning |
|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0 | Compatible with 1.2.3 (same MAJOR) |
~1.2.3 | >=1.2.3 <1.3.0 | Close to 1.2.3 (same MINOR) |
1.2.3 | =1.2.3 | Exact version |
>=1.2.0 | >=1.2.0 | At least 1.2.0 |
Conventional Commits
The Conventional Commits specification defines a commit message format that both humans and tools can read.
Format
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Types
| Type | Meaning | Version bump |
|---|---|---|
feat | New feature | MINOR |
fix | Bug fix | PATCH |
perf | Performance improvement | PATCH |
refactor | Code change that's not a feature or fix | No bump |
chore | Maintenance (deps, tooling) | No bump |
docs | Documentation only | No bump |
style | Formatting, whitespace (no logic change) | No bump |
test | Add or correct tests | No bump |
ci | CI configuration | No bump |
build | Build system changes | No bump |
revert | Revert a previous commit | PATCH |
Scope (optional)
feat(auth): add OAuth2 support
fix(api): handle null response from upstream
chore(deps): bump axios from 1.6.0 to 1.7.0
Scope is a noun in parentheses identifying the area of the codebase — component, module, or subsystem.
Breaking changes — MAJOR bump
Two ways to signal a breaking change:
Option 1: Append ! after the type/scope:
feat!: redesign authentication API
feat(auth)!: remove legacy session tokens
Option 2: Add BREAKING CHANGE: in the footer:
feat(auth): add JWT-based authentication
BREAKING CHANGE: The `/api/auth` endpoint now returns a JWT token instead of a session cookie.
Clients using session-based auth must migrate to token-based auth.
Examples
feat: add support for multiple Redis clusters
fix: prevent crash when config file is missing
docs: update API reference for auth endpoints
chore(deps): bump @types/node from 20.0.0 to 22.0.0
feat(api)!: rename /users endpoint to /accounts
ci: add CodeQL security scanning to PR workflow
test(auth): add unit tests for token expiry handling
revert: "feat: add dark mode" (introduced regression in Safari)
Enforcing Conventional Commits
commitlint
commitlint checks commit messages against the conventional commits spec.
1# Install
2npm install --save-dev @commitlint/cli @commitlint/config-conventional
3
4# Create config
5echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.mjs
6
7# Test manually
8echo "feat: add login page" | npx commitlint
9echo "added login page" | npx commitlint
10# ✖ subject may not be empty [subject-empty]
11# ✖ type may not be empty [type-empty]Husky — git hooks manager
Husky runs commitlint automatically on every commit via the commit-msg git hook.
1npm install --save-dev husky
2
3# Initialize Husky (creates .husky/ directory)
4npx husky init
5
6# Add the commit-msg hook
7echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
8chmod +x .husky/commit-msgNow any commit with a non-conventional message is rejected:
git commit -m "fixed the bug"
# ⧗ input: fixed the bug
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]git commit -m "fix: prevent crash when config file is missing"
# [main abc1234] fix: prevent crash when config file is missingEnforce in CI (catch PRs that bypass hooks)
1# .github/workflows/pr-lint.yml
2name: Lint Commits
3on:
4 pull_request:
5
6jobs:
7 commitlint:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 with:
12 fetch-depth: 0 # Full history needed to check all commits in the PR
13
14 - uses: actions/setup-node@v4
15 with:
16 node-version: 20
17
18 - run: npm ci
19
20 - name: Validate PR commits
21 run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verboseCHANGELOG.md Structure
A conventional CHANGELOG groups commits by release and type:
1# Changelog
2
3## [2.0.0] - 2026-06-01
4
5### ⚠ BREAKING CHANGES
6
7* **auth:** remove legacy session tokens — migrate to JWT
8
9### Features
10
11* **api:** add pagination to /accounts endpoint
12* **auth:** add JWT-based authentication
13
14### Bug Fixes
15
16* prevent crash when config file is missing
17* **api:** handle null response from upstream correctly
18
19## [1.4.2] - 2026-05-15
20
21### Bug Fixes
22
23* **db:** retry connection on transient failuresThis is the format Release Please generates automatically.
Release Please
Release Please is a Google-maintained GitHub Action that:
- Reads conventional commits since the last release
- Determines the next version (MAJOR/MINOR/PATCH bump)
- Opens a Release PR that updates
CHANGELOG.mdand version files - When you merge the Release PR, it creates a GitHub Release and git tag
Setup
# Install the CLI (optional — GitHub Actions is the primary interface)
npm install --save-dev release-please1# .github/workflows/release-please.yml
2name: Release Please
3on:
4 push:
5 branches: [main]
6
7permissions:
8 contents: write
9 pull-requests: write
10
11jobs:
12 release-please:
13 runs-on: ubuntu-latest
14 steps:
15 - uses: googleapis/release-please-action@v4
16 id: release
17 with:
18 release-type: node # or: python, go, java, rust, helm, simpleThat's the minimal config. Release Please creates release-please-config.json and .release-please-manifest.json on first run.
Configuration: release-please-config.json
1{
2 "release-type": "node",
3 "bump-minor-pre-major": true,
4 "changelog-sections": [
5 { "type": "feat", "section": "Features" },
6 { "type": "fix", "section": "Bug Fixes" },
7 { "type": "perf", "section": "Performance" },
8 { "type": "deps", "section": "Dependencies" },
9 { "type": "chore", "section": "Miscellaneous", "hidden": true },
10 { "type": "docs", "section": "Documentation", "hidden": true }
11 ],
12 "extra-files": [
13 "charts/my-api/Chart.yaml" // Also bump version in Helm chart
14 ]
15}How Release Please works
After every merge to main:
- Scans commits since last release tag
- Computes next version based on commit types
- Opens (or updates) a Release PR titled "chore(main): release 2.1.0"
- The PR diff shows: updated
CHANGELOG.md, updatedpackage.jsonversion field
When you merge the Release PR:
- Release Please creates a git tag
v2.1.0 - Creates a GitHub Release with the changelog section as the release notes
Trigger deployments from the release
1# .github/workflows/deploy.yml
2name: Deploy on Release
3on:
4 release:
5 types: [published] # Triggered when Release Please creates the release
6
7jobs:
8 deploy:
9 runs-on: ubuntu-latest
10 steps:
11 - uses: actions/checkout@v4
12
13 - name: Get release version
14 run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
15
16 - name: Build and push
17 run: |
18 docker build -t ghcr.io/myorg/my-api:$VERSION .
19 docker push ghcr.io/myorg/my-api:$VERSION
20
21 - name: Deploy to production
22 run: kubectl set image deployment/my-api my-api=ghcr.io/myorg/my-api:$VERSION -n productionMonorepo Support
Release Please supports multiple independent packages in a monorepo:
1// release-please-config.json
2{
3 "packages": {
4 "packages/api": {
5 "release-type": "node",
6 "package-name": "@myorg/api"
7 },
8 "packages/worker": {
9 "release-type": "node",
10 "package-name": "@myorg/worker"
11 },
12 "charts/my-api": {
13 "release-type": "helm"
14 }
15 }
16}Each package gets its own Release PR and version when commits touching its directory are merged.
The Full Workflow
Developer writes: feat(api): add rate limit headers
→ commits with Husky checking the message format
→ pushes to feature branch
→ opens PR
→ commitlint CI checks all commits in the PR
→ PR is merged to main
Release Please runs:
→ sees: feat(api): add rate limit headers (MINOR bump)
→ opens Release PR: "chore(main): release 1.5.0"
→ updates CHANGELOG.md with the feature listed
→ updates package.json version to 1.5.0
Team reviews and merges Release PR:
→ Release Please tags v1.5.0
→ GitHub Release created with CHANGELOG section as notes
→ Deploy workflow triggers on release published event
→ v1.5.0 image is built, signed, and deployed to production
Frequently Asked Questions
What actually counts as a breaking change?
Anything that requires a consumer to change something. That includes removing or renaming a field, tightening validation, changing a default, and altering behaviour a documented contract relied on. Adding an optional field is not breaking; making it required is. When in doubt, ask whether a caller doing nothing would still work.
Do I have to write commits this way for the tooling to work?
The tooling derives the version bump and changelog from commit types, so yes — inconsistent messages give an inconsistent version history. Enforce it in a commit hook and in CI rather than relying on discipline, because the one unconventional commit is the one that produces a wrong release.
How should I version pre-1.0 software?
By convention — semver itself says only that anything may change during 0.y.z — increment the minor for breaking changes and the patch for everything else, and be explicit that the API is unstable. The convention exists so consumers can pin sensibly. Staying on 0.x indefinitely to avoid committing to stability is common and worth being honest about in your README.
Should the changelog be generated or written?
Generated as a base, edited for the entries that matter. A purely generated changelog reads as a commit log and buries the two things a reader needs — what breaks and what they must do about it. Let tooling collect, then write the upgrade notes yourself.
What's Next
- Jenkins Declarative Pipelines — the CI system that triggers on releases
- Supply Chain Security: Sigstore and SLSA — sign and attest the artifacts that Release Please tags
Next in CI/CD Pipelines
GitHub Actions: CI/CD for Containers and Kubernetes
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.