Git Tagging & Build Versioning — Quick Reference
Tag Types
| Type |
Command |
Carries |
Use For |
| Lightweight |
git tag v1.4.0 |
name + commit only |
local bookmarks, never push |
| Annotated |
git tag -a v1.4.0 -m "..." |
tagger, date, message |
releases, deploys |
| Signed |
git tag -s v1.4.0 -m "..." |
annotated + GPG/SSH signature |
supply-chain critical releases |
SemVer Naming
vMAJOR.MINOR.PATCH # v2.3.0
vMAJOR.MINOR.PATCH-PRERELEASE # v2.4.0-rc.1
vMAJOR.MINOR.PATCH+METADATA # v2.3.0+build.4821
Common Operations
# Create / push
git tag -a v2.3.0 -m "Release 2.3.0"
git push origin v2.3.0
git push origin --tags # all local tags
# Inspect
git show v2.3.0 --no-patch # tag object (annotated only)
git show-ref --tags # all tags with SHAs
git rev-parse v2.3.0^{} # peel tag to commit SHA
git tag -v v2.3.0 # verify signature
# Delete
git tag -d v2.3.0 # local
git push origin --delete v2.3.0 # remote
git push origin :refs/tags/v2.3.0 # remote (legacy syntax)
# Re-tag (only if not yet published)
git tag -d v2.3.0 && git tag -a v2.3.0 -m "Release 2.3.0 (correct)"
git describe — Build Versioning
git describe
# v2.3.0-4-gabc1234
# | | |
# | | +- 'g' + abbreviated commit SHA
# | +---- commits since tag
# +---------- nearest reachable tag
# Flags
git describe --tags --always --dirty --long --match='v*'
# --tags : match lightweight too (default: annotated only)
# --always : fall back to SHA if no tag
# --dirty : append -dirty if working tree has changes
# --long : always show count and SHA
# --match : restrict tag glob
# Stamp a build
VERSION=$(git describe --tags --always --dirty)
echo "Building $VERSION"
Signing Setup (One-Time)
# SSH signing (Git 2.34+)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global tag.gpgsign true # sign all tags by default
# GPG signing
git config --global user.signingkey <KEYGRIP>
git config --global tag.gpgsign true
git fetch --tags origin
for tag in $(git tag -l 'v*'); do
git verify-tag "$tag" || { echo "unsigned: $tag"; exit 1; }
done
CI: Build on Tag Push (Forgejo Actions)
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build
run: |
VERSION=$(git describe --tags --always --dirty)
echo "VERSION=$VERSION" >> $GITHUB_ENV
docker build -t myapp:$VERSION .
CI: Deploy Gate by Tag Pattern
case "$GIT_TAG" in
v[0-9]*.[0-9]*.[0-9]*-rc.*) ENV=staging ;;
v[0-9]*.[0-9]*.[0-9]*) ENV=production ;;
*) echo "not a release tag"; exit 1 ;;
esac
Pre-Release Sequence
git tag -a v2.4.0-alpha.1 -m "alpha cut"
git tag -a v2.4.0-beta.1 -m "feature freeze"
git tag -a v2.4.0-rc.1 -m "release candidate"
git tag -a v2.4.0 -m "Release 2.4.0"
Gotchas
git checkout v2.3.0 lands on a detached HEAD; use v2.3.0^{} to be explicit
- Lightweight tags have no tag object —
git show v2.3.0 shows the commit, not metadata
git describe default only matches annotated tags; pass --tags to include lightweight
--dirty suffix is the cheapest way to flag a build that came from a non-clean tree
- Once a tag is published, never force-move it; cut a new point release instead
- Signed tags need the signer's public key on the verifying CI runner (use
allowed_signers for SSH)