Semantic Versioning, and Where It Stops Applying
A version number is a compatibility promise to consumers who upgrade on their own schedule — and for a continuously deployed internal service there are none, so a build number is the honest answer.
The question, the obvious approach, and why it breaks
Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.
What does a version number promise, who is it a promise to, and what should an internal service use instead?
Versioning is treated as a universal practice, so teams apply a scheme designed for independently-upgrading consumers to artifacts that have none, and spend real effort on a number nobody reads.
Everything gets MAJOR.MINOR.PATCH. Bug fix bumps patch, new feature bumps minor, breaking change bumps major. It is the standard, it is unambiguous, and it applies to every artifact we build.
The scheme is a protocol between a publisher and consumers who choose when to upgrade. An internal service deployed continuously has exactly one deployment of itself, chosen by the same team that built it. There is no consumer making an upgrade decision, so the number carries no information.
- The scheme is a protocol between a publisher and consumers who choose when to upgrade. An internal service deployed continuously has exactly one deployment of itself, chosen by the same team that built it. There is no consumer making an upgrade decision, so the number carries no information.
- The compatibility judgement is made by the publisher, from the inside, about usage they cannot see. "This is a minor change" is a guess about every consumer's code path, and the guess is wrong often enough that major-version discipline is mostly reputational.
- Consumers depend on observable behaviour rather than on the documented contract — timing, error text, iteration order, an undocumented field. A change that is minor by the contract is breaking in practice, and the number said it was safe.
- On a service deployed many times a day, the numbers become meaningless quickly: a sequence of patch bumps that correspond to nothing anyone reasons about, or a version that is bumped by a bot and never read.
0.xexplicitly suspends the rules, and a large fraction of real-world packages live there permanently, which means the guarantee people rely on is frequently not being offered at all.- Two different meanings get crammed into one number: "what compatibility do I promise" and "which build is this". They have different consumers and different lifetimes, and merging them loses both.
What is actually happening
Underneath the tooling, which is the part that survives a change of tool.
- A version is a claim about substitutability: can a consumer replace what they have with this, without changing their code? Semantic versioning encodes the answer in three positions — no (major), yes and there is more (minor), yes (patch).
- That claim is only worth encoding when three things are true: consumers exist who upgrade independently, they can express which versions they accept, and the publisher can actually distinguish breaking from non-breaking. Remove any one and the number degenerates into decoration.
- A build identifier answers a different question: which build is this? It needs to be unique, monotonic and resolvable to a commit. It makes no compatibility claim at all, which is exactly why it never lies.
- For a continuously deployed service, compatibility is not expressed in a number — it is enforced at the interfaces. Old and new must coexist during a rollout, so compatibility is a property of the API and the schema, checked by tests and by rollout design (Version Coexistence: N and N+1, in Both Directions).
- The two can coexist without confusion as long as each is used for its own audience: a semantic version for artifacts others depend on, a build identifier for artifacts you deploy.
What to number, and with what
The question is not which scheme is better. It is what the number is for, and there are only two real answers: to tell a consumer whether they can upgrade, or to tell an operator which build is running.
Who reads this artifact's version number, and what do they do with it?
when Independent consumers upgrade on their own schedule and express version constraints — public libraries, SDKs, CLIs, shared internal packages across teams that pin.
cost Every change needs a compatibility judgement, backed by tests that can actually fail on a break. Published versions cannot be recalled.
when The artifact is deployed rather than depended on: a continuously deployed service, a job image, an internal tool with one deployment.
cost Communicates nothing about compatibility, so compatibility has to be handled at the interface and enforced by rollout design.
when Consumers care mainly about recency and support windows — base images, platform releases, distributions.
cost Says nothing about breaking changes; consumers must read release notes rather than a range constraint.
when Small internal artifacts where the only question ever asked is "which commit is this".
cost Not ordered, so "is this newer" needs a lookup rather than a comparison. Pair it with a build number if ordering matters.
when Never, unless something genuinely consumes it as a dependency.
cost Real effort spent producing a number with no reader, and the false impression that compatibility has been considered.
The claim, the reader, and what actually enforces it
A version number never enforces anything by itself. Reading the last column is the fastest way to tell whether your scheme is a guarantee or a habit.
This is also where the service case becomes obvious: for a deployed service, every enforcement mechanism that matters lives at the API and schema level, and none of them consult the version number.
| Artifact | What the number claims | Who reads it | What enforces the claim |
|---|---|---|---|
| Public library | Substitutability within a major | Dependency resolvers, package authors | Compatibility tests, sometimes an API-diff tool |
| Internal shared package | Same, if teams pin independently | Other teams' lockfiles | Tests, plus a support conversation |
| Public HTTP API | The contract of a versioned surface | Client developers | Contract tests and a deprecation window (Backward Compatibility: The Real Rules in API terms) |
| Continuously deployed service | Nothing — it is a build identity | Operators, during incidents | Rollout design: old and new must coexist (Version Coexistence: N and N+1, in Both Directions) |
| Container base image | Recency and a support window | Dockerfile authors | The publisher's own policy, which you should pin against anyway |
| Database schema | The migration ordering | The migration runner | Expand/migrate/contract, not the number (Expand, Migrate, Contract) |
Two version strings, one artifact
The practical resolution is not to choose. It is to stop asking one string to do two jobs: let the compatibility claim be a compatibility claim, and let the identity be an identity.
For a service, the second column is the whole answer, and the absence of the first is a feature rather than an omission.
checkout-service 2.4.7 "2" -> nobody consumes it, so nothing depends on the major "4" -> bumped when someone remembers "7" -> bumped by a bot on every merge deployed as checkout:2.4.7 (a mutable tag) rollback: "go back to 2.4.6" -> resolves to whatever that is now
checkout-service build 4821 (commit a1b2c3d) deployed as checkout@sha256:9f3e... compatibility handled at the API and schema level rollback: the previous digest, byte-identical to what ran checkout-client 2.4.7 (published; consumers pin ^2) major bump means: your code may need changes
The left column spends effort maintaining a compatibility promise nobody consumes while leaving the identity question — which bytes are running — unanswered. The right column answers the identity question exactly and reserves the compatibility promise for the artifact that actually has consumers making an upgrade decision.
How to do it properly
Most important first.
- Use semantic versioning for artifacts with independent consumers: published libraries, SDKs, CLI tools, shared internal packages, and anything with a deprecation path (Deprecation as a Process, Not a Label in API terms).
- Use a monotonic build identifier for continuously deployed services — a build number, a date-ordered string, or simply the commit SHA. Make sure it resolves to a commit and appears in logs and on the version endpoint (From Developer to Users).
- Never encode a compatibility promise you do not enforce. If you claim semver, you need tests that fail on a breaking change, or the claim is a habit rather than a guarantee.
- Express service compatibility where it lives: in the API contract and the database schema, versioned explicitly and verified during rollout (Expand, Migrate, Contract).
- Whatever the scheme, make the artifact identity separate from it. The version is a label; the digest is the identity (Tags Versus Digests).
- Write down which of your artifacts are versioned for consumers and which are numbered for you. The ambiguity, not the scheme, is what causes arguments.
How much can this affect
Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.
For a published artifact, nothing contains a wrong compatibility claim — consumers act on it automatically, and version ranges spread the change to everyone who resolves it. For an internal service the blast radius of the number itself is nil; the deployment is what carries risk.
What can go wrong
- A major bump treated as a milestone announcement rather than as a compatibility statement, so consumers stop trusting the signal.
- A minor bump that breaks consumers, discovered by them, which converts a version scheme into a support burden.
- Version numbers maintained by hand, so two artifacts from the same pipeline disagree, or a release is published with the previous version because someone forgot the bump.
- A service with an elaborate semver scheme and no consumers, where the effort is real and the benefit is zero.
- Version-based rollback assumptions: "roll back to 1.4.2" is a name, and names resolve. The rollback target has to be an identity (Tags Versus Digests).
- The mitigation failing: automated semver from commit messages, which converts the compatibility judgement into a formatting convention and produces confident major bumps for a typo in a comment.
- "Semver is best practice, so everything should use it." It is a protocol with prerequisites. Applied where the prerequisites are absent, it costs effort and communicates nothing.
- "A patch release is always safe to take." It is safe according to the publisher's judgement about usage they cannot observe. Treat it as a strong hint, not a guarantee.
- "We deploy continuously so versioning does not matter." Identity matters enormously; compatibility numbering does not. Those are different things, and conflating them is how services end up with no version endpoint at all.
- "The version number is the artifact identity." It is a label attached to an identity. Two builds can carry one version, which is the entire subject of Tags Versus Digests.
- "Bumping major is a failure of design." Sometimes it is the honest thing to do. A publisher who never bumps major is often one who breaks consumers in minor releases instead.
Operating it
Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.
- For each artifact you publish, you can name who consumes it and whether they choose when to upgrade. If nobody does, the version is decoration.
- Consumers pin or range-constrain the version, which means the number is actually being read by something.
- A deliberately breaking change fails a compatibility test before it fails a consumer.
- The running service reports an identifier that resolves to a commit, whatever scheme produced it.
- Rolling back a published version is largely impossible: consumers may already have fetched it, and most registries refuse deletion. The remedy is publishing a new version, plus a deprecation or yank marker where the registry supports one.
- That asymmetry is the strongest argument for reserving semantic versions for things that genuinely need them. A build number for an internal service can be superseded in minutes; a published
2.0.0is permanent. - For services, rollback is by artifact identity and is unaffected by whatever numbering scheme is in use.
- Automate build identifier assignment completely — a build number or commit SHA should never be typed by a person.
- Automate the mechanical parts of a semantic release: changelog assembly, tagging, publishing.
- Do not automate the major/minor judgement. Deciding whether a change is breaking requires knowing how consumers use the thing, which is exactly the knowledge a commit-message convention does not contain (The Automation Trap).
- Semantic versioning costs discipline: someone must judge every change, and the guarantee is only as good as the tests behind it. Where consumers exist, that cost buys them the ability to upgrade without reading a diff.
- Build numbers give up the ability to communicate compatibility at all, which is fine when there is nothing to communicate it to and unacceptable for a public library.
- Running both schemes for different artifact classes is more explanation for new engineers than running one, and it is honest, which is the better trade.
Where this applies
This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.
- GENERALThe prerequisites for a compatibility number — independent consumers, expressible constraints, a publisher who can tell breaking from non-breaking — are language- and ecosystem-independent. Where they hold, semantic versioning works; where they do not, no scheme rescues it.
- ORG-SPECIFICWhich artifacts count as "published" is an organisational boundary, not a technical one. In a monorepo with synchronised deploys, an internal library has no independent consumers and needs no compatibility number; in an organisation where teams pin dependencies and upgrade quarterly, that same library does. Decide it explicitly rather than by which template the service was generated from.
- TOOL-SPECIFICEcosystems differ in what they enforce. Some registries forbid republishing a version and support yanking; some resolve floating ranges by default while others require a lockfile; some languages have tooling that detects breaking API changes mechanically. What semver costs you depends heavily on which of those you have.
Where the depth lives
This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.
- — Testing & Reliability Engineering — a compatibility claim is only as strong as the test that would fail if it were false.