open atlas
↑ Back to track
CI/CD pipelines CICD · 12 · 01

Versioning and tags: semver, conventional commits, and the immutable release ref

Semver encodes a compatibility contract: major breaks, minor adds, patch fixes. Conventional commits let CI derive the bump automatically. An annotated, immutable git tag plus a content digest is the auditable ref a release points at — and the thing rollback re-deploys.

CICD Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

The bump looked harmless: 2.4.12.5.0, a minor release, “just a new optional field on the response.” Forty minutes after the deploy, three downstream teams paged at once — their clients deserialized the response into a strict struct that rejected unknown fields, and every request now 500’d. The change was not additive in practice; it was a breaking change wearing a minor’s clothes. The release engineer had hand-typed the version into a file, picked “minor” by gut, and shipped. Nobody could answer the only question that mattered during the rollback — “what is the last good version?” — because the tag history was a soup of v2.5.0, v2.5.0-hotfix, release-2, and a latest that had been force-pushed twice. The fix that stuck was not a better gut: it was making the machine decide the bump from the commit history, and making every release point at one immutable ref nobody could move.

Semver is a contract, not a counter

Before you pick the next version number, ask yourself: “If a consumer upgrades without reading the changelog, will anything break for them?” The answer decides the bump. A version MAJOR.MINOR.PATCH is a promise to whoever depends on you, read right to left:

  • PATCH (2.4.12.4.2): backward-compatible bug fix. A consumer can take it blind.
  • MINOR (2.4.12.5.0): backward-compatible new functionality. Old callers keep working; new callers get more.
  • MAJOR (2.4.13.0.0): a breaking change. Something that worked before now does not — a removed field, a changed default, a stricter validation, a renamed endpoint.

Together these three levels mean any downstream team can read one number and know their upgrade risk without reading the diff — which is exactly why the bump must be honest.

The Hook’s bug was a category error: the team called an added field “minor,” but their consumers’ strict deserialization made any new field breaking. The lesson is that the bump is defined by the consumer’s compatibility surface, not the size of the diff. A one-line change can be a major; a thousand-line internal refactor with an unchanged public API is a patch. Pre-1.0.0 the contract is explicitly weaker — 0.y.z says “anything may break” — which is why libraries that ship 0.x for years are quietly opting out of the guarantee. Build metadata (+build.5) and pre-release tags (-rc.1, -beta.2) extend the grammar: 2.5.0-rc.1 < 2.5.0, and pre-releases sort before their final, so a release candidate never accidentally outranks the GA it precedes.

Quiz

A change removes one field from a JSON response that no current client reads, but it is technically a removal. Some consumers use strict deserialization that rejects unknown fields; none read the removed field. What is the correct semver bump, and why?

Conventional commits: let the machine compute the bump

If the bump is supposed to encode the consumer’s compatibility risk, why let a human guess it under time pressure? Hand-picking the bump is exactly the human judgment that failed in the Hook. Conventional commits move the decision into the commit message, where it is auditable and mechanical. Each commit’s subject carries a type:

feat: add pagination cursor to /orders     → MINOR
fix: stop double-charging on retry          → PATCH
refactor: extract the rate limiter          → no release
feat!: drop the v1 auth header              → MAJOR  (the ! marks a break)
feat: switch storage backend

BREAKING CHANGE: the on-disk format changed;
run the v3 migration before deploying.       → MAJOR (footer also triggers it)

A release tool (semantic-release, release-please, changesets) scans the commits since the last tag, takes the highest bump implied by any of them — one feat! anywhere makes the whole release a major — computes the next version, writes the changelog, and creates the tag in CI. The version is now a deterministic function of history, not a field someone edits. The discipline this demands is real: the bump is only as honest as the commit types, so feat! and BREAKING CHANGE: have to be enforced (commit-lint on PRs, squash-merge with a curated subject) or the automation cheerfully ships a major as a patch.

Quiz

Since the last tag, the commit log contains: three `fix:` commits, one `feat:` commit, and one `feat!:` commit. What version bump does a conventional-commits release tool compute from `2.4.1`?

The tag is the immutable ref

A semver string is a label; the git tag is what a release actually points at, and its discipline is what made the Hook’s rollback impossible. Two rules carry the weight. First, prefer an annotated tag (git tag -a v2.5.0 -m ...), which is a real object carrying tagger, date, and message — not a lightweight tag, which is a bare pointer with no metadata or audit trail. Second, a release tag is immutable: once v2.5.0 is pushed and a release is cut from it, you never move it. If v2.5.0 is broken you ship v2.5.1; you do not force-push v2.5.0 to new bytes, because anyone who already pulled v2.5.0 would now disagree with you about what that version is — the exact latest-force-pushed-twice chaos from the Hook. The version tag is a human-friendly alias; the load-bearing identity is the content digest (@sha256:...) of the built artifact, which is content-addressed and cannot be overwritten. A release record in GitHub Releases ties the three together — tag, semver name, and the digest of what shipped — so the answer to “what is the last good version?” is a lookup, not an archaeology dig. Rollback then is mechanical: re-deploy the immutable ref (the prior tag’s digest) that the release record names, and you are byte-for-byte back to known-good.

Why this works

Why pin the digest when the tag already names the version? Because a tag is a mutable pointer and a digest is content-addressed. If your deploy says “ship v2.5.0” and someone (a bad CI job, a force-push, a re-tag) moves v2.5.0 to different bytes, your rollback re-deploys the wrong artifact under a name you trust — the failure from the Docker world where re-deploying latest re-ships the bug. Pinning @sha256:... means the release record points at bytes that can never change, so “re-deploy the last good release” returns to exactly what was tested, not to whatever the tag now resolves to.

Recall before you leave
  1. 01
    Why is the semver bump defined by the consumer's compatibility surface rather than the size of the code change, and give an example where a tiny diff is a major?
  2. 02
    Walk through how conventional commits turn commit history into a version, and what makes the result trustworthy enough to automate.
Recap

A version number is a contract read right to left: PATCH is a backward-compatible fix a consumer can take blind, MINOR is a backward-compatible addition that leaves old callers working, and MAJOR is a break — a removal, a changed default, a stricter rule — defined by the consumer’s compatibility surface, not the diff size, which is why a one-line field removal is a major and a huge internal refactor is a patch. Pre-1.0 explicitly weakens the guarantee. Hand-picking the bump is the human judgment that fails; conventional commits move it into the message (feat → minor, fix → patch, feat! or BREAKING CHANGE: → major), and a release tool takes the highest bump across all commits since the last tag, computes the next version deterministically, writes the changelog, and creates the tag in CI — honest only if the commit types are enforced. The release points at an annotated, immutable git tag: once cut you ship v2.5.1 rather than force-push v2.5.0, because moving a tag makes everyone who pulled it disagree about what the version is. The load-bearing identity under the tag is the content digest (@sha256:...), which is content-addressed and unmovable, and a GitHub release record ties tag, name, and digest so “what is the last good version?” is a lookup. Rollback is then mechanical: re-deploy the prior release record’s digest and you are byte-for-byte back to known-good — which only works because the tag was immutable all along. Now when you see a version bump chosen by gut during an incident, you know the exact failure mode — and the exact fix.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.