Skip to content

Phase 2 · CONTAINERS & CI/CD

Versioning & releases — semver, tags, changelogs

Day 37 of 90 ~45 min 0/25 in phase Builds on Day 36

By the end of today

  • Read a version like 2.4.1 and know which part to bump
  • Cut an annotated git tag and a GitHub Release with gh
  • Write a keep-a-changelog CHANGELOG from conventional commits

Semantic versioning, tags, and a changelog

Section 1 of 5 · ~3 min

For six weeks you’ve committed code and pushed it through a pipeline. But “the latest commit” is a terrible thing to hand a user, a teammate, or a rollback script — 9f3c1a2 tells nobody what changed or whether it’s safe to upgrade. A release turns a commit into a named, meaningful point: version 2.4.1, tagged in git, with notes that say what moved. Three pieces make that work — semver, tags, and a changelog.

Semantic versioning (semver) is the near-universal grammar for version numbers: MAJOR.MINOR.PATCH, e.g. 2.4.1. Each part answers one question about what changed since the last release:

  • PATCH (2.4.0 → 2.4.1) — backward-compatible bug fixes only. Safe to take.
  • MINOR (2.4.1 → 2.5.0) — new features, still backward-compatible. Safe to take; PATCH resets to 0.
  • MAJOR (2.5.0 → 3.0.0) — a breaking change: something that worked will now break. Read the notes first. MINOR and PATCH reset to 0.

The contract is the whole point: a consumer reading 2.4.1 → 2.4.2 knows they can upgrade without touching their code, while 2.x → 3.x means “budget time.” React follows this publicly — React 18 to 19 is a major precisely because APIs were removed, while 18.2 to 18.3 was a safe minor.

Conventional commits feed the version. If every commit message starts with a type — fix:, feat:, or a feat!: / BREAKING CHANGE: marker — a tool (or a human) reads the log and knows the next bump: any fix: → PATCH, any feat: → MINOR, any ! → MAJOR. That’s how the version stops being a guess.

Real world: Semver is the nutrition label on a package. PATCH is “recipe unchanged, fixed a typo on the box” — grab it. MINOR is “now with added vitamins, same product” — still fine. MAJOR is “reformulated, may contain nuts” — you have to read it. Version numbers that ignore this are labels that lie, and everyone downstream stops trusting them.

The release flow: feat and fix commits since the last tag decide a semver bump to v0.3.0, git tag -a marks that exact commit as an annotated tag, and gh release create turns the tag into a GitHub Release — while CHANGELOG.md records the Added and Fixed changes by hand. feat: + fix: commits since v0.2.0 semver bump → v0.3.0 (minor) git tag -a v0.3.0 annotated tag gh release create GitHub Release CHANGELOG.md records Added / Fixed by hand — the human half of the release
Commits decide the bump, an annotated tag pins the exact commit, and gh turns it into a release — the changelog is the human-readable half.

Tags, a CHANGELOG, and cutting the release

A version is just a number until you pin it to a commit. An annotated git tag does that: git tag -a v2.4.1 -m "…" marks one exact commit as v2.4.1 and stores the tagger, date and message as a real git object. A lightweight tag — no -a — is just a bare pointer with none of that, which is why releases use annotated. Push it with git push origin v2.4.1 (a plain git push does not carry tags).

A CHANGELOG.md is the human half. The keep-a-changelog convention groups changes under each version heading by type — Added, Changed, Fixed, Removed — newest on top, with an Unreleased section you fill as you go. It’s what a person reads; the tag is what a machine checks out.

Finally, gh release create v2.4.1 turns the tag into a GitHub Release: a page with the notes, downloadable source archives, and optionally built artifacts attached. GitHub Releases can even generate notes from your merged PRs. Now a version number, a commit, human notes and a downloadable artifact are one thing — that’s what “cutting a release” means, and you do it below.

Hands-On Lab

Section 2 of 5 · ~3 min

Budget about 25 minutes. You need the gh CLI authenticated once (gh auth login) and a small repo pushed to GitHub — here pushkar/versioning-lab, already carrying a v0.2.0 tag and two newer conventional commits. Work in your WSL2 Ubuntu 24.04 terminal. Tags, SHAs, timestamps and URLs are unique to each repo and run — yours will differ from the samples.

# 1. Find the current released version — the newest tag.
git tag --list --sort=-v:refname
# Output:
# v0.2.0
# v0.1.0
# 2. List commits since that tag — the raw material for the bump.
git log v0.2.0..HEAD --oneline
# Output (your SHAs differ):
# 3d9f1a2 (HEAD -> main) feat: add --json output flag
# b7c4e08 fix: handle empty config file without crashing

