Tags, Releases, and Versioning
Cut a release that is reproducible a year later. Annotated versus lightweight tags, pushing and never moving tags, git describe, semantic versioning, and changelogs from history.
Cut a release that is reproducible a year later. Annotated versus lightweight tags, pushing and never moving tags, git describe, semantic versioning, and changelogs from history.
Someone opens a bug report against version 1.2.0 of your project, which shipped eleven months ago. Can you build exactly that code again, byte for byte? Can you tell them what changed between 1.1.0 and 1.2.0 without reading a year of commits?
By the end of this lesson you can. You will cut a release that is still reproducible next summer, stamp your builds with a version string derived from history itself, and generate a changelog from your commit log instead of from memory.
A branch is a moving pointer. Every commit you make on main
drags main forward one step. That is exactly what you want while
you are working, and exactly what you do not want when you are
pointing at a shipped release.
A tag is a name pinned to one specific commit, and it stays there. Think of a book you are reading: the bookmark is a branch, sliding forward every night. A tag is the sticky note stuck to page 47 — the page never changes, and neither does the note.
That single property makes releases reproducible. "Build v1.2.0"
resolves to the same commit today, next month, and after four
hundred more commits have landed on main.
git tag # list every tag in this repo
git tag -l "v1.*" # only tags matching a patternGit has two kinds of tag, and the difference is not cosmetic.
A nickname, and nothing more.
A file in .git/refs/tags/ containing a commit hash. No
author, no date, no message.
git describe ignores these unless you pass --tags.
A record of a decision.
A real object in Git's database: the commit, plus who created the tag, when, and why — and it can be cryptographically signed.
That is what makes an audit trail possible.
git tag v1.2.0-lw # lightweight
git tag -a v1.2.0 -m "Release 1.2.0" # annotatedYou can prove the difference. git cat-file -t reports the type
of the object a name resolves to:
git cat-file -t v1.2.0-lw # commit — the ref points straight at it
git cat-file -t v1.2.0 # tag — a real tag objectgit show v1.2.0 on an annotated tag prints the tagger, the date,
and the tag message before the commit. On a lightweight tag it
prints the commit alone, because there is nothing else to print.
You can tag a commit you made in the past, too. Pass its hash:
git tag -a v1.1.1 9fceb02 -m "Backport: fix session timeout"And you can remove a local tag you got wrong:
git tag -d v1.2.0 # deletes the local tag onlyHere is the detail that catches almost everyone: git push does
not push tags. You can tag a release, push, watch the push
succeed, and find nothing on the remote but commits.
git push origin v1.2.0 # push one specific tag
git push --tags # push every local tag (blunt)
git push --follow-tags # push commits + annotated tags on them--tags pushes everything, including half-finished experiments
and lightweight scratch markers. --follow-tags pushes only
annotated tags that are reachable from the commits you are already
pushing, which is almost always what you actually mean.
Deleting a tag from the remote is a separate act from deleting it locally:
git push origin --delete v1.2.0git tag -f v1.2.0 <newhash> will happily repoint a tag, and
git push --force origin v1.2.0 will push that change. Both
commands work. That is the problem.
The only safe time to move a tag is before you push it, while nobody else has seen it. After that, tags are immutable by social contract even though Git will not enforce it.
Once tags exist, git describe can tell you where any commit
sits relative to the nearest one behind it. This is how CI systems
stamp a build without anyone typing a version by hand.
git describe --tags --always --dirty
# v1.2.0-14-gabc1234-dirtyRead that left to right. v1.2.0 is the nearest tag reachable
from your current commit. 14 is how many commits you are past
it. gabc1234 is the current commit — g for "git", then the
abbreviated hash. -dirty appears only when your working tree has
uncommitted changes.
Each flag earns its place. --tags lets lightweight tags count
too, so you get an answer in repos that use them. --always falls
back to a bare hash instead of failing when no tag is reachable —
important on a fresh clone with no releases yet. --dirty makes
an untracked-change build impossible to mistake for a clean one.
On a tagged commit the output collapses to v1.2.0, so one CI
line gives you a clean version for releases and a traceable one
for everything else:
VERSION=$(git describe --tags --always --dirty)
docker build -t myapp:"$VERSION" .Semantic Versioning (semver) gives those numbers agreed
meaning. A version is MAJOR.MINOR.PATCH:
Two optional suffixes exist. A pre-release is appended with a
hyphen and sorts before the plain version: 1.0.0-rc.1 comes
out ahead of 1.0.0. Build metadata is appended with a plus and
is ignored entirely for ordering: 1.0.0+build.5 and
1.0.0+build.9 rank the same.
Now the honest part. Semver is a contract about an API, so it fits libraries well and applications badly. If you ship a web app, "breaking" has no clear meaning — there is no caller to break — and teams end up bumping MAJOR for marketing reasons or never bumping it at all. Even in libraries, reasonable people disagree about whether a bug fix that someone depended on is a fix or a break. Pick an interpretation, write it down, and be consistent.
Conventional Commits is a convention for subject lines that makes them parseable:
type(scope): subjectCommon types: feat for a new capability, fix for a bug fix,
docs, refactor, perf, test, build, chore. The scope is
optional and names the area touched.
feat(auth): add passkey login
fix(api): reject expired refresh tokens
perf(search): cache tokenizer between queries
feat(api): return ISO timestamps in all responses
BREAKING CHANGE: timestamps were Unix epoch integers; clients
parsing them as numbers must switch to ISO 8601 strings.The payoff is not tidiness. It is that a program can now read your
history and compute the next version: any BREAKING CHANGE:
footer (or a ! after the type, as in feat(api)!:) means MAJOR,
any feat means MINOR, any fix means PATCH. The same parse
produces your changelog. You stop deciding these things by hand.
Because tags pin commits, a range between two tags is exactly "the work in this release". Ask Git for it:
git log v1.1.0..v1.2.0 --oneline --no-merges--no-merges drops merge commits, which carry no content of their
own and only add noise. Shape the output with --pretty:
git log v1.1.0..v1.2.0 --no-merges --pretty=format:"- %s (%h)"
# - add passkey login (a1b2c3d)
# - reject expired refresh tokens (e4f5a6b)With conventional commits you can group by type. This pipeline writes a release-notes skeleton:
range="v1.1.0..v1.2.0"
emit() { # $1 = type, $2 = section heading
body=$(git log "$range" --no-merges -E \
--grep="^$1(\(.+\))?!?: " --pretty=format:"- %s (%h)")
[ -n "$body" ] && printf '\n### %s\n\n%s\n' "$2" "$body"
}
emit feat "Features"
emit fix "Bug fixes"
emit perf "Performance"
git log "$range" --grep="BREAKING CHANGE" --pretty=format:"- %s"Here is the whole process as you would actually run it.
main and pull, so your local
main matches the remote exactly.package.json,
pyproject.toml, Cargo.toml — and commit it:
git commit -m "chore(release): 1.2.0".git tag -a v1.2.0 -m "Release 1.2.0".git push --follow-tags.main. main moves;
the tag does not, so the tag is the reproducible input.When a bug lands in an old release and main has already moved
on, do not tag from main. Branch from the tag itself:
git switch -c release/1.2.x v1.2.0 # branch pinned at the tag
# fix the bug, commit it
git tag -a v1.2.1 -m "Release 1.2.1"
git push --follow-tags origin release/1.2.x
git switch main
git cherry-pick <fixhash> # carry the fix forwardUse git switch rather than git checkout here: switch only
ever changes branches, so it cannot silently discard file changes
the way the overloaded checkout can.
git tag # list all tags
git tag -l "v1.*" # list tags matching a pattern
git tag -a v1.2.0 -m "msg" # annotated tag on HEAD
git tag -a v1.1.1 9fceb02 -m "msg" # annotated tag on an old commit
git tag -s v1.2.0 -m "msg" # signed annotated tag
git tag v1.2.0-lw # lightweight tag (local use only)
git tag -d v1.2.0 # delete a local tag
git show v1.2.0 # tag message + the commit
git cat-file -t v1.2.0 # "tag" vs "commit" — proves the type
git tag -v v1.2.0 # verify a signed tag
git push origin v1.2.0 # push one tag
git push --tags # push all local tags (blunt)
git push --follow-tags # push commits + their annotated tags
git push origin --delete v1.2.0 # delete a tag on the remote
git describe --tags --always --dirty # v1.2.0-14-gabc1234-dirty
git log v1.1.0..v1.2.0 --oneline --no-merges # release range
git log v1.1.0..v1.2.0 --pretty=format:"- %s (%h)" # changelog lines
git shortlog -sn v1.1.0..v1.2.0 # contributor credit
git switch -c release/1.2.x v1.2.0 # patch branch from an old release
git cherry-pick <hash> # carry that fix back to mainYou can now pin a release so it stays reproducible, derive a
version string from history, and build release notes from commits
instead of recollection. The habits that make it stick are small:
annotated tags always, --follow-tags on every push, and never
moving a tag once it is public.
Next comes recovering lost work with the reflog — the log Git keeps of every place your branches have been, including the ones you thought you destroyed. It is the safety net under everything you have learned in this course, and it turns "I deleted the branch" from a disaster into a two-minute fix.
Before you move on, practise on a real repo. Tag its current
commit as v0.1.0, make a handful of conventional commits, run
git describe, then generate the changelog for the range. Doing
it once on code you care about teaches more than three readings.