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)
The parts of a describe stringThe tag names the nearest release. The distance counts commits since that tag, so it grows as development continues. The g-prefixed abbreviated object ID identifies the exact commit. A dirty suffix marks uncommitted changes in the working tree.v2.4.0nearest tag-3commits since-g1a2b3c4exact commit-dirtyuncommitted changesdistance zero and no dirty suffix means the build is exactly the release

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.

Why does git describe fail or look wrong?If it reports no names found, the clone has no tags or not enough history, so fetch tags and deepen. If it picks an unexpected tag, a non-release tag is nearer, so add a match pattern. If the version has a dirty suffix in CI, a build step modified tracked files before describe ran.What is wrong with the version?No names foundShallow / no tagsfetch-depth 0wrong tag chosenOther tags nearer--match 'v[0-9]*'-dirty in CIFiles modifiedrun describe firstrun describe before any build step that writes into the working tree

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"
Version strings for different buildsA build of the release tag gets a plain version. A development build after the tag gets a pre-release style version with the distance and commit. A build with uncommitted changes is marked dirty and must never be published. All three come from the same describe call.describe outputSemVer formrelease buildv2.4.02.4.03 commits laterv2.4.0-3-g1a2b3c42.4.0-dev.3+g1a2b3c4uncommitted changes…-dirty…dirty — never publishevery build is identifiable; only exact tags are releasable

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.