There’s a feat: in there, so the highest bump present is a MINOR: v0.2.0 → v0.3.0. Save this as CHANGELOG.md, in keep-a-changelog format — newest version on top, changes grouped by type:

# Changelog
All notable changes to this project are documented here.
The format follows Keep a Changelog, and this project uses Semantic Versioning.

## [Unreleased]

## [0.3.0] - 2026-07-11
### Added
- `--json` output flag for machine-readable results.

### Fixed
- Empty config file no longer crashes on startup.

## [0.2.0] - 2026-06-20
### Added
- Subnet lookup subcommand.
# 3. Commit the changelog so the tag includes it.
git add CHANGELOG.md
git commit -q -m "docs: update changelog for 0.3.0"
git log --oneline -1
# Output (SHA differs):
# f4a8c60 (HEAD -> main) docs: update changelog for 0.3.0
# 4. Create the ANNOTATED tag (-a) marking this commit as v0.3.0.
git tag -a v0.3.0 -m "Release 0.3.0 — JSON output, empty-config fix"
git tag --list --sort=-v:refname | head -1
# Output:
# v0.3.0
# 5. Read the tag back — annotated tags store the tagger, date and message.
git show v0.3.0 --stat --no-patch
# Output (tagger, date and SHAs are yours-will-differ):
# tag v0.3.0
# Tagger: Pushkar <you@example.com>
# Date:   Sat Jul 11 10:22:07 2026 +0530
#
#     Release 0.3.0 — JSON output, empty-config fix
#
# commit f4a8c60...
#  CHANGELOG.md | 8 ++++++++
#  1 file changed, 8 insertions(+)
# 6. Confirm it's a real tag object, not a lightweight pointer.
git cat-file -t v0.3.0
# Output (a lightweight tag would print "commit" here instead):
# tag
# 7. Push the tag — commits and tags push separately.
git push origin v0.3.0
# Output (your repo path differs):
# To https://github.com/pushkar/versioning-lab.git
#  * [new tag]         v0.3.0 -> v0.3.0
# 8. Cut the GitHub Release from that tag, using the changelog section as notes.
gh release create v0.3.0 --title "v0.3.0" --notes "Added --json output. Fixed empty-config crash."
# Output (your URL differs):
# https://github.com/pushkar/versioning-lab/releases/tag/v0.3.0
# 9. Read the release back the way anyone else would.
gh release view v0.3.0
# Output (dates and URL are yours-will-differ):
# v0.3.0
# Pushkar released this about 1 minute ago
#
#   Added --json output. Fixed empty-config crash.
#
# No assets included in this release.
# 10. List releases; the newest is marked Latest.
gh release list
# Output (ages differ):
# TITLE   TYPE    TAG NAME  PUBLISHED
# v0.3.0  Latest  v0.3.0    about 1 minute ago
# v0.2.0          v0.2.0    about 3 weeks ago

Read the last steps back to yourself: you read the commits since the last tag, let a single feat: decide a MINOR bump to v0.3.0, wrote the changelog a human can read, pinned the exact commit with an annotated tag, pushed it, and turned it into a GitHub Release with notes and downloadable archives. A number, a commit, notes and an artifact are now one addressable thing — that is a release, and tomorrow you ship it.

Common Errors & Fixes

Section 3 of 5 · ~3 min

These three catch nearly everyone the first time they tag and cut a release. Read the error text slowly — parsing it is the actual skill.

Common error: Trying to re-use a version number that’s already tagged:

fatal: tag 'v0.3.0' already exists

Why: A tag is an immutable pointer by design, so git refuses to silently move v0.3.0 to a different commit. The message means this version was already cut — either a previous run tagged it, or two commits both tried to ship the same number without bumping it.

Fix: Never re-release a version — bump to the next one (v0.3.1 or v0.4.0) instead. If the tag is genuinely wrong and hasn’t been pushed or pulled by anyone, delete it locally with git tag -d v0.3.0 and recreate it; but once a tag is public, moving it breaks everyone who already pulled it.

How you’d spot it in prod: A release job that fails with “tag already exists” almost always means the version wasn’t incremented — a merge shipped without bumping. Check that the new version is strictly higher than the newest existing tag before re-running.

Common error: Creating the tag locally but never pushing it, so CI or a teammate can’t find the version:

fatal: couldn't find remote ref refs/tags/v0.3.0

Why: git push pushes branch commits, but it does not push tags. The tag exists on your machine and nowhere else, so anything that tries to check out v0.3.0 — a tag-triggered deploy, a fresh clone, a colleague — asks the remote for a ref that isn’t there.

Fix: Push the tag explicitly with git push origin v0.3.0 (or git push --tags to send all of them). Confirm it landed with git ls-remote --tags origin, which lists exactly the tags the remote knows about.

