Git Tagging Strategy & Build Versioning¶
Tags pin a human-readable name to a commit. For DevOps, that name is the version that ships to production, the artifact identifier in the registry, the rollback target in the incident channel, and the SHA your package.json or pyproject.toml resolves to. Picking the right tag type and naming scheme up front is the difference between a clean release pipeline and a 3am Slack thread where nobody knows what version is running.
This guide covers the three tag types, signing tags for supply-chain integrity, semver and pre-release conventions, git describe for build stamping, and the CI/CD patterns that turn tags into deployable artifacts.
The Three Tag Types¶
git tag v1.4.0 # lightweight: just a ref to a commit
git tag -a v1.4.0 -m "release notes" # annotated: a real Git object
git tag -s v1.4.0 -m "release notes" # signed: annotated + GPG/SSH signature
Lightweight tags are a named pointer. They carry no author, no date, no message, no signature. They look like a tag but act like a branch that never moves. Avoid them for releases. Use them for personal bookmarks (git tag wip-debug-2025-08-30) that you never push.
Annotated tags are a full Git object: they store the tagger, date, message, and an optional signature. They can be verified with git verify-tag, they show up in git log like commits, and they survive a push to a remote. Use them for every release tag.
Signed tags are annotated tags whose object is signed with your GPG or SSH key (gpg.format = ssh). They are the only tag type that proves both who tagged the commit and that the tag points to that exact commit. Use them for anything that leaves your org, anything you sign commits on, and anything your CI enforces with git verify-tag.
Verify a tag:
git tag -v v1.4.0 # show tag + verify signature
git show v1.4.0 --no-patch # show the tag object (annotated only)
Listing tags with their underlying SHA:
git show-ref --tags
# v1.4.0 abc123...
# v1.4.0^{} def456... # ^{} peels the tag object to the commit
The ^{} syntax is critical: a signed tag's name points to the tag object, but the commit the tag refers to is reached by following the tag object. CI scripts that need the commit SHA almost always want git rev-list -1 v1.4.0^{} or git describe (covered below).
Semantic Versioning for Release Tags¶
The convention that holds up under CI/CD pressure is SemVer 2.0.0:
- MAJOR — breaking changes to public API or behavior
- MINOR — new features, backwards compatible
- PATCH — bug fixes, backwards compatible
- PRERELEASE —
alpha.1,beta.2,rc.1for non-production builds - BUILD —
+sha.abc1234or+build.4821for traceability, ignored by version comparators
Pre-release tags must come before the stable release on the timeline. 1.5.0-rc.1 is older than 1.5.0. SemVer specifies lexical ordering for identifiers, but in practice the pre-release segments are numeric and monotonically increase.
Tag a release:
git tag -a v2.3.0 -m "Release 2.3.0 — add bulk import endpoint"
git push origin v2.3.0
# or push every tag in one go
git push origin --tags
Pre-release:
The v prefix is convention, not requirement. It is popular because it sorts cleanly and the tag stands out in shell completion. Some teams omit it (2.3.0) to keep the tag name identical to the version string used in artifact paths. Pick one and enforce it.
Signing Release Tags¶
Signing a tag is the only way a downstream consumer can verify that the v2.3.0 they pulled is the v2.3.0 you tagged, not a re-tagged commit from a compromised account. See docs/devops-workflows/git-commit-signing.md for key setup; the workflow here is the same.
# SSH signing (Git 2.34+)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git tag -s v2.3.0 -m "Release 2.3.0"
git tag -v v2.3.0
Enforce signed tags in CI:
git fetch --tags origin
for tag in $(git tag -l 'v*'); do
git verify-tag "$tag" || { echo "unsigned tag: $tag"; exit 1; }
done
Refuse to deploy an unsigned release tag. The Forgejo or GitHub Action that publishes the artifact should call git verify-tag and fail the pipeline if the tag is not signed.
Tag-Based Deployments¶
The canonical pattern: a release tag is the trigger. The tag SHA is the deployable artifact. No human decides "is this version ready" — the tag is the answer.
# CI checkout triggered by push of v2.3.0
git fetch --depth=1 origin tag v2.3.0
git checkout v2.3.0^{} # peel to commit
COMMIT_SHA=$(git rev-parse HEAD)
echo "$COMMIT_SHA" > build/sha.txt
The ^{} peel matters. Without it, git checkout v2.3.0 gives you a detached HEAD whose refname is the tag, but subsequent commands may behave oddly. git checkout v2.3.0^{} resolves to the commit and leaves you in a normal detached HEAD on that commit.
Deploy gates often look like:
# Only deploy from release/* branches or signed v* tags
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" =~ ^release/ ]]; then
echo "release branch deploy allowed"
elif git verify-tag HEAD 2>/dev/null; then
echo "signed tag deploy allowed"
else
echo "refusing deploy: not a release branch or signed tag"
exit 1
fi
git describe: Build Versioning from Tags¶
git describe turns "where am I relative to the nearest tag?" into a single string. It is the standard answer to "what version is this build?"
git describe
# v2.3.0-4-gabc1234
# | | |
# | | +- 'g' + abbreviated commit SHA
# | +---- number of commits since the tag
# +---------- the most recent reachable tag
This is your build version: v2.3.0 was the last release, this build is 4 commits past it, at abc1234. Drop the string into your artifact:
VERSION=$(git describe --tags --always --dirty)
# v2.3.0-4-gabc1234
# or on a tag itself:
# v2.3.0
# or with uncommitted changes:
# v2.3.0-4-gabc1234-dirty
The flags:
--tags— match lightweight and annotated tags (default is annotated only)--always— fall back to abbreviated SHA if no tag is reachable--dirty— append-dirtyif the working tree has uncommitted changes--long— always show the count and SHA, even on a tag (v2.3.0-0-gabc1234)--abbrev=N— SHA prefix length (default 7)--match=<pattern>— restrict tag matching to a glob (--match='v*'to skipwip-*)
Stamp a build:
# Makefile target
version:
$(eval VERSION := $(shell git describe --tags --always --dirty))
@echo "Building $(VERSION)"
go build -ldflags "-X main.version=$(VERSION)" -o bin/app .
# Docker build
VERSION=$(git describe --tags --always --dirty)
docker build --build-arg VERSION="$VERSION" -t myapp:"$VERSION" .
The --dirty flag is the most underused feature. A build that has uncommitted changes is not the same artifact as a clean checkout, and git describe will tell you that with a single suffix. Bake it into every CI build and you will never again wonder whether the artifact in the registry came from a clean tree.
Pre-Release Tag Patterns¶
The v2.4.0-rc.1 style is the safest convention because SemVer-aware tooling (npm, cargo, go modules, pip) sorts it below the corresponding stable release. That means publishing 2.4.0-rc.1 to a "staging" registry will not be picked up by a >=2.4.0 consumer constraint.
git tag -a v2.4.0-alpha.1 -m "alpha cut for 2.4.0 sprint"
git tag -a v2.4.0-beta.1 -m "feature freeze for 2.4.0"
git tag -a v2.4.0-rc.1 -m "release candidate, blocking on QA sign-off"
git tag -a v2.4.0 -m "Release 2.4.0"
For long-running release trains, some teams cut a release/2.4 branch and tag from there. The branch is the only place release candidates and the final tag come from; main keeps moving. This pairs with the worktree pattern: a worktree pinned to release/2.4 is the only working tree allowed to push a v2.4.* tag.
Tag Hygiene¶
Tags are cheap to create and expensive to mis-create. A tag with a typo or pointing to the wrong SHA is a release incident waiting to happen.
Delete a local tag:
Delete a remote tag (both refs must be removed):
Re-tag after a fix (only if the bad tag is not yet published):
If the bad tag already shipped, do not delete it. Tag a new point release (v2.3.1) and move on. Force-pushing a moved tag is a supply-chain anti-pattern: anyone who already resolved the old SHA now has a different artifact under the same name.
Protect tags on the remote:
# Forgejo / Gitea: protected tags pattern in admin/settings
# GitHub: rulesets → tag patterns → require signed tags, restrict deletion
# GitLab: Settings → Repository → Protected tags → v*
CI/CD Integration Patterns¶
Pattern: Tag-driven Docker image¶
#!/usr/bin/env bash
# build-and-push.sh — invoked on tag push
set -euo pipefail
if [[ -z "${GIT_TAG:-}" ]]; then
echo "GIT_TAG not set; this workflow only runs on tag push"
exit 1
fi
git fetch --depth=1 origin tag "$GIT_TAG"
git checkout "${GIT_TAG}^{}" # peel to commit
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse HEAD)
docker build -t registry.example.com/myapp:"$VERSION" \
-t registry.example.com/myapp:"$GIT_TAG" \
--build-arg VERSION="$VERSION" \
--build-arg COMMIT="$COMMIT" .
docker push registry.example.com/myapp:"$VERSION"
docker push registry.example.com/myapp:"$GIT_TAG"
Pattern: Version file generation for downstream consumers¶
# Generate a version.json that downstream tools can read without git
git describe --tags --always --dirty --long > version.txt
COMMIT=$(git rev-parse HEAD)
DATE=$(git log -1 --format=%cI)
cat > version.json <<EOF
{
"version": "$(cat version.txt)",
"commit": "$COMMIT",
"tag": "${GIT_TAG:-}",
"build_date": "$DATE"
}
EOF
Pattern: Pre-release gate¶
# Only promote rc tags to the staging environment
if [[ "$GIT_TAG" =~ -rc\.([0-9]+)$ ]]; then
DEPLOY_ENV=staging
elif [[ "$GIT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
DEPLOY_ENV=production
else
echo "tag $GIT_TAG is not a release or rc tag"
exit 1
fi
Troubleshooting¶
git describe says "No names found, cannot describe anything."
You are on a commit that has no reachable tag. Either cut a tag or pass --always to fall back to the abbreviated SHA.
git checkout v2.3.0 lands on a detached HEAD, then git status complains.
Expected. You are on a detached HEAD on the commit the tag points to. git switch - or git switch main to get back to a branch. To make the checkout explicitly a commit, use git checkout 'v2.3.0^{}' to peel through the tag object.
Signed tag verifies locally but not in CI.
The CI runner does not have your public key imported. Distribute the team's signing public keys to ~/.gnupg (or ~/.config/git/allowed_signers for SSH) on the runner, or use a signing key generated on and bound to the runner.
Lightweight tag was pushed by accident and consumers now can't git show v2.3.0 properly.
Lightweight tags don't have an object to show. Convert it: delete the lightweight tag, then git tag -a v2.3.0 -m "..." at the same commit. If the lightweight tag already shipped to a remote, delete it everywhere (git push origin :refs/tags/v2.3.0) and re-tag.
git describe --tags picks a wip-* tag instead of v*.
Restrict the match pattern: git describe --tags --match='v*'. The first matching tag in commit-time order is chosen.
Two CI runs created the same tag with different SHAs (race condition).
Tags are not atomic across runners. The first push wins; the second push fails with rejected (non-fast-forward). The "winner" is whoever pushed first. For shared release tags, gate the push on a coordinator job: a single Forgejo Actions workflow with concurrency: release-tag that runs the build and pushes the tag from one place.
Verification Checklist¶
Before shipping a release tag:
- [ ] Tag is annotated (
git show v1.4.0 --no-patchshows tagger, date, message) - [ ] Tag is signed if your org requires it (
git tag -v v1.4.0succeeds) - [ ] Tag points to the expected commit (
git rev-parse v1.4.0^{}matches the merged PR) - [ ] Tag name matches your naming convention (
vMAJOR.MINOR.PATCH[-PRERELEASE]) - [ ]
git describe --tags --always --dirtyproduces a clean version (no-dirtysuffix) - [ ] The remote has the tag (
git ls-remote --tags origin | grep v1.4.0) - [ ] Branch protection prevents tag deletion and movement on the remote
Summary¶
Release tags are the contract between your repo and the rest of the world. Annotated signed tags with SemVer names give you a stable, verifiable, machine-readable version string. git describe turns that version into a build identifier every artifact can carry. CI patterns that gate on tag presence, tag type, and tag signature are how you keep that contract honest as the team scales.