A tag is a ref that does not move. That is nearly the whole feature, and the interesting parts are what you build on top of it: reproducible releases, a version string for any commit, and a way to answer “which code is actually running” without guessing.
Topic 1: Two Kinds of Tag
git tag v1.0.0 # lightweight — just a pointer
git tag -a v1.0.0 -m "Release 1.0.0" # annotated — a real object
git tag -s v1.0.0 -m "Release 1.0.0" # annotated and signed
An annotated tag is a genuine object with a tagger, a date, a message and an optional signature. A lightweight tag is a file containing a hash and nothing else — no author, no date, no message, not signable.
git cat-file -t v1.0.0 # "tag" for annotated, "commit" for lightweight
git show v1.0.0 # annotated shows tagger + message first
Use annotated tags for every release, always. A lightweight tag is a private bookmark; a release tag is a record that somebody will read during an incident in two years, and “who cut this and when” is exactly what they will want.
git tag # list
git tag -l 'v1.*' # filter
git tag --sort=-v:refname # newest first, version-aware sorting
git tag -a v1.0.0 9f8e7d # tag a commit retroactively
git tag -d v1.0.0 # delete locally
git push origin --delete v1.0.0 # delete remotely
Tags do not push by default. git push sends branches; tags need to be asked for:
git push origin v1.0.0 # one tag — the safe habit
git push origin --tags # all tags, including local scratch ones
git push --follow-tags # branches + annotated tags reachable from them
--follow-tags is the sensible default for a release workflow: it pushes annotated tags that belong to the commits you are pushing, and skips your local experiments.
Never move a published tag. Somebody has pinned to it, a build cached it, a deployment references it. If a release is wrong, cut a new version. git tag -f exists and re-pointing a public tag is the kind of thing that produces two different artifacts with the same version number in two different places.
Topic 2: git describe — a Version for Any Commit
git describe --tags
# v1.1.0-14-g9f8e7d
# │ │ │
# │ │ └─ commit 9f8e7d (the g means "git")
# │ └──── 14 commits since the tag
# └─────────── the most recent reachable tag
On a tagged commit it prints just v1.1.0. The variants worth knowing:
git describe --tags --always --dirty
# v1.1.0-14-g9f8e7d-dirty ← uncommitted changes in the tree
# 9f8e7d ← --always: fall back to a hash if no tag exists
Stamp this into every build. A Go example, and every toolchain has an equivalent:
VERSION=$(git describe --tags --always --dirty)
go build -ldflags "-X main.version=$VERSION" ./cmd/app
Now a running process can answer “what are you?” precisely, and the answer maps to one commit. A -dirty suffix in production is its own alarm: something was built from an unclean tree, which means the artifact does not correspond to any commit at all.
Topic 3: Semantic Versioning, Applied Honestly
MAJOR.MINOR.PATCH 2.4.1
MAJOR breaking change — consumers must do work
MINOR new functionality, backwards compatible
PATCH bug fix, backwards compatible
2.4.1-rc.1 pre-release: sorts BEFORE 2.4.1
2.4.1+build.7 build metadata: ignored for precedence
Two rules that carry most of the value:
- A breaking change is defined by the consumer, not by the size of the diff. Renaming a public field is major; rewriting the entire internals with an identical interface is a patch.
0.xmeans anything can break. Do not reach1.0.0until you are willing to be held to compatibility, and do not stay at0.xfor three years to avoid the commitment.
Semver applies cleanly to libraries and APIs. For a continuously deployed service, calendar versioning (2026.08.1) is often more honest — nobody consumes your service’s version as a compatibility contract, and a date says something useful at a glance.
Topic 4: Generating a Changelog
git log --oneline v1.0.0..v1.1.0
git log v1.0.0..v1.1.0 --pretty=format:'- %s (%an)'
git log v1.0.0..HEAD --no-merges --pretty=format:'- %s'
git shortlog v1.0.0..v1.1.0 -sn # contributors, by commit count
If commits follow Conventional Commits, the categorisation is mechanical:
git log v1.0.0..HEAD --pretty=format:'%s' | grep '^feat' | sed 's/^feat[(:]/- /'
which is what tools like git-cliff, release-please and semantic-release automate — including deriving the version bump from the commit types. The prerequisite is commit discipline: a changelog generated from “fix”, “updates” and “wip” is worse than no changelog, because it looks official.
Keep a CHANGELOG.md by hand for anything with users. A generated list of commits is a record of work; a changelog is a description of what changed for them, which is a different document with a different audience.
Topic 5: Making a Release Reproducible
Three habits that make a release something you can reconstruct:
Tag the exact commit that was tested, not main at the time you remembered. The artifact and the tag must come from the same hash.
Build from a clean checkout in CI, not from a laptop. git describe --dirty catching a -dirty build is a good failure; a human noticing afterwards is not.
Record the provenance in the artifact. The commit hash, the build time, the builder. git archive produces a source tarball at exactly a tag:
git archive --format=tar.gz --prefix=app-1.1.0/ v1.1.0 -o app-1.1.0.tar.gz
Note that git archive excludes anything marked export-ignore in .gitattributes — a clean way to keep CI configuration and test fixtures out of a source release:
.github/ export-ignore
tests/fixtures/ export-ignore
Signed tags are how a consumer verifies a release came from you:
git tag -s v1.1.0 -m "Release 1.1.0"
git tag -v v1.1.0 # verify the signature
For anything public, or anything shipped inside a company that other teams depend on, a signed tag is the cheapest supply-chain control available.
Topic 6: Finding Out What Is Deployed
The questions an incident actually starts with, and the commands that answer them:
# Which tag contains this commit? (i.e. which release shipped this fix)
git tag --contains 9f8e7d
# Which branches contain it?
git branch -a --contains 9f8e7d
# What is between what is deployed and what is on main?
git log --oneline v2026.08.3..main
# Is this exact commit an ancestor of main? (scriptable, exit code)
git merge-base --is-ancestor 9f8e7d main && echo "already on main"
git tag --contains is the one worth memorising. “Is the fix in production?” becomes a one-line command instead of a conversation, and the answer is authoritative.
Try it yourself: tag a commit, add three more commits, and run git describe --tags --dirty with and without an uncommitted change. Two seconds, and it explains what a version string can carry that a manually maintained constant never will.
Common mistake: using lightweight tags for releases and discovering later that nobody can tell who cut v1.4.0 or when. There is no metadata to recover — the tag never had any. Annotated tags cost two extra characters (-a) and one message, and they are the difference between a release record and a bookmark.