How you’d spot it in prod: A release-on-tag pipeline that never fires, or a deploy that can’t check out the version it was told to ship, while the tag is plainly visible in your local git tag list. The tell is that the tag is local-only — it was never pushed.

Common error: Using a lightweight tag (no -a) and then having a build script’s version lookup fail:

fatal: No annotated tags can describe '3d9f1a2b8e7d6c5f4a3b2c1d'.
However, there were unannotated tags: try --tags.

Why: git describe — which build scripts use to derive a version string like v0.3.0-2-g3d9f1a2 — only considers annotated tags by default. A lightweight tag created with a bare git tag v0.3.0 has no tag object, so describe ignores it and errors when no annotated tag is reachable.

Fix: Create release tags with git tag -a (annotated) so they carry a tag object, message and date. If you’re stuck with a lightweight tag, git describe --tags will include it, but you lose the tagger, date and message a release should record — re-tag properly instead.

How you’d spot it in prod: A binary or container whose --version prints a bare commit SHA instead of v0.3.0, or a build that can’t derive its version at all. The cause is a lightweight tag where an annotated one was needed.

Versioning Interview Questions

Section 4 of 5 · ~1 min

Versioning and release hygiene come up the moment an interviewer moves from “can you write a pipeline?” to “how do you ship it safely?” — a calm answer that explains the semver contract and why tags are immutable beats reciting commands. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~25 more minutes today:

  • 5 min — Skim the Semantic Versioning spec summary at the top and read just the MAJOR/MINOR/PATCH rules — the whole standard fits on one page and you’ll reference it for years.
  • 10 min — Read keepachangelog.com and note the guiding principle: a changelog is for humans, grouped by change type, not a raw git log dump. Then re-read the CHANGELOG.md you wrote against it.
  • 10 min — Re-run the release with gh release create v0.3.1 --generate-notes on a throwaway tag and read the notes GitHub builds from your merged PRs — the automated half of the notes you wrote by hand today.
What is semantic versioning, and what do MAJOR, MINOR and PATCH mean? Both

Semantic versioning is a three-part number, MAJOR.MINOR.PATCH — for example 2.4.1 — where each part signals what changed since the last release. You bump PATCH for backward-compatible bug fixes, so 2.4.1 to 2.4.2 is always safe to take. You bump MINOR for new features that don't break anything, and PATCH resets to zero. You bump MAJOR for a breaking change — something that used to work now won't — and both MINOR and PATCH reset. The value is the contract: a consumer reads the number and knows whether upgrading is safe or needs care. React uses it publicly — 18 to 19 is a major because APIs were removed.

What's the difference between a lightweight and an annotated git tag, and which do you use for a release? Both

A lightweight tag is just a name pointing at a commit — no extra data. An annotated tag, created with git tag -a, is a full git object that also stores who tagged it, when, and a message, and it can be signed. For releases you always want annotated: the tagger, date and message are part of the record, and git describe — which build scripts use to derive a version string — only considers annotated tags by default. A lightweight tag makes git describe fail or fall back to a bare SHA. Lightweight tags are fine for a quick private bookmark, but anything you release or ship should be annotated and pushed.

Why cut a tagged release instead of just deploying the latest commit? Product

A commit SHA like 9f3c1a2 tells nobody what changed or whether upgrading is safe, and it's easy to lose. A release turns one exact commit into a named, meaningful point: a version number, an annotated tag pinning the commit, human-readable notes, and downloadable artifacts. That gives you three things deploying a raw commit doesn't — traceability, because the version maps to an exact commit and its changelog; communication, because release notes tell users what moved; and clean rollbacks, because 'roll back to v0.2.0' is unambiguous where 'roll back a few commits' isn't. It's the difference between shipping something you can talk about and reason about versus shipping an anonymous checkpoint.

How do conventional commits relate to the version number? Both

Conventional commits put a type at the front of every commit message — fix:, feat:, or a feat!: / BREAKING CHANGE: marker — so the git log itself tells you the next version. The rule maps cleanly to semver: any commit since the last tag that's a fix: means at least a PATCH bump, any feat: means a MINOR bump, and any breaking-change marker means a MAJOR bump. You take the highest one present. That's what lets tools like semantic-release or release-please compute and tag the version automatically, and it's why a changelog can be generated from the log. Even by hand, it turns 'what's the next version?' from a judgment call into reading the commit types.

Mark Day 37 complete

Tomorrow you take a tagged release and actually ship it — rolling, blue-green and canary deploy strategies, and when each one earns its keep.

Stuck on today’s lab? Ask in Mission 90 Q&A