A release tag gives people a readable name for a particular source revision. Unlike a branch, it does not advance when another commit is created. However, Git does not make a tag reference physically impossible to change: a user with sufficient access can replace or delete it. The dependable release contract is therefore a combination of a recorded object ID, an immutable-name policy, and controls over who may update release references.
Two kinds of tags
A lightweight tag is a reference directly naming an object, usually a commit. An annotated tag names a separate tag object containing a target, tagger identity, timestamp, message, and optionally a signature. The identity is the person making the tag, which need not be the commit’s author. An annotated tag is not automatically signed just because it contains this metadata.
For an annotated release, git rev-parse refs/tags/v2.4.1 identifies the tag object, while git rev-parse 'refs/tags/v2.4.1^{commit}' resolves its target commit. Record both when annotation or signature identity matters. Checking only the commit misses changes to the tag message or signer; checking only the readable name does not establish either identity.
Why sign a release
git tag -v verifies a signed tag using the configured signing backend and locally available trust information. A successful cryptographic check is useful only if the key is also one your release policy authorizes. A mathematically valid signature from an unknown person does not establish that the project’s maintainer approved the release.
A signed source tag also does not prove that a downloaded binary was built from that source. Artifact checksums, provenance, and the build process establish that separate relationship. Do not pipe verification through head and then trust the pipeline’s last exit status: truncating the display can conceal the verifier’s failure. Capture and enforce the verification command’s own result.
Move with care
Correcting an unpublished local tag is different from replacing one that consumers already fetched. Once distributed, prefer a new version name and document the withdrawn release. A security fix does not require rewriting the old tag; preserving the original reference makes affected deployments easier to identify. Server-side protection should reject unauthorized release-tag updates and deletions.
If an exceptional replacement is approved, publish the exact old and new object IDs and coordinate that single reference with consumers. A blanket forced fetch of every tag can overwrite unrelated local references and should not be routine repair advice. Consumers should never discover silently that two different commits were both presented as the same release.
When it breaks
A floating name such as latest can resolve differently between builds. Record the resolved commit with every artifact and deployment, even if a convenience name is displayed to humans. Reusing a name does not make two builds identical, and pinning a commit does not by itself freeze external dependencies or compiler versions.
Another failure is a release tag available only in one local clone. Ordinary branch pushes do not necessarily transfer it. --follow-tags concerns qualifying annotated tags reachable from the pushed commits, not every local tag. Verify the exact remote reference and its object ID with git ls-remote --tags; a substring match on the version name can accidentally accept another release. Remote visibility proves distribution, not build correctness or signature trust.
Check it quickly
Run this with Python 3 and Git. It creates an isolated temporary repository, supplies an example identity, disables signing for the demonstration, and creates no remote. It never tags the surrounding project. Python removes the disposable repository afterward.
import os
import subprocess
import tempfile
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-tag-demo-") as directory:
def git(*args, check=True):
return subprocess.run(
["git", "-c", "user.name=Tag Example",
"-c", "user.email=tags@example.invalid",
"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
"-c", "core.hooksPath=/dev/null", *args],
cwd=directory, env=env, text=True, capture_output=True, check=check,
)
git("init", "-q")
git("commit", "--allow-empty", "-qm", "first")
first = git("rev-parse", "HEAD").stdout.strip()
git("tag", "-a", "demo-v1", "-m", "example release")
git("tag", "demo-bookmark")
print("annotated:", git("cat-file", "-t", "demo-v1").stdout.strip())
print("lightweight:", git("cat-file", "-t", "demo-bookmark").stdout.strip())
git("commit", "--allow-empty", "-qm", "second")
target = git("rev-parse", "demo-v1^{commit}").stdout.strip()
print("tag-still-first:", target == first)
print("head-moved:", git("rev-parse", "HEAD").stdout.strip() != first)
verification = git("tag", "-v", "demo-v1", check=False)
print("unsigned-rejected:", verification.returncode != 0)
Expect object types tag and commit, followed by three True results. A new commit advances the branch without moving the tag, and an unsigned annotation fails signature verification. The exercise intentionally does not demonstrate a trusted signature or remote publication; those need the project’s authorized key policy and actual remote-reference checks.
Further reading
Sources
- git-tag reference, tag objects and the consequences of replacing a distributed tag, checked 2026-09-07.
- Git tagging basics, lightweight and annotated tags plus sharing behavior, checked 2026-09-07.