Module 12 — Tags and Releases
Updated 7 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — Two kinds of tag
A1. Lightweight and annotated
A tag is a name pinned to a specific commit — and unlike a branch (Module 5's moving pointer), a tag never moves. It marks one commit forever: "this exact snapshot is v1.0.0." Git has two kinds. A lightweight tag is just a ref — a file under .git/refs/tags/ holding a commit hash, nothing more (Module 3's ref, pointed at a commit instead of a branch). An annotated tag is a real object in the database — Git's fourth object type (Module 3 named it and deferred it to here): it stores the tagger's name, email, date, a message, and (Part D) an optional signature, then points at the commit. git tag <name> makes a lightweight tag; git tag -a <name> -m "message" makes an annotated one. For releases, use annotated — the metadata (who cut it, when, why) is exactly what a release needs, and only annotated tags can be signed.
🧪 Exercise 12.1 — make one of each, then look inside
cd ~/git-course/kitchen # a repo with a few commits
git tag v0.9-beta # lightweight
git tag -a v1.0.0 -m "First stable release: core bread recipes" # annotated
git tag # list them
git cat-file -t v0.9-beta # what kind of object does each name?
git cat-file -t v1.0.0
git cat-file -p v1.0.0 # read the annotated tag object✅ Expected result — click to reveal
v0.9-beta
v1.0.0
commit
tag
object 5de043ae19dde738a1c05691b66ecbd7bb7a3cb7
type commit
tag v1.0.0
tagger Aisha Rahman <[email protected]> 1788819385 +0000
First stable release: core bread recipesWhat to read out of it: git cat-file -t reveals the difference at the object level — the lightweight tag v0.9-beta resolves straight to a commit (it's a bare pointer, no object of its own), while v1.0.0 is a tag object. Reading that tag object shows its anatomy: it contains the commit hash (object …), the target's type, its own name, the tagger line (identity + timestamp — the metadata a lightweight tag lacks), and the message. This is Module 3's fourth object type in the flesh: blob, tree, commit, and now tag — all content-addressed, all read with the same cat-file.
A lightweight tag is a sticky dot on a painting: "this one." Useful, but it tells you nothing — who placed it, when, why this painting. An annotated tag is the engraved plaque beside the artwork: title, date acquired, curator's note, and the museum's seal (the signature). Both point at the same painting; only the plaque carries the story of the designation.
Where the analogy stops working. A plaque can be re-engraved and a painting reframed; the record drifts. A Git tag is anchored by content-addressing — the annotated tag object names the commit by hash, so it is bound to that exact snapshot and provably so. And a museum's seal can be forged onto a plaque; a signed tag's cryptographic signature (Part D) cannot — verification recomputes it against a public key. The plaque is a description; the signed tag is evidence.
A2. Tagging, listing, deleting
Tags are not limited to "right now." git tag -a <name> <commit> tags any historical commit — the common "we forgot to tag last week's release" fix. Listing scales: git tag -l "v1.*" filters by pattern, -n1 shows each tag's message, and --sort=-v:refname sorts by version number (not alphabetically — critical, because plain sort puts v1.10.0 before v1.9.0). Deleting is git tag -d <name> — but note this deletes only your local tag; a pushed tag needs separate remote deletion (Part C).
🧪 Exercise 12.2 — tag the past, filter, delete
cd ~/git-course/kitchen
FIRST=$(git rev-list --max-parents=0 HEAD) # the root commit's hash
git tag -a v0.1.0 -m "Initial prototype" $FIRST
git tag -l "v0.*" -n1 # matching tags, with messages
git tag --sort=-v:refname # version-sorted, newest first
git tag -d v0.9-beta # remove the lightweight beta locally
git tag✅ Expected result — click to reveal
v0.1.0 Initial prototype
v0.9-beta Add baking
v1.0.0
v0.9-beta
v0.1.0
Deleted tag 'v0.9-beta' (was 5de043a)
v0.1.0
v1.0.0What to read out of it: v0.1.0 now marks the first commit though you created the tag just now — tags name any commit, past or present. The -n1 listing shows annotated tags' messages (Initial prototype) and, for the lightweight v0.9-beta, falls back to the commit's subject (Add baking) since it has no message of its own — a visible symptom of the lightweight/annotated difference. --sort=-v:refname ordered them as real versions (v1.0.0 > v0.9-beta > v0.1.0). The delete printed the hash it removed (was 5de043a) — the same last-chance note branch -d gives (Module 5), because deleting a tag is deleting a ref.
🎯 Interview questions — Part A
🎯 "What is the difference between a lightweight tag and an annotated tag?" — asked verbatim in GeeksforGeeks' 70+ Git Interview Questions, updated Jul 2026
A lightweight tag is just a ref — a name pointing straight at a commit, with no object of its own and no metadata; effectively a private bookmark. An annotated tag is a full Git object storing the tagger's name and email, a timestamp, a message, and optionally a GPG/SSH signature, which then points at the commit. Consequences: annotated tags record who tagged, when, and why; they can be signed and verified; and git describe prefers them. Create lightweight with git tag <name>, annotated with git tag -a <name> -m "…". Use annotated for anything shared — releases especially.
The details that separate candidates: an average answer says "annotated has a message." A strong answer states that an annotated tag is a distinct object (Git's fourth type — blob/tree/commit/tag) verifiable with git cat-file -t, that only annotated tags can be signed, and that git describe and most release tooling assume annotated. The practical rule they want: lightweight for throwaway local markers, annotated for every release — because a release needs provenance, and a bare ref carries none.
🎯 "What is the purpose of 'git tag -a'?" — asked verbatim in GeeksforGeeks (updated Jul 2026) and Interview Coder, Sep 2025
git tag -a <name> creates an annotated tag: a stored object carrying the tagger's identity, date, and a message (supplied with -m or via the editor), pointing at a commit (HEAD by default, or a given hash). Its purpose is durable, attributable release marking — the metadata answers "who cut this release, when, and what is it," which a lightweight tag cannot. Add -s to sign it cryptographically for provable authenticity. It's the standard way to mark versions: git tag -a v2.1.0 -m "Release 2.1.0", then push it.
The details that separate candidates: an average answer says "makes a tag with a message." A strong answer positions -a as the release tool (metadata + signability + describe support), can tag a past commit (git tag -a v2.1.0 <hash> when a release went out untagged), and knows the immediate follow-up most people forget — tags don't push with commits; git push origin <tag> or --tags is required (Part C). Mentioning -s/-m and that annotated is a real object rounds it out.
Part B — Semantic versioning and git describe
B1. What the numbers mean
Tag names are free-form, but a convention won: semantic versioning (semver), MAJOR.MINOR.PATCH. The rules encode a promise to your users: bump PATCH (1.4.2 → 1.4.3) for backward-compatible bug fixes; MINOR (1.4.2 → 1.5.0) for backward-compatible new features; MAJOR (1.4.2 → 2.0.0) for a breaking change that requires consumers to adapt. Optional suffixes mark pre-releases: 2.0.0-rc.1, 1.0.0-beta. The payoff is that a version number becomes machine-readable intent: dependency tools can auto-accept patch and minor updates while pinning majors, precisely because the numbers carry a compatibility contract — not decoration. (Git conventionally prefixes the tag with v: v2.0.0; the v is habit, semver's spec is the bare number.)
B2. git describe — where am I relative to the last release?
git describe answers "what version is this build?" for any commit — including the commits between releases. It finds the most recent tag reachable from a commit and, if you are past it, appends how far: v1.0.0-2-gd255463 means "2 commits after tag v1.0.0, at commit d255463" (the g prefix is for "git"). Exactly on a tagged commit, it prints just the tag. This is how build systems stamp version strings automatically — every CI artifact gets a name that pinpoints its source commit relative to the last release, with no manual bookkeeping. Use --tags to consider lightweight tags too (by default describe uses only annotated ones — another reason releases should be annotated).
🧪 Exercise 12.3 — describe a release, and the commits after it
cd ~/git-course/kitchen # v1.0.0 is on the current tip from 12.1
echo "more" >> bread.txt && git commit -qa -m "Tweak bread"
echo "more2" >> bread.txt && git commit -qa -m "Tweak bread again"
git describe --tags # HEAD: 2 commits past v1.0.0
git describe --tags --long # --long: always show the count, even on a tag
git describe --tags HEAD~2 # exactly on the tagged commit✅ Expected result — click to reveal
v1.0.0-2-gd255463
v1.0.0-2-gd255463
v1.0.0What to read out of it: HEAD describes as v1.0.0-2-gd255463 — decoded: nearest tag v1.0.0, 2 commits beyond it, at abbreviated commit d255463 (your hash differs). That single string is a complete build identity: which release it descends from, how far past, and the exact commit. HEAD~2 — the commit v1.0.0 actually points at — describes as bare v1.0.0, because it is the tag. --long forces the count form even there (v1.0.0-0-g… on a tagged commit), which build scripts prefer for a uniform format. This is why CI can label a nightly build v1.0.0-2-gd255463 and you instantly know it's two commits ahead of the last release.
🎯 Interview questions — Part B
🎯 Corpus note — semantic versioning & git describe
In the surveyed interview corpus (GeeksforGeeks, Interview Coder), tagging questions concentrate on the annotated-vs-lightweight distinction (Part A); semantic versioning appears mostly as a mentioned concept rather than a verbatim standalone Q&A, and git describe is essentially absent as a named question. Rather than fabricate one, this Part carries a corpus note. What to be able to say cold: semver's MAJOR.MINOR.PATCH encodes a compatibility contract (breaking / feature / fix), and git describe turns any commit into a human-readable build identity relative to the last tag (<tag>-<N>-g<hash>). If an interviewer asks "how do you version releases and identify arbitrary builds," those two facts are the answer — and knowing that describe prefers annotated tags ties it back to Part A.
Part C — Pushing tags and the release flow
C1. Tags don't travel with commits
Here is the surprise that catches nearly everyone once: git push does not push tags. Commits go; tags stay home. You created v1.0.0, pushed your branch, and the tag is nowhere on the server — because tags are a separate namespace Git deliberately does not sync implicitly (you might have local scratch tags you never meant to share). Three ways to send them: git push origin <tagname> for one specific tag (the disciplined choice — you push exactly the release you mean), git push origin --tags for all local tags at once (convenient, but ships any junk tags too), and deleting a remote tag needs its own command: git push origin --delete <tagname>, because local git tag -d only touches your copy.
🧪 Exercise 12.4 — publish a release tag, then retract one
cd ~/git-course && git init --bare -q bakery.git && cd kitchen
git remote add origin ~/git-course/bakery.git
git push -q -u origin main
git push origin v1.0.0 # push ONE tag deliberately
git push origin --tags # push the rest (v0.1.0)
git push origin --delete v0.1.0 # retract a remote tag✅ Expected result — click to reveal
To /home/aisha/git-course/bakery.git
* [new tag] v1.0.0 -> v1.0.0
To /home/aisha/git-course/bakery.git
* [new tag] v0.1.0 -> v0.1.0
To /home/aisha/git-course/bakery.git
- [deleted] v0.1.0What to read out of it: * [new tag] v1.0.0 -> v1.0.0 — the tag traveled only because you explicitly pushed it; the earlier git push -u origin main (which you ran) sent commits but no tags. --tags then pushed the remaining v0.1.0. The retraction (- [deleted]) mirrors Module 8's branch deletion and needs the same --delete because a local git tag -d would never have touched the server. This local-only-by-default behavior is the single most common tag surprise — "I tagged the release but the deploy can't find v1.0.0" is almost always an unpushed tag.
🎯 Interview questions — Part C
🎯 Corpus note — pushing tags
Tag pushing appears in the corpus only inside broader tag answers (usually as the "don't forget tags don't auto-push" caveat within the git tag -a question), not as a standalone verbatim item — so, a corpus note rather than a fabricated block. The one fact interviewers reliably probe as a follow-up: git push does not send tags — you need git push origin <tag> (one) or --tags (all), and remote deletion is git push origin --delete <tag>. Volunteering that caveat when asked about tagging, before it's asked, is itself the signal of someone who has actually shipped a release — because everyone hits the "my tag isn't on the server" surprise exactly once.
Part D — Signed tags: provable releases
D1. Signing and verifying
Module 1's trap: commit and tag identity are self-declared — anyone can tag as anyone. For code the world downloads and runs, "who really cut this release?" needs a cryptographic answer. git tag -s <name> creates a signed annotated tag: Git signs the tag object with your private key (GPG traditionally, or SSH keys in modern Git — git config gpg.format ssh with a user.signingkey), embedding a signature block. Anyone with your public key can then run git tag -v <name> to verify the signature recomputes — proving the tag was made by your key and the commit it names is unaltered. This is how Linux distributions, language runtimes, and security-conscious projects let users trust a release tarball actually came from the maintainers. The exercise below uses SSH signing (no GPG setup needed); skip it if you have no signing key — the concept is the interview content.
🧪 Exercise 12.5 — sign a release and verify it (SSH-key path)
cd ~/git-course/kitchen
# One-time setup (skip if you already sign): make an SSH key and tell Git to use it
ssh-keygen -t ed25519 -f ~/.ssh/git_sign -N "" -C "[email protected]"
git config gpg.format ssh
git config user.signingkey ~/.ssh/git_sign.pub
git tag -s v1.1.0 -m "First signed release" # -s: sign it
git cat-file -p v1.1.0 | head -9 # the signature is IN the tag object
# To verify, Git needs to know which keys to trust (allowed-signers file):
printf '[email protected] namespaces="git" %s\n' "$(cat ~/.ssh/git_sign.pub)" > ~/allowed_signers
git config gpg.ssh.allowedSignersFile ~/allowed_signers
git tag -v v1.1.0 # verify✅ Expected result — click to reveal
object d2554633cb3640ee63d44fb0109cada88a3fae4d
type commit
tag v1.1.0
tagger Aisha Rahman <[email protected]> 1788819442 +0000
First signed release
-----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg...
Good "git" signature for [email protected] with ED25519 key SHA256:PHATihCoJvN9...What to read out of it: the tag object now carries a -----BEGIN SSH SIGNATURE----- block inside it — the signature is part of the content-addressed object, so it cannot be detached or altered without changing the tag's hash (Module 3's integrity, now doing security work). git tag -v printed Good "git" signature for [email protected] — verification succeeded: the signature matches a trusted key, named in your allowed-signers file. Two failure modes are worth seeing on purpose. With gpg.ssh.allowedSignersFile unset entirely, git tag -v errors out with error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification — Git won't even attempt trust without a trust list. With the file configured but not containing this signer's key (e.g. verifying someone else's tag), you get the revealing pair: Good "git" signature with ED25519 key SHA256:… followed by No principal matched. — the math checks out, but Git has no trusted identity for that key. That gap — valid signature vs trusted signer — is exactly the distinction interviewers probe.
🎯 Interview questions — Part D
🎯 Corpus note — signed tags
Signed/GPG tags appear in the corpus only as a mentioned option inside the git tag -a answer ("can be signed with -s"), not as a standalone verbatim question — hence a corpus note. The interview-ready substance: git tag -s signs an annotated tag with your key; git tag -v verifies it; the point is provable authorship of releases against a trusted public key, and the sharp distinction between a valid signature and a trusted signer (a valid signature from an untrusted key proves nothing about origin). Tie it to the supply-chain frame — signed tags and commits are how downstream consumers know a release genuinely came from the maintainers — and to Module 1's "identity is declared, not verified": signing is the mechanism that finally verifies it.
Part E — Production practice
E1. Symptom → cause → diagnosis → fix
| Symptom | What is really happening | What to run | The fix |
|---|---|---|---|
| Deploy/release pipeline can't find the tag you "just made" | git push doesn't send tags; the tag is local-only | git ls-remote --tags origin (what the server actually has) | git push origin <tag> (or --tags); make tag-push part of the release runbook |
| git describe fails: fatal: No names found, cannot describe anything. | No annotated tag is reachable from this commit (and --tags not passed for lightweight) | git tag (any tags at all?) · git describe --tags (include lightweight) | Create the release tags; use annotated so describe finds them without --tags |
| Deleted a tag locally but it's still on the server / still triggers builds | git tag -d is local only; the remote tag persists | git ls-remote --tags origin | git push origin --delete <tag>; coordinate — others may have fetched it |
| Version sort puts v1.10.0 before v1.9.0 | Alphabetical sort, not version sort | git tag (default alpha) vs git tag --sort=-v:refname | Always --sort=v:refname (or -v:refname for newest-first) for version tags |
| Two developers' clones disagree about what v2.0.0 points to | A tag was moved/re-created (-f) after being pushed — tags are meant to be immutable | git ls-remote --tags origin vs local git rev-parse v2.0.0 | Never re-point a published tag; cut a new version instead. If unavoidable, force-push + all-hands re-fetch |
| git tag -v says No principal matched despite a Good signature | The signature is valid but the signer isn't in your trusted set | Check gpg.ssh.allowedSignersFile / imported GPG keys | Add the maintainer's public key to your allowed-signers/keyring — validity ≠ trust |
| A "small fix" release broke downstream auto-updates | A breaking change shipped as PATCH/MINOR — semver contract violated | Review the change against the last release's public behavior | Bump MAJOR for any breaking change regardless of diff size; publish an errata/deprecation note |
E2. Capstone — four tickets
Worked answer: annotated, semver, signed, pushed — as a scripted checklist. Cut releases with git tag -a v<MAJOR>.<MINOR>.<PATCH> -m "Release notes summary" on the exact commit being shipped (annotated for metadata + describe support; semver so the number carries compatibility intent — bump MAJOR on breaking changes without exception). Sign them (-s) if the artifact leaves the org or compliance wants provenance. Push specifically: git push origin v1.4.0 (never a blanket --tags from a dev machine — it ships scratch tags), which is what the release pipeline watches for to build/publish/deploy. Stamp every non-release build with git describe --tags --long so nightly artifacts read v1.4.0-7-gabc1234 — unambiguous lineage. Enforce with a tag-protection rule on the host (only maintainers/CI may push v* tags) so the release trigger can't be pulled by accident. One page, and it wires tagging into the whole delivery chain.
Worked answer: tags name any commit, so retro-tagging is trivial and correct: git tag -a v2.3.0 <deployed-hash> -m "Release 2.3.0 (tagged retroactively)", then git push origin v2.3.0. Verify it lands where the deploy actually was (git describe --tags <deployed-hash> should now print exactly v2.3.0). Two honest caveats: annotated tags record the tagger's date (now), not the release date — so note the real ship date in the message for the record; and if a CI pipeline triggers on tag push, a retroactive tag may kick off a build/publish for an already-deployed version — check whether that's harmless (idempotent republish) or needs the pipeline skipped for this tag. Prevent recurrence: make "cut and push the tag" a gated step in the deploy pipeline, not a human afterthought — the whole class of "forgot to tag" bugs comes from tagging being manual.
Worked answer: signed tags plus published trust anchors. Maintainers create releases with git tag -s v<x> -m "…" using keys the project has published (in the repo's SECURITY.md, a keyserver, and uploaded to the host so commits/tags show "Verified"). Consumers verify with git tag -v v<x> against those keys — and the audit-critical teaching point (the counter-intuitive callout, as a deliverable): a Good signature alone is worthless without checking the key is a trusted maintainer key — a compromised mirror can present a validly-signed tag from an attacker's key. So document the exact trusted key fingerprints out-of-band, and instruct verifiers to confirm the signer matches, not merely that the signature is valid. Layer in reproducible builds if the tarball (not just the tag) must be verifiable, and tag-protection so only maintainer keys can push v*. The one-sentence summary for the audit: signing proves who, trust anchoring proves which who — you need both.
Worked answer: the root error is treating a published tag as mutable — tags are immutable release identities; moving one means "v1.5.0" now refers to two different snapshots depending on who you ask, which is exactly the integrity that versioning exists to prevent. Damage control: decide the single canonical v1.5.0, force-push it once more so the server is authoritative, and have everyone git fetch --tags --force to converge (fetch does not update an existing local tag without --force — another reason this hurts). Then communicate the collision to anyone who may have built the old one. The real fix is procedural: the last-minute fix should have been v1.5.1, not a moved v1.5.0 — cutting a new version is free and honest; re-pointing a shipped tag is a supply-chain hazard. Add host tag-protection making published tags non-force-pushable so this is impossible, not merely discouraged. The interview close: immutability of released tags is a contract with everyone downstream, and "just move the tag" silently breaks it.
E3. Documentation reference
| Topic | Official source | What it covers |
|---|---|---|
| Tagging (tutorial) | Git Book §2.6 — Tagging | Lightweight vs annotated, pushing, checking out tags |
| git tag | git-tag manual | -a/-m/-s/-v/-d/-l/--sort, tagging past commits |
| git describe | git-describe manual | --tags, --long, --match, the <tag>-<N>-g<hash> format |
| Semantic Versioning | semver.org | The MAJOR.MINOR.PATCH contract in full, with pre-release rules |
| Signing your work | Git Book §7.4 — Signing Your Work | Signed tags and commits, GPG/SSH setup, verification |
E4. Self-assessment
Answer each aloud, from memory, before moving on. Every one is answered on this page.
- How does a tag differ from a branch in what it does over time?
- Lightweight vs annotated: what object does each resolve to, and which can be signed?
- Which Git object type is Module 3's "fourth type," and which tag kind creates one?
- How do you tag a commit from last week that was never tagged?
- Why does git tag sort wrong for versions, and what flag fixes it?
- State the semver bump rule for a bug fix, a new backward-compatible feature, and a breaking change.
- Why is "it's a small change" not a valid reason to avoid a MAJOR bump?
- Decode v1.0.0-2-gd255463 field by field. What produces it?
- What surprising thing does git push not do, and what are the two ways to fix it?
- How do you delete a tag that's already on the remote — and why isn't git tag -d enough?
- git tag -s then git tag -v: what does each do, and what does a "Good signature" not guarantee?
- Why is re-pointing a published tag a supply-chain hazard, and what should you do instead?
E5. Sources
🗒️ Cheat sheet — Module 12
| Command | What it does |
|---|---|
| git tag <name> | Lightweight tag — a bare ref to a commit, no metadata |
| git tag -a <name> -m "msg" · ... <commit> | Annotated tag (object w/ tagger, date, message) · on a specific past commit |
| git tag · git tag -l "v1.*" -n1 · --sort=-v:refname | List · filter with messages · sort by version (not alpha!) |
| git tag -d <name> · git push origin --delete <name> | Delete locally · delete on the remote (separate command) |
| git show <tag> · git cat-file -p <tag> | Tag + its commit · the raw annotated-tag object |
| git describe --tags · --long | <tag>-<N>-g<hash> build identity · always include the count |
| git push origin <tag> · git push origin --tags | Push one tag (deliberate) · push all local tags (ships scratch too) |
| git tag -s <name> -m "msg" · git tag -v <name> | Signed annotated tag · verify its signature (needs trusted key) |
| git ls-remote --tags origin | What tags the server actually has (diagnose unpushed/undeleted tags) |
| git fetch --tags · ... --force | Fetch tags · overwrite a moved tag (fetch won't move existing tags otherwise) |
Key concepts: a tag is a permanent, non-moving name for one commit · lightweight = bare ref; annotated = a real object (Git's 4th type) with tagger/date/message, signable, preferred by describe — use annotated for releases · tag any commit, past or present · sort versions with --sort=v:refname, never plain alpha · semver MAJOR.MINOR.PATCH = a compatibility contract (breaking/feature/fix); bump MAJOR for any breaking change regardless of size · git describe = <tag>-<commits-since>-g<hash>, the auto build-version · git push does NOT push tags — push origin <tag> or --tags; remote delete needs --delete · signed tags (-s/-v) prove authorship, but a valid signature ≠ a trusted signer · published tags are immutable — cut a new version, never move a shipped tag.