Skip to content

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

CI: Verify All Tags

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)