Deriving versions from git describe Jump to heading
Hard-coding a version number in a source file means remembering to bump it, and builds between releases all claim to be the last release. git describe computes a version from history instead: the nearest tag, how many commits have been made since, and the abbreviated commit ID. A build of the tagged commit gets v2.4.0; a build three commits later gets v2.4.0-3-g1a2b3c4, which is unique, ordered and traceable. Tools such as setuptools-scm, Go’s build info and many release scripts rely on it. Getting it right in practice means handling a few details: shallow clones in CI that have no tags, dirty working trees, tag patterns, and converting the raw output into the version format your ecosystem expects. This page covers them, within release tagging and versioning.
When to use this approach Jump to heading
- Version numbers live in source files and are often forgotten or wrong.
- Development builds all report the same version as the last release.
- You want every build traceable to an exact commit from its version string alone.
- You use per-package tags in a monorepo, as in tagging packages independently in a monorepo.
Step 1 — Read describe’s output Jump to heading
git describe finds the most recent annotated tag reachable from a commit and reports the distance and abbreviated ID.
git describe # v2.4.0 (on the tag)
git describe # v2.4.0-3-g1a2b3c4 (3 commits later)
git describe --dirty # v2.4.0-3-g1a2b3c4-dirty (uncommitted changes)
git describe --long # v2.4.0-0-g8b3e4f2 (always show distance)
git describe --abbrev=0 # v2.4.0 (tag only) Only annotated tags count unless --tags is passed; release tags should be annotated anyway, as explained in annotated vs lightweight tags.
Step 2 — Restrict to release tags Jump to heading
Repositories often carry tags that are not releases: deploy markers, archive tags, per-package tags. --match and --exclude limit which tags describe considers.
git describe --match 'v[0-9]*' --exclude '*-rc*' # final releases only
git describe --match 'billing-api@*' # one package in a monorepo Step 3 — Make it work in CI’s shallow clones Jump to heading
CI usually clones with depth one and no tags, so describe fails with “No names found”. Fetch tags and enough history, or full history.
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history and all tags
- run: echo "VERSION=$(git describe --dirty --match 'v[0-9]*')" >> "$GITHUB_ENV" # Alternative for very large repositories: shallow history, but with tags
git fetch --tags --depth=200 origin
git describe --match 'v[0-9]*' || echo "no tag within 200 commits — deepen further" The trade-offs of history depth are in shallow clone vs full history in CI.
Step 4 — Convert to your ecosystem’s version format Jump to heading
Raw describe output is not valid SemVer or PEP 440. Convert the distance and commit into the format’s build or local metadata fields.
d=$(git describe --long --dirty --match 'v[0-9]*') # v2.4.0-3-g1a2b3c4[-dirty]
tag=${d%%-*}; rest=${d#*-}; dist=${rest%%-*}; sha=${rest#*-g}; sha=${sha%-dirty}
ver=${tag#v}
# SemVer: pre-release for development builds, build metadata for the commit
if [ "$dist" = 0 ]; then semver=$ver; else semver="$ver-dev.$dist+g$sha"; fi
# PEP 440: dev release plus local version label
if [ "$dist" = 0 ]; then pep=$ver; else pep="$ver.post$dist.dev0+g$sha"; fi
case "$d" in *-dirty) semver="$semver.dirty"; pep="$pep.dirty" ;; esac
echo "$semver $pep" Tools such as setuptools-scm (Python), git describe-based ldflags in Go, and several JavaScript release tools do this conversion for you with configurable schemes; the script shows what they compute.
Step 5 — Embed the version in builds Jump to heading
Inject the computed version at build time rather than writing it to a tracked file, so the working tree stays clean and the version always matches the commit.
# Go
go build -ldflags "-X main.version=$(git describe --dirty --match 'v[0-9]*')" ./cmd/app
# Container images: label with both version and commit
docker build --label "org.opencontainers.image.version=$semver" \
--label "org.opencontainers.image.revision=$(git rev-parse HEAD)" -t app:"$semver" . Step 6 — Refuse to release dirty or untagged builds Jump to heading
Development builds can carry any describe string. Release builds must be exactly a tag, with a clean tree. Check before publishing.
v=$(git describe --dirty --exact-match --match 'v[0-9]*' 2>/dev/null) || { echo "not on a release tag"; exit 1; }
case "$v" in *-dirty) echo "working tree is dirty"; exit 1 ;; esac
echo "releasing $v" Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why does git describe pick an older tag than I expect? Jump to heading
It picks the nearest tag reachable from the commit. A newer tag on a release branch is not reachable from main, so main describes against the last tag on its own history.
Is the distance number stable? Jump to heading
For a given commit and tag, yes. It counts commits reachable from the commit but not from the tag, so rebasing or squashing history changes it.
Can two builds get the same version? Jump to heading
Only if they are the same commit (and both clean). The abbreviated ID makes versions unique; increase --abbrev in very large repositories to avoid ambiguity.
Related Jump to heading
- Release Tagging & Versioning — the parent topic.
- Tagging Pre-Releases and Release Candidates — how rc tags interact with describe.
- Linking a Container Image to Its Commit — recording the commit in artefacts.
- Automating Changelog Generation with semantic-release — creating the tags describe reads.