Git, CI/CD & GitOpsXIX · Tags and ReleasesTags and Releases
Creating and listing tags — git tag, -a, -m, -d, -l with patterns, and git tag -n
What you'll learn
- Create lightweight and annotated tags with git tag, -a, -m, and a target commit
- Delete local tags with git tag -d and recognise the difference from a remote delete
- List tags matching a glob pattern with git tag -l and read the output
- Show the annotation message of one or more tags with git tag -n and -n<num>
- Choose the correct combination of flags for a release tag in a CI script
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
The git tag porcelain command is the single entry point for
every tag operation: create, delete, list, verify. The flag
set is small — -a, -m, -d, -l, -n, -f, -s,
-u — but each flag changes the operation’s semantics. The
mistakes that show up in production are not exotic; they are
forgetting -m (no message), forgetting -a (the tag is
lightweight), or running git tag -d against a remote
expecting it to remove the remote tag (it does not). This
lesson covers the flags you need for day-to-day release
work; signing (-s, -u, --verify) is covered in XIX-04.
Creating tags
The git tag command takes a tag name, optionally a target
commit, and optionally a set of flags that determine what
kind of tag is created:
# Lightweight tag at HEAD
git tag v1.0.0
# Annotated tag at HEAD with a message
git tag -a v1.0.0 -m "Release v1.0.0"
# Annotated tag at a specific commit with a message
git tag -a v1.0.0 "$COMMIT_OID" -m "Release v1.0.0"
The three forms differ in two ways: whether a tag object is created (lightweight versus annotated, covered in XIX-01) and which commit is tagged (HEAD by default, or the commit named as the second positional argument).
# What -a actually does: the tag is an object, not a bare ref
git tag -a v1.0.0 -m "Release v1.0.0"
git cat-file -t v1.0.0
# tag
git cat-file -p v1.0.0
# object 8a3f9d2...
# type commit
# tag v1.0.0
# tagger Ops <ops@example.com> 1730000000 +0000
#
# Release v1.0.0
# Without -a, the same name produces a lightweight tag
git tag v0.9.0
git cat-file -t v0.9.0
# commit
The -m flag supplies the message inline. Without -m, Git
opens the editor and prompts for a message, which is the
correct interactive behaviour but is brittle in CI: a script
that hits git tag -a v1.0.0 without -m will hang
waiting for the editor. Always pass -m in scripts; the
message can be a variable built from the changelog or the
release notes:
RELEASE_MESSAGE=$(cat release-notes.md)
git tag -a v1.0.0 -m "$RELEASE_MESSAGE" "$COMMIT_OID"
Deleting tags
git tag -d <name> removes a tag from the local ref store.
It does not remove the underlying commit and does not
propagate to any remote:
# Delete a local tag
git tag -d v1.0.0
# Deleted tag 'v1.0.0' (was 8a3f9d2)
# The remote tag still exists
git ls-remote --tags origin v1.0.0
# 8a3f9d2... refs/tags/v1.0.0
Deleting the remote tag requires an explicit push:
# Delete a remote tag
git push origin --delete v1.0.0
# To git@github.com:example/repo.git
# - [deleted] v1.0.0
This is the most common foot-gun in tag work: an engineer deletes a local tag, believes the release is undone, and discovers the next deploy still resolves the remote tag. The two operations are independent; both are required to retire a release marker.
flowchart LR
L["git tag -d v1.0.0\nremoves local ref"] --> R["remote tag\nstill present"]
R --> P["git push origin --delete v1.0.0\nremoves remote ref"]
Listing tags
The default git tag invocation lists every tag in the
repository, in alphabetical order, one per line. The -l
flag takes a glob pattern and filters the list:
# All tags
git tag
# v0.9.0
# v1.0.0
# v1.1.0
# v2.0.0-rc1
# Tags matching v1.*
git tag -l "v1.*"
# v1.0.0
# v1.1.0
# Tags matching v*-rc*
git tag -l "v*-rc*"
# v2.0.0-rc1
The -l flag looks redundant when the argument is *
(matches everything), but it is the right form when a
script wants to filter: git tag -l always interprets its
argument as a pattern, so git tag -l "v2.*" is the
correct form and git tag "v2.*" is a syntax error.
git tag -l is also the right command to capture a list of
tags into a variable for scripting:
LATEST_V2=$(git tag -l "v2.*" | sort -V | tail -n 1)
echo "Latest v2 release: $LATEST_V2"
The sort -V (version sort) ensures v2.10.0 sorts after
v2.9.0; plain sort would put v2.10.0 before v2.9.0
because 1 sorts before 9 lexicographically.
Showing tag annotations with -n
git tag -n prints the annotation message alongside each
tag name. The optional <num> argument limits the message
to that many lines:
# One-line annotation summary
git tag -n
# v0.9.0 Lightweight placeholder; superseded
# v1.0.0 Release v1.0.0
# v1.1.0 Release v1.1.0 — TLS rotation
# First three lines of each annotation
git tag -n3
# v1.0.0 Release v1.0.0
# - Add new ingress controller
# - Bump base image to 1.2.3
# v1.1.0 Release v1.1.0 — TLS rotation
# - Rotate CA bundle
# - Pin cert-manager to 1.13
-n is the right command for a quick scan of the recent
release history without invoking git show on each tag.
The output is column-aligned with the tag name in the first
column and the annotation in the second; if a tag has no
annotation (lightweight), the second column is empty.
flowchart LR
A["git tag -n"] --> B["annotated tag\nshows message"]
A --> C["lightweight tag\nempty column"]
Production discipline
- Always pass
-aand-min release scripts. A release tag without-ais lightweight; a release tag without-mhangs on the editor in CI. Both failure modes are avoidable by passing both flags. - Pass the target commit explicitly.
git tag -a v1.0.0 -m "..." "$COMMIT_OID"is more robust thangit tag -a v1.0.0 -m "...", which tags whatever HEAD resolves to at the moment of the command. The two are equivalent today and may diverge tomorrow if a hook advances HEAD. - Quote patterns in
git tag -l "v1.*". Unquoted patterns are shell-expanded against the working directory, producing a pattern that Git never sees. - Treat
git tag -das a local-only operation. The remote tag is not removed. Usegit push origin --delete <tag>(or, on older Git,git push origin :refs/tags/ <tag>) to remove the remote tag.
Cross-course references
- Git, CI/CD & GitOps — Part XIX-01 (Lightweight versus
annotated tags) — the on-disk difference the
-aflag encodes. - Git, CI/CD & GitOps — Part XIX-03 (Tag pushing and
fetching) —
git push --tagsandgit push --follow-tagsas the propagation step aftergit tag. - Git, CI/CD & GitOps — Part XIX-04 (Signed tags) —
-sand-uas the signing variants of-a. - Ansible for Production Sysadmins — Part XXXVII
(RepoArch) —
git taginvocations inside release playbooks, where quoting and-mdiscipline matters.
Quiz
Knowledge check · 4 questions
Q1. Which `git tag` invocation creates an annotated tag at a specific commit with a message inline?
Q2. `git tag -d v1.0.0` removes the tag from the remote as well as from the local repository.
Q3. What does `git tag -n3` print, and how does its output differ between an annotated tag and a lightweight tag?
Q4. Diagnose a release pipeline that creates lightweight tags because the CI step forgot `-a`, and propose the corrected invocation.
A CI pipeline runs `git tag v$RUNTIME_VERSION` at the end of a release job, then `git push origin $RUNTIME_VERSION`. The pipeline succeeds. The audit team later asks for the release notes of v3.2.1 and runs `git tag -n v3.2.1` — the output is empty for v3.2.1 but populated for v3.1.7. The team realises the recent releases are lightweight tags because the pipeline never passed `-a` or `-m`.
Passing score: 75%. Answers are checked in this browser.