The semver promise vs the semver reality
The semver specification is admirably precise: increment the major version when you make incompatible API changes, the minor version for backward-compatible new functionality, and the patch version for backward-compatible bug fixes. Downstream consumers can then express version constraints that capture their intent — allow patches and minor updates but not major changes.
The problem is enforcement. There is no automated mechanism that checks whether a given code change is breaking. A developer who adds a required parameter to a public function (breaking change) might bump the patch version because it feels like a small fix. A developer who removes a feature that has been deprecated for two years might bump the minor version because they believe no one actually uses it. Both are wrong. Both break downstream users who followed the semver contract.
The root cause: determining whether a change is breaking requires understanding the public API surface and how it changed. That requires reading diffs — and doing so systematically enough to catch every breaking change across a potentially large codebase. Manual processes don't scale to this.
What automated diff analysis for versioning looks like
Several tools attempt to automate breaking change detection by analyzing the diff between two versions of a library's API surface:
- go-apidiff — For Go packages, analyzes exported types, functions, and their signatures. Detects removed exports, changed parameter types, changed return types.
- api-extractor — Microsoft's tool for TypeScript. Generates an API surface report and can diff two reports to detect breaking changes in type definitions.
- japicmp — Java API compatibility checker. Compares two JARs and reports added/removed/changed public API elements.
- semver-diff — A simpler tool that compares two semver version strings and tells you the type of change (major/minor/patch), but doesn't analyze the code itself.
# Example: using api-extractor for TypeScript API diff # Install: npm install -g @microsoft/api-extractor # Generate API report for current version: api-extractor run --local # This produces a .api.md file documenting the public API surface. # Commit this file. On next release, the diff shows what changed: git diff HEAD~1 -- temp/mylib.api.md # Output shows: # + addUser(user: User): Promise<User> ← new export (minor) # - deleteUser(id: string): void ← removed export (MAJOR!) # ~ updateUser(id: string, user: Partial<User>): Promise<User> ← changed (check carefully)
Conventional commits: commit messages as version signals
A parallel approach to code-level diff analysis is using commit messages as signals for version bumps. The Conventional Commits specification standardizes commit message formats that encode the nature of the change:
# Conventional commit format: # <type>[optional scope]: <description> # [optional body] # [optional footer(s)] # Examples: feat: add user authentication endpoint # → minor bump fix: correct timeout calculation bug # → patch bump feat!: remove legacy API v1 endpoints # → major bump (! = breaking) fix(auth): handle expired token gracefully # → patch bump # Breaking change via footer (alternative to !): feat: redesign authentication flow BREAKING CHANGE: The auth() function now requires a config object instead of positional arguments. See migration guide.
With conventional commits, tooling like semantic-release can automatically determine the next version number, generate a changelog, and publish the release — all without human judgment.
semantic-release: the ecosystem converging point
semantic-release is the most widely adopted tool in the automated versioning space. It reads conventional commits since the last release, determines the appropriate version bump, generates a changelog, tags the release, and publishes to npm (or wherever your artifact goes) — all in a CI pipeline.
# .releaserc.json for semantic-release:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github"
]
}
# In CI (GitHub Actions):
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-releaseThe result: every commit to main automatically triggers a release if the conventional commit format indicates one is needed. Humans don't decide version numbers. The commit history decides version numbers, which is as close to objective as you can get without full static analysis.
The problem: commit messages are still human-written
Here is the fundamental limitation of the conventional commits approach: it depends on developers correctly labeling their commits. A developer who removes a public function but writes fix: remove broken function instead of feat!: remove public deleteUser function will produce a patch bump when a major bump was required.
Commit messages are a proxy for code-level change analysis. They're a good proxy — better than nothing, better than pure human judgment — but they're still subject to human error and optimism. The developer who thinks "this change probably isn't breaking for anyone in practice" will write a fix: commit and cause a breaking release.
The ideal toolchain combines both approaches: use conventional commits as the baseline for release automation, and add automated API diff analysis as a CI check that fails the pipeline when a breaking change is detected but the commit message doesn't declare it. This catches the "accidental breaking change with wrong label" case that conventional commits alone miss.
The right direction, but still too blunt
The ecosystem is converging on automated versioning as the correct direction. The combination of conventional commits, semantic-release, and API diff tools in CI gets teams most of the way there. The remaining problem is granularity.
Not all breaking changes are equally breaking. Removing a function that one external consumer calls is technically a major version bump. Removing a function that's been marked deprecated for 18 months and has zero external callers is... also technically a major bump, but it's absurd to treat them equivalently. Current tools can't make this distinction without usage data, which they don't have.
Future tooling will likely incorporate usage telemetry from package managers and registries to distinguish high-impact from low-impact breaking changes. Until then, the best practice is: adopt conventional commits, add semantic-release to your CI pipeline, and add API diff checking as a breaking-change gate. You will release more major versions than before — and that is correct. It was always the right answer; you were just underreporting breaking changes.
Try it yourself
Diff Checker — compare two texts online →