Git95 min total · 17 parts
Git Internals and Workflows: Branching, Merging, and Rebasing
Part 14 of 17 · ~3 min
Tags and Releases
Friday of release week. Shift swapping is merged, the release branch is cut and stabilised, and version 2.3.0 is going out to clinics.
Structurally, a tag is barely distinguishable from a branch — each is a label naming one commit. The one difference everything else follows from: a branch is expected to move, and a tag is not. main means "the latest thing"; v2.3.0 means "that exact code, forever." That is what makes a tag something you can hand a customer, cite in a bug report, or check out in six years to reproduce a fault.
There are two kinds, and the difference is larger than it first appears:
git tag v2.3.0-rc1 # lightweight: a bare pointer, nothing else
git tag -a v2.3.0 -m "Release 2.3.0" # annotated: a real object with a message, tagger, and date
A lightweight tag is nothing but a file holding a hash — that is the entire thing. An annotated one is a different animal: Git stores it as a genuine object of its own, complete with author, timestamp, and message, and that object simply happens to reference a commit. You can see the difference directly:
git cat-file -t v2.3.0 # tag ← annotated: a tag object
git cat-file -t v2.3.0-rc1 # commit ← lightweight: straight at the commit
And that structural difference has a practical consequence people run into without knowing why. git describe — the command that turns a commit into a human-readable version string, and the thing most build pipelines use to stamp a version number into an artifact — ignores lightweight tags by default:
git describe
# fatal: No annotated tags can describe 'd7e40b1...'.
# However, there were unannotated tags: try --tags.
Use annotated tags for anything that represents a real release. The message gives you somewhere to record what shipped, the tagger and date give you an audit trail, and tooling treats them as first-class. Keep lightweight tags for private bookmarks — a commit you want to find again next week.
Tags are not pushed by your ordinary git push. They have to be sent explicitly:
git push origin v2.3.0 # push one tag
git push origin --tags # push all local tags (including ones you forgot about)
git push --follow-tags # push commits plus annotated tags reachable from them — usually what you want
--follow-tags is the sensible default for a release flow: it pushes annotated tags that are actually part of the history you are pushing, and leaves your local scratch bookmarks alone. Setting git config push.followTags true makes it automatic.
Finally: moving a tag that has already been pushed is disruptive for exactly the reason rebasing shared commits is. Other people already have v2.3.0 pointing at one commit; re-pointing it means their repository and yours quietly disagree about what version 2.3.0 is, and Git does not update an existing tag on fetch by default, so many of them will never find out. If 2.3.0 was wrong, ship 2.3.1. Tags are cheap and confusion is not.