Ch. 16 · Git & CI/CD

CI Release Versions and Artifact Traceability

Derive release versions from annotated Git tags, stamp them into every artifact, and fix the shallow-clone CI failure that breaks git describe.

~6 min readadvancedupdated Oct 7, 2026

A customer reports a bug in “version 2.3.” Can you tell, in under a minute, which commit that is, which build produced the file they’re running, and whether the fix you merged yesterday is in it? If the answer involves searching Slack for “who deployed on Tuesday,” versioning isn’t doing its job.

Interviewers asking about release versioning want more than “we use semver.” They want to know how a version number connects to source and to artifacts, where that number comes from in CI, and what stops two different builds from having the same name.

What a version number has to do

Semantic versioning (MAJOR.MINOR.PATCH) is a promise to users about compatibility. Bump the major version for breaking changes, the minor for new backwards-compatible features, the patch for fixes. That’s the outward-facing half, and for libraries it matters a lot, because dependency ranges like ^2.3.0 rely on it.

The inward-facing half is traceability. A release version should identify exactly one commit and exactly one set of artifacts built from it. That means three rules:

  1. The version is derived from the source, not typed into a release form.
  2. Every artifact carries its version and commit inside it.
  3. A version, once published, never points at anything else.

Tags as the source of truth

The usual approach is to make an annotated Git tag the single source of truth for a release. An annotated tag (git tag -a v1.4.0 -m "Release 1.4.0") is a real object with a tagger, date and message, and it can be signed. A lightweight tag (git tag v1.4.0) is just a name for a commit. Release tooling should use annotated tags, and so does git describe by default.

git describe turns any commit into a name relative to the nearest annotated tag. In a test repository with three commits after v1.4.0:

$ git describe
v1.4.0-3-g8c12127
$ echo x >> app.txt && git describe --dirty
v1.4.0-3-g8c12127-dirty
Text

Read that as “three commits after v1.4.0, at commit 8c12127” (the g prefix stands for Git). The -dirty suffix means the working tree had uncommitted changes, which is a build you should never ship. Every CI build between releases gets a unique, sortable, traceable name for free.

The annotated-only default matters. With a lightweight tag on the same commit:

$ git tag v1.5.0-light
$ git describe
v1.4.0-3-g8c12127
$ git describe --tags
v1.5.0-light
Text

Plain git describe ignored the lightweight tag. If one person on the team creates release tags by hand without -a, release builds can come out named after the previous version. Using --tags everywhere or, better, creating tags only from the release pipeline avoids that.

Once v1.5.0 is tagged properly, a build of that exact commit describes as v1.5.0, with no suffix. That’s how a pipeline tells a release build from a development one.

The CI shallow-clone failure

Here’s the problem almost everyone hits once. CI systems clone shallowly to save time. GitHub’s actions/checkout fetches a single commit by default (fetch-depth: 1), and that fetch doesn’t include your tags. Reproduced with a local shallow clone:

$ git clone --depth 1 --no-tags file:///.../rel rel-shallow
$ cd rel-shallow
$ git describe
fatal: No names found, cannot describe anything.
Text

Fetching tags alone isn’t enough, because the tag points at a commit outside the shallow history:

$ git fetch --tags
$ git describe
fatal: No tags can describe 'a1bf4ea647785225f5cc32512fd316d1c6c49a7c'.
Try --always, or create some tags.
$ git fetch --unshallow
$ git describe
v1.5.0-1-ga1bf4ea
Text

In GitHub Actions, the fix is fetch-depth: 0 on the checkout step, which fetches all history and tags. For very large repositories, a partial clone (filter: blob:none) gets full history without downloading every file version. Whatever you do, don’t add --always to make the error go away. It falls back to a bare hash (a1bf4ea), and your release silently loses its version.

Getting the version into the artifact

The version and commit should be readable from the artifact itself, not just from the filename:

  • Container images: set labels at build time, org.opencontainers.image.version=v1.5.0 and org.opencontainers.image.revision=<full commit SHA>, and tag the image with the version. Record the digest the registry returns.
  • Go binaries: go build -ldflags "-X main.version=$(git describe --dirty)", and Go 1.18+ also embeds VCS info that go version -m ./app prints.
  • Node packages: npm version updates package.json and creates a tag. For services, write a build-info.json with the version and SHA during the build.
  • Running services: expose the version on a health or info endpoint and include it in logs and error reports. When an alert fires, the first question is “which version is this?”, and the answer should be in the alert.

Generate this once in the build job and pass it along. Rebuilding for each environment risks stamping the same version on different bytes, as the build-once-promote guide explains.

A version means one thing forever

Suppose v2.3.0 shipped with a bug, and someone fixes it and moves the tag: git tag -f v2.3.0 && git push -f origin v2.3.0. Now there are two different v2.3.0 builds. Customers who downloaded the first can’t tell. Caches, mirrors and package registries may have either one. Any bug report against v2.3.0 is ambiguous. And Git itself won’t update an existing tag on fetch without --force, so teammates’ clones keep the old one.

The fix is always a new version, v2.3.1. Package registries enforce this: npm refuses to publish over an existing version, and Maven Central doesn’t allow re-releasing. On GitHub, tag rulesets can block updating or deleting tags matching v* so nobody can do it by accident.

The same logic applies to container tags. myapp:2.3.0 should never be overwritten. Mutable tags like latest or stable are fine as moving labels, as long as nothing that matters deploys by them.

The traceability chain

Put together, every link is recorded and can be followed both ways:

  1. Version v1.5.0 → annotated tag → commit a1bf4ea (and the PRs merged into it).
  2. Commit → the CI run that built and tested it → the artifact digest it pushed.
  3. Digest → every deployment that used it, with time and environment.

With that, “is yesterday’s fix in production?” is git tag --contains <fix-sha> compared with the version on the production info endpoint. “What changed between the versions?” is git log v1.4.0..v1.5.0 --oneline. Release notes generated from that log, or from conventional commit messages, stay accurate because they come from the same source.

Interview practice

Your team’s release process: a developer bumps version in package.json, merges to main, and CI builds and publishes app-<version>.tgz. Twice this quarter, two different builds were published with the same version, and once a hotfix tag was force-moved. Support can’t tell customers which build they have. Redesign the process.

Answer

The version is typed by hand and isn’t tied to a commit, so any build of main before the next bump reuses it. Nothing marks which commit is the release, and tags can be moved.

I’d make an annotated tag the trigger and the source of truth. A release workflow runs on pushes of tags matching v*. It checks out with fetch-depth: 0 so git describe works, verifies that git describe --exact-match equals the tag (so it’s building exactly the tagged commit), and refuses to run if the tree is dirty. It derives the version from the tag, writes it and the full commit SHA into package.json and a build-info.json inside the artifact, builds once, and publishes. Publishing must fail if the version already exists. Builds from main that aren’t tagged get the git describe name, like v2.3.0-14-g5e2f1aa, so they’re traceable and can’t be mistaken for releases. A tag ruleset blocks updates and deletion of v* tags, so a bad release is fixed by tagging v2.3.1. Finally, the app reports its version and commit on an info endpoint and in support diagnostics, so support can read the exact build from the customer’s install.

Further reading

More in Git & CI/CD

esc