Git, CI/CD & GitOpsV · Branches, Refs and HEADBranches, Refs and HEAD
Tags versus branches — what tags are for, annotated versus lightweight, and why branches are not releases
What you'll learn
- Distinguish a tag (intended to be immutable) from a branch (intended to move)
- Differentiate annotated tags from lightweight tags and explain why releases require annotated tags
- Recognise why a branch should not be used as a release marker and what fails if it is
- Use git describe to derive a human-readable name from the closest tag and the commit count
- Identify which tools depend on tag immutability (git describe, signed tags, supply-chain attestation)
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A tag is a ref intended to be immutable. A branch is a
ref intended to move. The distinction is the contract: a tag
says “this commit is the release of v1.0.0, and this commit
will be the release of v1.0.0 forever”; a branch says “this
ref is my current line of work, and it will move as I commit”.
The two are the same on-disk format (a file under .git/refs/
with a 40-character OID), but the convention that surrounds
them is different, and the difference matters for every tool
that reasons about release identity.
Tags are immutable; branches move
The two namespaces exist separately because the operations on them are different. A branch is rewritten every commit. A tag is rewritten only when explicitly moved (and an explicit move is a security incident in production).
# A branch is rewritten by every commit
git branch main
cat "$GIT_DIR/refs/heads/main"
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
git commit -m "next"
cat "$GIT_DIR/refs/heads/main"
# 4d2c8e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6f
# A tag is rewritten only by git tag -f
git tag v1.0.0
cat "$GIT_DIR/refs/tags/v1.0.0"
# 4d2c8e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6f
git tag -f v1.0.0 8a3f9d2
# v1.0.0: 8a3f9d2... (the tag was moved)
The -f flag is the only way to move a tag. It is not used in
normal workflows; its presence in a script is a red flag that
someone is updating a release marker, which is exactly what
the tag was supposed to prevent.
flowchart LR
B["refs/heads/main\n8a3f9d2 -> 4d2c8e0 -> 9a8b7c6"] --> C1["commit"]
T["refs/tags/v1.0.0\n4d2c8e0 (final)"] --> C2["commit"]
The branch’s progression is shown as three OIDs over time; the tag’s OID is fixed at the moment of the release. The branch moves; the tag does not.
Annotated tags versus lightweight tags
The two kinds of tags differ in what they are on disk:
# Lightweight tag: just a ref pointing at a commit
git tag v1.0.0-light "$COMMIT_OID"
cat "$GIT_DIR/refs/tags/v1.0.0-light"
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
# Annotated tag: a ref pointing at a tag object
git tag -a -m "Release v1.0.0" v1.0.0 "$COMMIT_OID"
cat "$GIT_DIR/refs/tags/v1.0.0"
# aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
git cat-file -p v1.0.0
# object 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
# type commit
# tag v1.0.0
# tagger Ops <ops@example.com> 1730000000 +0000
#
# Release v1.0.0
The lightweight tag is a bare ref — its file contents are the commit OID. There is no tag object, no tagger, no message, no signature. The annotated tag is a ref pointing at a tag object, which in turn points at the commit. The tag object has its own OID, computed from the tag payload, and the payload carries the tagger, the message, and an optional GPG signature.
flowchart LR
L["refs/tags/v1.0.0-light\n8a3f9d2"] --> C1["commit 8a3f9d2"]
A["refs/tags/v1.0.0\n<tag-oid>"] --> T["tag object\nobject: 8a3f9d2\ntype: commit\ntag: v1.0.0\ntagger: ...\ngpgsig: ..."] --> C2["commit 8a3f9d2"]
The annotated tag adds a layer of indirection — the tag object — that carries the metadata. The benefit is that the tag object is content-addressed: an annotated tag at the same commit with the same tagger, message, and signature has the same OID in any repository. The lightweight tag is just a name; the annotated tag is a cryptographic commitment.
What differs in practice
- Signing. A lightweight tag cannot be signed. A signed
release tag is by definition annotated:
git tag -screates an annotated tag and signs it. - Pushing. Both kinds of tags can be pushed with
git push, butgit push --follow-tagsonly pushes annotated tags by default. Lightweight tags are local names unless explicitly pushed. - Message. A lightweight tag has no message. An annotated
tag has a message that can be read with
git tag -norgit show <tag>. - Verification.
git verify-tagonly works on annotated tags (lightweight tags have no signature to verify). The exit code ofgit verify-tag <tag>is the cryptographic ground truth about whether the tag is signed by a trusted key.
The production discipline is to use annotated tags for releases and lightweight tags for personal bookmarks. A release tag is a contract with the security team, the audit team, and downstream consumers; a personal tag is a note to the future-self.
git describe and the closest-tag rule
Many tools need to derive a human-readable identifier from a
commit. git describe walks backwards from HEAD (or a
specified commit) and finds the closest reachable annotated
tag, then returns the tag name plus the number of commits
since the tag plus a short OID:
# HEAD is four commits past v1.0.0
git describe HEAD
# v1.0.0-4-g8a3f9d2
The format is <tag>-<count>-g<short-oid>. The g prefix
is convention to disambiguate the OID from the count. The
output is unique per commit: a commit that is exactly the tag
returns just the tag name; a commit further away returns the
suffix.
# The exact tag returns just the tag
git describe v1.0.0
# v1.0.0
# A commit exactly five ahead returns the count and the short OID
git describe v1.0.0-5-g9f3c1d7
# v1.0.0-5-g9f3c1d7
git describe searches annotated tags only. A commit that
is reachable only through a lightweight tag returns an error:
git describe --tags HEAD
# fatal: No annotated tags can describe 'HEAD'.
The --tags flag relaxes the search to include lightweight
tags, but the production use is the default: only annotated
tags are release markers, and only they should drive
human-readable identifiers.
# The standard CI invocation: pin the build to a describe output
BUILD_VERSION=$(git describe HEAD)
echo "Build version: $BUILD_VERSION"
This is the discipline for a CI pipeline that produces artifacts named by version. The describe output is a unique identifier per commit, derived from the closest annotated tag. The pipeline promotes the same identifier across environments because the tag is fixed; the suffix grows as additional commits are added.
Why tools depend on tag immutability
Several tools assume tags do not move. When the assumption breaks, the tools silently produce wrong results:
# git describe: assumes the tag is at the commit it was at
# A moved tag changes the describe output for every commit ahead.
# Signed tags: git verify-tag assumes the tag object is stable
# A moved tag changes the ref but the tag object is unchanged;
# the signature still verifies, but the tag no longer points at
# what it claimed to.
# git log --decorate: shows the tags that reach a commit
# A moved tag decouples from the commit history.
# CI pinning: a pipeline that pins to <tag> resolves to <new-oid>
# A moved tag silently changes the artifact the pipeline builds.
The failure mode of a moved tag is silent: every tool that
trusted the tag continues to run, but the answer it gets
is wrong. The discipline is to forbid tag movement in
production repositories and to alert on any git tag -f
operation.
Listing tags and inspecting them
The git tag porcelain command lists tags and creates them:
# List all tags
git tag
# v0.9.0
# v1.0.0
# v1.1.0
# List tags matching a pattern
git tag -l 'v1.*'
# v1.0.0
# v1.1.0
# Show the tag object (annotated) or ref (lightweight)
git show v1.0.0
# tag v1.0.0
# Tagger: Ops <ops@example.com>
# Date: ...
#
# Release v1.0.0
#
# commit 8a3f9d2...
# Show only the commit (no tag metadata)
git show v1.0.0^{commit}
The ^{commit} suffix dereferences the tag to its commit
without printing the tag object. This is the right form for
scripts that need the commit OID the tag points at: it
handles both lightweight and annotated tags uniformly.
Production discipline
- Use annotated tags for releases, never lightweight. Lightweight tags are personal bookmarks; annotated tags are release identities. A release without a tagger, a message, and a signature is not a release.
- Sign release tags with
git tag -s. The signature is the cryptographic ground truth of who tagged the commit.git verify-tag <tag>is the verifier; exit 0 is the answer. - Forbid
git tag -fin production repositories. A forced tag update is a security event, not a routine operation. Branch protection rules and CI hooks should reject tag-overwrite attempts. - Adopt
git describeas the canonical build identifier. Pin the artifact to the describe output, not to the tag name. The describe output is unique per commit and survives tag renames.
Cross-course references
- Docker for Production Sysadmins - Part VII (Tagging) draws the parallel between Git tags and OCI image tags: both are intended to be immutable references to a content-addressed object, and both are unsafe to pin in a pipeline when they are mutable. Image tags should be pinned by digest, just as Git tags should be pinned by OID.
- Terraform for Production Sysadmins - Part IX (State) uses Git tags to mark Terraform state versions: the tag is the boundary between committed state and uncommitted state, and the state version is the immutable reference.
- Ansible for Production Sysadmins - Part XXXVIII (Review) describes signed release tags as the boundary between reviewed and unreleased code; the tag is what the deploy pipeline checks before deploying.
Quiz
Knowledge check · 4 questions
Q1. What is the key difference between a branch and a tag in Git?
Q2. A lightweight tag can be signed with a GPG key, just like an annotated tag.
Q3. What does `git describe HEAD` return, and why does it search only annotated tags by default?
Q4. Diagnose a release pipeline that uses a branch as a release marker and recommend the corrected discipline.
An infrastructure team uses a branch named `production` as the release marker. The CI pipeline builds the artifact from the tip of `production` and deploys it to the production cluster. After a recent migration, a different team force-pushed the `production` branch to a corrected commit. The audit asked: which commit was running in production on a given date six months ago?
Passing score: 75%. Answers are checked in this browser.