Tags and releases
Tags name a fixed point in history, usually a release. A lightweight tag is just a pointer; an annotated tag (git tag -a) is a real object with tagger, date, and message — use it for releases. Tags are NOT pushed by default; send them with git push --tags. Version with semver.
Six weeks after you shipped, a customer reports a bug “in version 1.4.0.” Which commit was 1.4.0? Branches move and main is now hundreds of commits ahead, so you need a permanent, named bookmark on the exact commit you released. That bookmark is a tag — and getting the kind and the push right is the difference between a clean release process and a confusing one.
By the end of this lesson you will be able to tag a release the right way, understand why annotated tags beat lightweight ones for releases, push tags deliberately, and read a version number like an engineer.
After this lesson you can create lightweight and annotated tags, choose the right one for a release, push tags to a remote (knowing they are not pushed automatically), inspect a tag, and explain what a semantic version number communicates.
A tag is a name permanently attached to one commit — usually a release point. Unlike a branch, a tag does not move when you add new commits; it stays pinned to the commit it marked. List and inspect tags with:
git tag # list all tags
git tag -l "v1.4.*" # list tags matching a pattern
git show v1.4.0 # show what the tag points atThis gives you a stable vocabulary: “the bug is in v1.4.0” maps to one exact commit forever, no matter how far main advances.
There are two kinds of tag, and the difference matters for releases. A lightweight tag is just a name pointing at a commit — nothing more. An annotated tag is a full object in the git database storing the tagger’s name, an email, a date, and a message (and can be GPG-signed):
git tag v1.4.0-tmp # lightweight: bare pointer, no metadata
git tag -a v1.4.0 -m "Release 1.4.0" # annotated: real object with author + date + messageUse lightweight tags for private, throwaway bookmarks. Use annotated tags for anything you release, because the metadata — who cut the release, when, and a message — is exactly what an audit or a git describe later relies on.
Tags are NOT pushed by default — you must send them explicitly. A normal git push moves branch commits but leaves your new tags sitting locally. Two ways to publish them:
git push origin v1.4.0 # push one specific tag
git push origin --tags # push ALL local tags not yet on the remoteThis trips up almost everyone once: you tag the release, push, announce it — and the tag is nowhere on the server because you never sent it. When automation (CI release pipelines) watches for tags, a forgotten --tags silently skips your release build.
Version numbers are not arbitrary: semantic versioning encodes compatibility. A semver string is MAJOR.MINOR.PATCH, and each part has a contract:
v 1 . 4 . 2
│ │ └─ PATCH: backward-compatible bug fixes
│ └───────── MINOR: new features, still backward-compatible
└───────────────── MAJOR: breaking changes (consumers must adapt)Bumping MAJOR signals “I broke the API”; MINOR says “new features, your code still works”; PATCH says “bug fixes only.” This is the language your tags speak to everyone downstream, which is why the tag name carries real meaning, not just a label.
▸Edge cases
Checking out a tag gives you a detached HEAD. Because a tag is not a branch, git checkout v1.4.0 lands you on the commit without any branch attached — HEAD points directly at the commit. That is fine for inspecting or building an old release, but any commits you make there belong to no branch and can be lost. If you need to fix something starting from a tag, branch from it explicitly: git switch -c hotfix/1.4.1 v1.4.0.
Cutting and publishing a release.
Tests are green on main and you are shipping 2.0.0 — a release that drops a deprecated endpoint, so it is a breaking (MAJOR) change.
git switch main
git pull
git tag -a v2.0.0 -m "Release 2.0.0: remove legacy /v1 API"
git show v2.0.0 # confirm tagger, date, message, and target commit
git push origin v2.0.0 # publish the tag so CI and consumers see itThe annotated tag records who cut 2.0.0 and when, the 2.0.0 number tells every consumer to expect breaking changes, and the explicit push makes the tag visible on the remote so the release pipeline fires. A teammate hotfixing it later runs git switch -c hotfix/2.0.1 v2.0.0 rather than committing on a detached HEAD.
▸Common mistake
The two release-day mistakes that recur: using a lightweight tag for a real release (so there is no record of who released it or when), and forgetting that tags are not pushed by default (so the tag exists on your laptop but never reaches the server or CI). Make annotated-tag-plus-explicit-push your fixed ritual. Bumping the wrong semver part — shipping a breaking change as a PATCH — is the third: it quietly breaks everyone who trusted the number to mean “safe upgrade.”
A tag is a permanent name on one commit, used to mark releases. A lightweight tag is a bare pointer; an annotated tag (git tag -a -m) is a real object storing tagger, date, and message — use annotated for anything you release, and git tag -s to sign it. Tags are not pushed by default: publish them with git push origin <tag> or --tags, or your release stays invisible to the server and CI. Version with semver (MAJOR.MINOR.PATCH) so the number itself communicates compatibility, and remember that checking out a tag gives a detached HEAD — branch from it to make fixes. Next you will run two working trees from one repo with git worktree.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.