Version numbers are a promise to the people and systems that depend on your software. Semantic Versioning (SemVer) makes that promise explicit: from the number alone, users can tell whether an upgrade is safe.
The format
MAJOR.MINOR.PATCH e.g. 2.4.1
- MAJOR — incompatible changes (breaking an API, removing a feature, changing behaviour users rely on).
- MINOR — new, backward-compatible functionality.
- PATCH — backward-compatible bug fixes.
Examples:
| Change | 2.4.1 → |
|---|---|
| Fix a crash | 2.4.2 |
| Add an optional API field | 2.5.0 |
| Remove an endpoint, rename a config key | 3.0.0 |
0.x versions signal “still unstable”: anything may change between minor versions. Releasing 1.0.0 means you’re committing to the public API.
Pre-releases and build metadata
3.0.0-alpha.1 < 3.0.0-beta.2 < 3.0.0-rc.1 < 3.0.0
1.4.0+build.517 # metadata, ignored for ordering
What counts as “breaking”?
Define your public API — HTTP endpoints, CLI flags, configuration files, library functions, database migrations users run. Breaking any documented behaviour is a major change. Internal refactors that don’t affect the public surface are patches (or minors if they add features).
For web applications deployed only by you, strict SemVer matters less, but a consistent scheme still helps: it labels deployments, links errors to versions, and makes rollbacks clear.
Tag releases in Git
git tag -a v2.5.0 -m "Release 2.5.0"
git push origin v2.5.0
Annotated tags store author, date and message. CI can build and publish artifacts automatically on tag pushes:
# GitHub Actions
on:
push:
tags: ['v*.*.*']
Keep a changelog
A human-readable CHANGELOG.md, grouped by release:
## [2.5.0] - 2026-11-04
### Added
- Export orders as CSV.
### Fixed
- Timezone bug in reports.
### Deprecated
- `/api/v1/legacy-export` (removal in 3.0).
Users read changelogs to decide when to upgrade; you read them when debugging “what changed?”.
Automating versions with Conventional Commits
Conventional Commits encode the change type in commit messages:
feat: add CSV export → minor
fix: correct report timezone → patch
feat!: remove legacy export → major (or "BREAKING CHANGE:" in the body)
Tools like release-please, semantic-release or changesets read these commits, calculate the next version, update the changelog, tag the release and publish it — releases become a pull-request merge.
Docker image tags
Publish several tags per release so users choose their update policy:
myapp:2.5.0 # exact, never changes
myapp:2.5 # latest patch of 2.5
myapp:2 # latest minor of 2
myapp:latest # whatever is newest (avoid in production)
Plus an immutable tag per commit (myapp:sha-a1b2c3d) for deployments. Consumers pinning myapp:2 get fixes and features without surprise breaking changes. See Docker image tags best practices and how this interacts with Watchtower.
Dependencies: version ranges
Package managers use SemVer to decide what to install:
^2.4.1(npm) —>=2.4.1 <3.0.0~2.4.1—>=2.4.1 <2.5.0"2.4.1"— exactly that version
Commit lockfiles (package-lock.json, composer.lock, poetry.lock) so builds are reproducible regardless of ranges, and let tools like Renovate or Dependabot propose updates.
Deprecation policy
Before removing something in a major version:
- Mark it deprecated in a minor release (docs, changelog, warnings in logs or response headers).
- Give users a migration path and time.
- Remove it in the next major release.
Alternatives
- CalVer (
2026.11.0) — date-based versions, common for applications and distributions (Ubuntu 24.04) where “compatibility” is less meaningful than “how recent”. - Build numbers / commit SHAs — fine for internal services deployed continuously.
Related: Git tags and releases and CI/CD pipelines explained.