Semantic versioning (SemVer) is a version-numbering scheme that labels each release as MAJOR.MINOR.PATCH - bumping MAJOR for breaking changes, MINOR for backward-compatible features and PATCH for backward-compatible bug fixes. It gives consumers a predictable, machine-readable contract for what an upgrade will and will not break, so tooling and humans can reason about compatibility without reading changelogs.
How does semantic versioning work?
Semantic versioning is a three-number contract. Every release carries a version
of the form MAJOR.MINOR.PATCH, and each position has one job:
- MAJOR is incremented when you make an incompatible change - the sort of change that would break a caller who upgrades without reading the release notes. Removing a public function, renaming a field in a JSON response, changing an error type, dropping a supported language runtime: all major.
- MINOR is incremented when you add functionality in a backward- compatible way. New endpoints, new optional parameters, new configuration keys that default to today's behaviour.
- PATCH is incremented for backward-compatible bug fixes - the caller sees no new surface, only correct behaviour where before it was wrong.
The rules cascade. Bumping MAJOR resets MINOR and PATCH to 0; bumping MINOR
resets PATCH to 0. So the sequence after 1.4.2 looks like 1.4.3 (fix),
1.5.0 (feature), 2.0.0 (breaking change) - never 1.4.3 -> 1.4.10 -> 2.0.5
carrying old digits along.
Two extra slots exist for the edges of a release cycle:
- Pre-release identifiers hang off the version with a hyphen -
2.0.0-rc.1,2.0.0-beta.3,2.0.0-alpha. These sort before the bare2.0.0, so a package manager solving^2.0.0will not accidentally install a beta. - Build metadata hangs off with a plus -
1.4.2+20260705.git.abc123- and is ignored for precedence. It is a label, not part of the version identity, and is useful for embedding a commit SHA or build timestamp without changing what the resolver sees.
The whole scheme is codified in SemVer 2.0.0, a
short specification (14 rules) that most modern package ecosystems -
npm, Cargo, Go modules, NuGet, Composer, pub, Helm - either
adopt directly or match closely.
Why does semantic versioning matter?
The value of SemVer is not aesthetic. It exists because dependency resolvers need a rule they can execute mechanically, and humans need a shorthand for "is this upgrade safe?"
- Range constraints become tractable. When
package.jsonpins"lodash": "^4.17.0", the resolver knows it can accept any4.x.ythat is>= 4.17.0and< 5.0.0. Without SemVer's contract,^and~ranges are guesswork; with it, they are a compatibility guarantee that a machine can verify. - Upgrade risk gets triaged in seconds. A developer looking at a
1.4.2 -> 1.4.7bump does not need to open the diff to know the shape of the risk: by contract, it is a bug fix. A1.4.2 -> 2.0.0bump does need the diff - by contract, the author is telling you something broke. That signal is the whole point. - Automated tooling has a hook. Dependabot, Renovate, security scanners, and reproducible-build systems all rely on the version to decide "is this a safe auto-merge?" versus "hold this for a human." Without a versioning contract, they either raise noise on every bump or silently miss breaking changes.
- Rollbacks become explicit. If
1.4.2misbehaves in production, you can pin to1.4.1and know exactly what surface you are getting back. With ad-hoc versioning (build-2381,release-tue), rollback becomes archaeology.
The failure mode SemVer prevents is the silent breaking change - the patch
release that renamed a field, the "minor" upgrade that dropped Node 18. Every
mature ecosystem has been burned by one, and the shared discipline of
"breaking = MAJOR, always" is what makes ranges like ^1.0.0 safe to write
at all.
SemVer vs CalVer vs ZeroVer
Three schemes dominate the wild, and they are optimised for different questions.
- SemVer (
1.4.2) answers "will this upgrade break me?" Best for libraries, SDKs and anything consumed by another codebase's dependency resolver. - CalVer (
2026.07.0,26.7.0,2026.7) answers "when was this shipped?" Best for applications and rolling-release products (Ubuntu, JetBrains IDEs,pip, Rust nightly) where the concept of a "breaking change" is fuzzy because there is no public API to break. - ZeroVer (
0.x.yforever) is the joke-with-a-point convention (Terraform historically, many young libraries) that says "I refuse to promise stability." Legitimate for genuine pre-1.0 work; a smell when a project is production-critical and has been on0.xfor five years.
Some projects hybridise: a service ships CalVer externally (2026.07.0) but
tags its internal client library with SemVer, so downstream code can still
pin ^3.2.0. That is a perfectly reasonable pattern - pick the scheme by the
question the consumer needs to answer, not by tribal loyalty.
Where does SemVer get misused?
Three anti-patterns show up over and over:
- The permanent
0.x. A project stays at0.99.14for years so the maintainers "don't have to commit to compatibility." In practice they do commit - every user'spackage.jsonpins a specific0.x, and breaking changes still hurt. Cut1.0.0when real users depend on you. - The vanity major. Bumping to
2.0.0for a marketing moment even when nothing broke. This trains consumers to ignore major bumps ("probably just a rebrand") and defeats the signalling purpose of the scheme. - The stealth breaking patch. Slipping a "small" incompatible fix into a patch release because it is "obviously" the correct behaviour. Even if the old behaviour was a bug, someone depended on it - by SemVer's contract, the fix is a MAJOR bump. Ship it as one.
The through-line: SemVer only works when everyone treats the contract as non-negotiable. As soon as MAJOR becomes optional, dependency ranges become lies, and the whole compatibility fabric of the ecosystem frays.
How do popular CI/CD tools handle semantic versioning?
Modelling SemVer in a CI/CD tool comes down to three moves: derive the version, tag the artifact with it, and promote the same artifact under that version through environments. Every mature platform can do it, but the ergonomics differ.
- GitHub Actions has the richest SemVer ecosystem. Actions like
paulhatch/semantic-version,google-github-actions/release-pleaseandsemantic-releasederive the next version from conventional commits or labels, cut a release, publish to npm/PyPI/GHCR, and open the changelog PR - all wired together withon: push. If your workflow already lives on GitHub, the friction is genuinely close to zero. - GitLab CI ships the
semantic-releasetemplate and pairs it with the built-in Package Registry, so1.4.2can be a versioned Composer, Maven, npm or generic package inside the same project. Its release CLI creates a GitLab Release object linked to the tag, which is convenient for compliance trails. - Jenkins stays flexible: shared libraries plus the
semantic-releasenpm CLI give you the same behaviour, but you assemble the pieces. Fine if you already have Jenkins expertise; heavier if you are starting fresh. semantic-release(the standalone tool) is the better fit if strict conventional-commits-driven releases are the whole point of your workflow - it reads commit messages, decides the bump automatically, writes the changelog and publishes, and it plugs into every CI. Nothing built into a platform matches its opinionated commit-to-release loop.- Argo CD / Flux don't do versioning themselves, but they honour it: an
image tag
web:1.4.2in a Kustomize overlay reconciles into the cluster, and Image Updater can watch for new SemVer-matching tags automatically. If you already live in GitOps, keep doing that. - Buddy
is one of the recommended options when you want the version, the artifact
and the routing to move together in one pipeline. Buddy artifacts are
addressed as
name:version, so a pipeline can compute the next SemVer (from git tags,package.jsonor a bump script), publishweb:$VERSIONwithbdy artifact publish, and flip a distribution route to that exact version - all in the same YAML. The concrete win is that "publish version" and "serve version" are the same identifier end-to-end, which keeps the SemVer contract intact from build through traffic.
Honest read: if you already use semantic-release and it fits, don't switch
- it is the deeper tool for pure release automation. Buddy's value here is consolidation: fewer moving parts when the version, the artifact registry and the domain routing all live in one place.
Example
This Buddy pipeline computes the next SemVer from the latest git tag on main,
publishes an artifact under that version, and - only if a smoke test passes -
routes the production domain at exactly that version. The version, the
artifact and the traffic are one identifier.
# .buddy/buddy.yml - derive SemVer, publish, route
- pipeline: "release-semver"
events:
- type: PUSH
refs:
- "refs/heads/main"
actions:
- action: "Compute next SemVer"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
# read commit range since the last v* tag and decide MAJOR/MINOR/PATCH
npx --yes conventional-recommended-bump -p angular > .bump
PREV=$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || echo v0.0.0)
NEXT=$(npx --yes semver -i $(cat .bump) ${PREV#v})
echo VERSION=$NEXT >> $BUDDY_PIPELINE_ENV
echo Building v$NEXT from $PREV
npm ci && npm run build
- action: "Publish artifact as web:$VERSION"
type: "BUILD"
docker_image_name: "ubuntu"
docker_image_tag: "22.04"
commands: |-
bdy artifact publish web:$VERSION ./dist --create
- action: "Smoke test staging at web:$VERSION"
type: "BUILD"
docker_image_name: "ubuntu"
docker_image_tag: "22.04"
commands: |-
bdy distro route update prod-distro --subdomain=staging --target=artifact=web:$VERSION
- action: "Verify /healthz"
type: "HTTP"
notification_url: "https://staging.example.com/healthz"
method: "GET"
- action: "Tag git and route production"
type: "BUILD"
docker_image_name: "ubuntu"
docker_image_tag: "22.04"
commands: |-
git tag v$VERSION && git push origin v$VERSION
bdy distro route update prod-distro --domain=example.com --target=artifact=web:$VERSION
The MAJOR/MINOR/PATCH decision is derived from the commits (via conventional commits), the artifact is stored under that exact number, and the route that serves users points at the same identifier. If the smoke test fails, no git tag is pushed and production keeps serving the previous version - the SemVer contract stays honest end-to-end.
Frequently asked questions
What version does a SemVer project start on?
SemVer 2.0.0 says any release under `0.y.z` is "initial development" - the public API is not stable and anything can change between minor bumps. The moment you promise stability to real consumers, cut `1.0.0`. Staying on `0.x` forever is a common way to dodge the discipline SemVer is meant to enforce.
When exactly do you bump the MAJOR version?
Whenever a change would break a caller that upgrades without reading the changelog: removing or renaming a public function, changing a signature, tightening validation, altering error semantics, dropping a supported runtime. If a downstream `^1.4.0` dependency range would produce a surprise, the change is major.
Is a Git tag the same as a SemVer version?
No. The Git tag (typically `v1.4.0`) is a pointer to a commit; the SemVer version is a claim about compatibility of the artifact built from that commit. Two builds from the same tag are the same version; a rebuild with different dependencies from an untagged commit is not.
Does SemVer apply to applications or only libraries?
SemVer was designed for libraries with a public API. Applications and services often adopt CalVer (calendar versioning like `2026.07.0`) or "ZeroVer" instead, because there is no downstream `package.json` to protect. Use SemVer where compatibility matters to another team's build; use CalVer where "when was this shipped?" matters more.
Suggest a new word or an edit to an existing one. Every submission is reviewed before it goes live.