Release Labels That Stay Trustworthy with git tag
git tag looks harmless right up until someone force-replaces a release name and every clone that already fetched it disagrees about what v2.4.0 means. This walks through creating an annotated release tag, checking what it actually points to, listing tags by version instead of alphabetically, and fixing a mistaken tag without quietly rewriting someone else's history.
The route
Jump straight to the step you need, or tick off Done means at the end.
Allow about fifteen minutes. You need an existing Git repository with at least one commit and permission to read its history. No command in the normal workflow needs sudo. Signing needs a usable GnuPG key, and publishing a tag needs push access to the remote. The examples were checked with Git 2.43.0 from Ubuntu package git-man 1:2.43.0-1ubuntu7.3.
Checkpoint
Already have a tag and just need to inspect it? Start at step 3. Tag already pushed somewhere? Read the warning in step 6 before you touch -f.
1. Confirm the repository and the installed command
Run these read-only checks from the repository holding the release commit:
$ git --version
git version 2.43.0
$ git status --short
$ git log -1 --oneline
4e1b2c3 Prepare the release
- Your IDs will differ. The commit hash and message are examples, not values to paste.
- An empty status matters. It confirms the tag will name the committed tree, not whatever is sitting uncommitted in your working copy. A tag cannot include work that has not been committed, so if
git status --shortshows changes, decide deliberately whether they belong in the release first. - Pick a name that survives everywhere. Tags live under
refs/tags/and must pass Git's reference-name checks. Use a predictable form such asv2.4.0, skip spaces, and do not rely on case to distinguish two tags: some filesystems cannot tell them apart.
2. Create an annotated release tag
For a real release, always create an annotated tag with a message:
$ git tag --annotate v2.4.0 --message 'Release 2.4.0'
$ git show --no-patch --format=fuller v2.4.0
tag v2.4.0
Tagger: Your Name <[email protected]>
TaggerDate: ...
Release 2.4.0
commit 4e1b2c3...
The tagger identity and object ID are machine-specific, so do not compare those lines literally. What matters is that git show displays an annotation and the commit the tag names. With -a or --annotate, Git builds a tag object carrying the tagger, date and message; passing the message inline avoids an editor popping up, which keeps the command safe to repeat from a release script.
Skip -a, -s, -u, -m and -F entirely and git tag v2.4.0 makes a lightweight tag instead: just a name pointing at an object, fine for a private marker but the wrong default for a release. Annotated tags carry metadata and are what tools like git describe expect to find.
The target defaults to HEAD. Name it explicitly when the release commit is not what is checked out:
$ git tag --annotate v2.4.0 --message 'Release 2.4.0' RELEASE_COMMIT
Replace RELEASE_COMMIT with a real commit ID or other object name. Git refuses to replace an existing tag unless you pass --force, so a duplicate-name error is a useful stop sign, not a problem to suppress.
3. Inspect tags without changing them
List everything, or narrow it with a shell wildcard. Quote the pattern so your shell does not expand it against filenames in the current directory:
$ git tag --list 'v2.*'
v2.3.0
v2.4.0
$ git tag --list --sort='version:refname' 'v*'
v2.3.0
v2.4.0
The default sort is lexicographic unless tag.sort is configured, so plain alphabetical order can put v2.10.0 ahead of v2.9.0. version:refname treats matching names as versions instead of strings; add a leading dash, --sort='-version:refname', for newest first.
Show the annotation while you list:
$ git tag --list --format='%(refname:short) %(subject)' 'v2.*'
v2.3.0 Release 2.3.0
v2.4.0 Release 2.4.0
$ git tag -n1 v2.4.0
v2.4.0 Release 2.4.0
--format fields are ref-format fields, not shell variables. For a quick target check, compare the tag and commit IDs directly:
$ git rev-parse v2.4.0^{commit}
4e1b2c3...
$ git show -s --format='%H %s' v2.4.0^{commit}
4e1b2c3... Prepare the release
The ^{commit} suffix peels an annotated tag down to the commit it ultimately names, so you never confuse the tag object's own ID with the release commit's ID.
4. Filter tags around a commit
Investigating a release without touching any refs:
$ git tag --list --contains HEAD
v2.4.0
$ git tag --list --points-at HEAD
v2.4.0
$ git tag --list --merged HEAD --sort='version:refname'
v2.3.0
v2.4.0
--containsfinds tags whose commits contain the selected commit.--points-atis narrower: only tags attached directly to that object.--mergedfinds tags whose commits are reachable from the selected commit.- Need the opposite? Use
--no-containsor--no-merged. All of these imply listing, so tacking on-lis redundant.
5. Verify a signed tag
An annotated tag is not automatically signed. For an authenticity check, create a GPG-signed tag with --sign, or pick a specific key with --local-user KEY_ID:
$ git tag --sign v2.4.0 --message 'Release 2.4.0'
$ git tag --verify v2.4.0
gpg: Signature made ...
gpg: Good signature from "Release Maintainer ..."
object 4e1b2c3...
type commit
tag v2.4.0
Only bother signing when the key is actually configured and recipients know how to check it. A local Good signature result means GnuPG verified the cryptographic signature; it does not prove the key belongs to who you think it does. Treat a signing failure as a reason to stop the release, never as a reason to fall back to an unsigned tag quietly.
user.signingKey selects a default key. tag.gpgSign can force signing on every tag, and --no-sign overrides that for one command. Both are security-sensitive settings: review changes to them separately from a release.
6. Correct a tag without rewriting trust by accident
Warning
git tag --force replaces an existing local tag. That is a history and release-management change, not a harmless retry.
If the tag never left your repository, retag the correct commit deliberately:
$ git tag --force --annotate v2.4.0 --message 'Release 2.4.0' CORRECT_COMMIT
$ git rev-parse v2.4.0^{commit}
CORRECT_COMMIT_ID
Replace both placeholders with the intended commit. Before you force anything, record the old target so you have an audit trail:
$ git rev-parse v2.4.0^{commit} > /tmp/v2.4.0-old-commit
$ cat /tmp/v2.4.0-old-commit
If the tag was pushed, or anyone else could have fetched it, use a new name instead, such as v2.4.1. Existing clones should never silently change what v2.4.0 means to them. If reusing the name is unavoidable, announce the correction, coordinate the change, and tell everyone to delete their old tag and fetch the replacement explicitly. A normal pull will not repair a changed tag for them.
Deleting is also destructive, and local only:
$ git tag --delete v2.4.0
Deleted tag 'v2.4.0' (was 4e1b2c3)
There is no special undo command, but you can recreate the tag from the recorded commit and original message. A remote tag needs its own coordinated remote deletion or update; deleting locally does not touch the remote at all.
Done means
- The tag names the intended commit, confirmed with
git rev-parse TAG^{commit}. - You used an annotated tag for release metadata, or deliberately chose a lightweight marker instead.
- Tag listings use a quoted pattern and version-aware sorting where it matters.
- A signed tag was only trusted once its key identity was verified, not just because GnuPG reported a good signature.
- You have not force-replaced a tag that others may already have fetched, without an explicit correction plan.