A version command can be perfectly correct on a maintainer’s workstation and still return no tag, an older tag, or a plausible but wrong release name in CI. The dangerous case is not the obvious failure. It is the checkout that contains every source file needed to compile, passes tests, and nevertheless lacks the Git evidence required to derive trustworthy release metadata.
The operational question is therefore narrower than “does CI have the repository?” A release-metadata gate must determine three things independently: whether the required tag references exist locally, whether enough ancestry exists to relate those refs to the checked-out commit, and whether the selected version policy actually means what the release process expects. Git documents shallow history, tag fetching, and git describe as separate behaviors; a successful checkout does not collapse them into one completeness guarantee. Git’s clone documentation, fetch documentation, and describe documentation define those boundaries explicitly.
This playbook is for build engineers, repository maintainers, and DevOps practitioners diagnosing that exact mismatch. The laboratory uses only synthetic files, a disposable bare origin, deterministic commits, file:// clones, and repository-local identity configuration. No registry publication, deployment authorization, credentials, OIDC, caches, artifact attestation, or semantic-release configuration is involved.
Specify the version contract before querying Git
Start by deciding what the version means. Do not run git describe, sort tags, or inspect the CI trigger and then retroactively call whichever string looks plausible “the release version.”
Four concepts that are often conflated have different acceptance rules.
An exact event-tag release version asserts that one named tag identifies the exact commit being released. For this policy, a tag on an ancestor is insufficient. A lightweight tag is not automatically invalid, but whether it is permitted must be explicit.
A nearest-ancestor build descriptor answers a different question: which permitted reachable tag forms the basis for a descriptive name for this later commit? In this article that command is git describe --tags --match 'v[0-9]*'. The --tags choice matters because default git describe considers annotated tags, while --tags also permits lightweight tags. Git documents both behaviors.
A tag-trigger identity is evidence about how CI was invoked. It must not silently substitute for the actual checked-out object. Record the trigger ref and HEAD independently.
Finally, semantic-version ordering is not git describe. Sorting all visible names and picking the numerically greatest version asks nothing about reachability from the target commit. Git describes a reachable-history search, not a SemVer maximum operation.
For readers coming from general Git workflow foundations, this is the important release-engineering refinement: ordinary source-control fluency does not establish that a bounded CI checkout contains the refs and graph required by a version algorithm.
The synthetic lab uses two explicit policies:
Policy | Declared target | Required evidence | Tag rule | Permitted output | Hold condition |
build-descriptor/v1 | Mainline commit D | Expected tag-ref set present; history sufficient to traverse from D; D equals HEAD | Annotated or lightweight because policy uses --tags | v1.2.1-1-g<abbrev> | Wrong HEAD, shallow/unknown ancestry, missing required refs, different descriptor basis |
exact-release/v1 | Commit C | refs/tags/v1.2.1 present and peelable; C equals HEAD | Lightweight explicitly allowed | exactly v1.2.1 | Ref absent, tag resolves elsewhere, wrong HEAD, policy/type mismatch |
The first policy is a build descriptor, not an event release. The second is an exact release identity, not “the nearest tag.”
The checked-out commit comes first. Record it as a full object ID and then derive metadata. Git’s rev-parse documentation provides both --is-shallow-repository and --show-object-format; ^{commit} can be used to require that a revision resolves to a commit. Those checks make repository identity and version naming separate evidence fields rather than one inferred string.
This gate approves version evidence only. It says nothing about whether a resulting artifact is authorized for deployment, signed correctly, or permitted to be published.
Build the deterministic origin and capture the positive control
The fixture is intentionally small enough to reason about without treating Git output as an oracle.
The executed test environment for this article was:
Evidence | Measured value |
Git | git version 2.47.3 |
Shell | GNU Bash 5.2.37(1)-release |
Repository object format | sha1 |
Repository ref format | files |
Clone transport | file:// |
Identity | repository-local synthetic name/email |
Global Git configuration writes | none |
The test was also rerun while ignoring global and system Git configuration using GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1; the deterministic commit IDs remained identical in this environment. That is a measured fixture observation, not a portability promise across modified scripts or different Git object formats.
A local filesystem path and a file:// URL are not interchangeable for this experiment. Git documents a local clone optimization for directly specified local repositories; using a URL avoids relying on that local optimization. Git also documents that --depth creates a shallow clone and that --no-tags configures remote.<name>.tagOpt=--no-tags, causing later normal fetches or pulls not to follow tags automatically. That is why every comparison clone below uses file://.
The graph is:
D main HEAD
C tag: v1.2.1 lightweight
* B tag: v1.2.0 annotated
| S tag: v9.0.0 annotated, unmerged side branch
|/
A rootThe complete harness below creates the bare origin, records the expected map before making comparison clones, builds five independent clones, performs the two partial repairs, and captures the commands that matter.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
umask 022
# Optional hard isolation from user/system Git configuration.
export GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_NOSYSTEM=1
LAB_ROOT="${1:-$(mktemp -d "${TMPDIR:-/tmp}/git-tag-lab.XXXXXX")}"
mkdir -p "$LAB_ROOT"
LAB_ROOT="$(cd "$LAB_ROOT" && pwd -P)"
ORIGIN="$LAB_ROOT/origin.git"
SEED="$LAB_ROOT/seed"
EXPECTED="$LAB_ROOT/expected.env"
printf 'LAB_ROOT=%s\n' "$LAB_ROOT"
printf 'BASH_VERSION=%s\n' "$BASH_VERSION"
git --version
git init --bare \
--object-format=sha1 \
--initial-branch=main \
"$ORIGIN" >/dev/null
git init \
--object-format=sha1 \
--initial-branch=main \
"$SEED" >/dev/null
# Repository-local test identity only.
git -C "$SEED" config user.name 'Refonte Release Lab'
git -C "$SEED" config user.email '[email protected]'
git -C "$SEED" config commit.gpgsign false
git -C "$SEED" config tag.gpgSign false
git -C "$SEED" config core.autocrlf false
git -C "$SEED" config core.eol lf
commit_file() {
local ts=$1
local file=$2
local content=$3
local message=$4
printf '%s\n' "$content" >"$SEED/$file"
git -C "$SEED" add -- "$file"
GIT_AUTHOR_DATE="$ts" \
GIT_COMMITTER_DATE="$ts" \
git -C "$SEED" commit -m "$message" >/dev/null
}
# A: common root.
commit_file \
'2026-01-01T00:00:00+0000' \
state.txt \
root \
'A: root'
A="$(git -C "$SEED" rev-parse HEAD)"
# S: unrelated side line carrying the deliberately larger version.
git -C "$SEED" switch -c side >/dev/null
commit_file \
'2026-01-01T00:10:00+0000' \
side.txt \
side-only \
'S: side release candidate'
S="$(git -C "$SEED" rev-parse HEAD)"
GIT_COMMITTER_DATE='2026-01-01T00:11:00+0000' \
git -C "$SEED" tag -a v9.0.0 \
-m 'synthetic unrelated side tag' "$S"
# Mainline B -> C -> D.
git -C "$SEED" switch main >/dev/null
commit_file \
'2026-01-01T00:20:00+0000' \
state.txt \
release-1.2.0 \
'B: annotated release ancestor'
B="$(git -C "$SEED" rev-parse HEAD)"
GIT_COMMITTER_DATE='2026-01-01T00:21:00+0000' \
git -C "$SEED" tag -a v1.2.0 \
-m 'synthetic annotated release' "$B"
commit_file \
'2026-01-01T00:30:00+0000' \
state.txt \
release-1.2.1 \
'C: lightweight release ancestor'
C="$(git -C "$SEED" rev-parse HEAD)"
git -C "$SEED" tag v1.2.1 "$C"
commit_file \
'2026-01-01T00:40:00+0000' \
state.txt \
post-release-build \
'D: untagged build tip'
D="$(git -C "$SEED" rev-parse HEAD)"
# Independent expected-results file is created before any comparison clone.
cat >"$EXPECTED" <<EOF
POLICY_REV=version-policy/2026-09-23
OBJECT_FORMAT=sha1
A=$A
B=$B
C=$C
D=$D
S=$S
MAIN_HEAD=$D
ANNOTATED_TAG=v1.2.0
ANNOTATED_TAG_COMMIT=$B
LIGHTWEIGHT_TAG=v1.2.1
LIGHTWEIGHT_TAG_COMMIT=$C
UNRELATED_TAG=v9.0.0
UNRELATED_TAG_COMMIT=$S
EXPECT_DEFAULT_DESCRIBE_PREFIX=v1.2.0-2-g
EXPECT_TAGS_DESCRIBE_PREFIX=v1.2.1-1-g
EXPECT_EXACT_RELEASE_TAG=v1.2.1
EXPECT_EXACT_RELEASE_COMMIT=$C
EOF
git -C "$SEED" remote add origin "file://$ORIGIN"
git -C "$SEED" push origin main side >/dev/null
git -C "$SEED" push origin \
refs/tags/v1.2.0 \
refs/tags/v1.2.1 \
refs/tags/v9.0.0 >/dev/null
git --git-dir="$ORIGIN" symbolic-ref HEAD refs/heads/main
clone_case() {
local name=$1
shift
git clone "$@" "file://$ORIGIN" "$LAB_ROOT/$name" >/dev/null 2>&1
}
clone_case full --branch main
clone_case shallow --depth 1 --no-tags --branch main
clone_case tags_only --depth 1 --no-tags --branch main
clone_case history_only --depth 1 --no-tags --branch main
clone_case positive --branch main
# Ref repair only.
git -C "$LAB_ROOT/tags_only" fetch origin \
refs/tags/v1.2.0:refs/tags/v1.2.0 \
refs/tags/v1.2.1:refs/tags/v1.2.1 \
refs/tags/v9.0.0:refs/tags/v9.0.0
# History repair only. no-tags remains configured.
git -C "$LAB_ROOT/history_only" fetch \
--unshallow origin main
probe() {
local repo=$1
printf 'HEAD=%s\n' \
"$(git -C "$repo" rev-parse 'HEAD^{commit}')"
printf 'object_format=%s\n' \
"$(git -C "$repo" rev-parse --show-object-format)"
printf 'ref_format=%s\n' \
"$(git -C "$repo" rev-parse --show-ref-format)"
printf 'shallow=%s\n' \
"$(git -C "$repo" rev-parse --is-shallow-repository)"
git -C "$repo" for-each-ref \
--format='%(refname) %(objecttype) %(objectname) %(*objectname)' \
refs/tags
git -C "$repo" log \
--graph --decorate --oneline --all --boundary
}
capture_describe() {
local repo=$1
shift
local out err rc
out="$(mktemp)"
err="$(mktemp)"
set +e
git -C "$repo" describe "$@" >"$out" 2>"$err"
rc=$?
set -e
printf 'rc=%s\n' "$rc"
printf 'stdout=%s\n' "$(cat "$out")"
printf 'stderr=%s\n' "$(cat "$err")"
rm -f "$out" "$err"
}
for case_name in full shallow tags_only history_only positive; do
printf '\n== %s ==\n' "$case_name"
probe "$LAB_ROOT/$case_name"
printf '%s\n' '-- default describe --'
capture_describe "$LAB_ROOT/$case_name" HEAD
printf '%s\n' '-- describe --tags --'
capture_describe "$LAB_ROOT/$case_name" --tags HEAD
done
printf '\nExpected results:\n'
cat "$EXPECTED"
printf '\nInspect lab at: %s\n' "$LAB_ROOT"
printf 'Cleanup when finished: rm -rf %q\n' "$LAB_ROOT"The stable semantic assertions are the relationships A/B/C/D/S, tag types, and policy outcomes. The hashes below are computed results from the executed fixture and will change if commit messages, timestamps, identities, file contents, or object format change.
Object | Measured ID |
A | e1272e1a91e7b7332d40eee1851991bbacc73ea5 |
B / v1.2.0^{commit} | 32e0f6b4591ee82d5150c8fa51efff810bfebdf9 |
C / lightweight v1.2.1 | a8419c1e3425c76b00e19e3ae02525d17fd79d5a |
D / main HEAD | 5c3729783b892bc0873a35d9edc1e86b5db68d53 |
S / v9.0.0^{commit} | 2c79b83fc5b30a4f60e242eec832c2320d0c227c |
The annotated v1.2.0 tag object measured 328e54f610a4b26ff9ddcfc6b6db69a3286ac1fa; the annotated v9.0.0 tag object measured 868c63390e57fee236470085c868470d13bf0f3e. The lightweight v1.2.1 ref points directly at C, so git cat-file -t refs/tags/v1.2.1 reports commit, while the two annotated refs report tag.
The independent complete clone produced this graph:
5c37297 (HEAD -> main, origin/main, origin/HEAD) D: untagged build tip
a8419c1 (tag: v1.2.1) C: lightweight release ancestor
* 32e0f6b (tag: v1.2.0) B: annotated release ancestor
| 2c79b83 (tag: v9.0.0, origin/side) S: side release candidate
|/
e1272e1 A: rootThis kind of graph/ref evidence is the part usually missing from broad discussions of version-controlled data engineering changes: reproducibility at build time depends not only on versioned source files, but on which repository metadata the build can actually inspect.
Reproduce missing refs and shallow ancestry as independent failures
The complete clone is useful only if negative controls fail for the reasons the policy predicts. Otherwise the “test” can merely be encoding whichever result one convenient checkout happens to return.
First consider the depth-one no-tag clone:
git clone \
--depth 1 \
--no-tags \
--branch main \
"file://$ORIGIN" \
"$LAB_ROOT/shallow"Git documents that --depth creates shallow history. It also states that --no-tags persists through remote.<remote>.tagOpt=--no-tags, so later normal fetch operations are not equivalent to recovering the omitted tag namespace.
Measured evidence from the clone was:
HEAD=5c3729783b892bc0873a35d9edc1e86b5db68d53
shallow=true
remote.origin.tagOpt=--no-tags
tags=<none>The working tree at D contains the same current state.txt content as the complete control. That is precisely why source-file appearance is not an acceptance test for version derivation.
Both version commands failed with status 128:
git describe HEAD
fatal: No names found, cannot describe anything.
git describe --tags HEAD
fatal: No names found, cannot describe anything.Do not add --always here and call the resulting abbreviated object name a successful release version. Git documents --always as a fallback that emits an abbreviated commit when no tag-based description can be found. That behavior can be valid for a separate non-release diagnostic policy, but it does not satisfy a policy requiring a release tag or tag-based descriptor.
Next isolate tag-ref repair without ancestry repair. Starting from another clean depth-one/no-tag clone, the lab explicitly fetched all three fixture tags:
git fetch origin \
refs/tags/v1.2.0:refs/tags/v1.2.0 \
refs/tags/v1.2.1:refs/tags/v1.2.1 \
refs/tags/v9.0.0:refs/tags/v9.0.0Git permits explicit tag refspecs, and its fetch documentation distinguishes explicitly requested tags from automatic tag following.
After that operation, the measured clone contained all three tag refs but remained shallow:
shallow=true
refs/tags/v1.2.0 present
refs/tags/v1.2.1 present
refs/tags/v9.0.0 presentIn this particular file:// run, fetching the tag refs also transferred enough objects to display B, C, S, and A in object-oriented inspection. That side effect did not repair the ancestry relationship from D because D remained a shallow boundary. git merge-base --is-ancestor returned 1 when testing both C and B against HEAD, and both describe variants still failed to describe D.
That result is worth pausing on: an object can exist locally without Git treating the checkout as having the complete parent traversal needed for the version question. A nonempty git tag listing is therefore not the same thing as recovered ancestry.
git merge-base --is-ancestor A B is specifically documented to return status 0 when A is an ancestor of B and 1 when it is not under the repository graph Git can inspect; other nonzero statuses indicate operational errors.
Now isolate the opposite repair: deepen the graph without assuming tags have appeared.
git fetch --unshallow origin main
Git documents --unshallow as converting a shallow repository into a complete one when the source repository itself is complete; when the source is shallow, the fetch can only retrieve as much as the source provides. Git also documents that deepening history does not inherently fetch tags for commits newly exposed by the deepening operation.
In the lab the bare origin was complete, so the history-only clone changed to:
shallow=false
remote.origin.tagOpt=--no-tags
tags=<none>Its mainline history A-B-C-D was now available, but both describe commands continued to fail because the clone still had no tag refs. This is the reciprocal negative control: complete ancestry is not tag discovery.
The evidence matrix from the executed batch was:
Checkout | Required fixture tag refs | Shallow? | Relevant ancestry usable? | git describe HEAD | git describe --tags HEAD | Correct next decision | ||||||||||||||||||
Full-history baseline | All present | false | Yes | v1.2.0-2-g5c37297 | v1.2.1-1-g5c37297 | Evaluate policy | ||||||||||||||||||
Depth 1 + --no-tags | None | true | No | rc 128 | rc 128 | DEEPEN AND RETEST | ||||||||||||||||||
Tags fetched only | All present | true | Still blocked at shallow D | rc 128 | rc 128 | DEEPEN AND RETEST | ||||||||||||||||||
History unshallowed only | None | false | Yes for main | rc 128 | rc 128 | FETCH AND RETEST | ||||||||||||||||||
Independent positive clone | All present | false | Yes | v1.2.0-2-g5c37297 | v1.2.1-1-g5c37297 | APPROVE under descriptor policy | ||||||||||||||||||
The engineering model is therefore two-dimensional before version semantics are even considered:
required refs
missing present
history complete FETCH/RETEST evaluate policy
history shallow DEEPEN/RETEST DEEPEN/RETESTA fetch command returning zero is not the acceptance criterion. A tag name appearing is not the acceptance criterion. A complete-looking source directory is not the acceptance criterion. The gate inspects the resulting repository state.
Interpret annotated, lightweight, and unrelated tags correctly
The fixture deliberately makes default git describe and git describe --tags disagree without either command being broken.
At D, the mainline is:
v1.2.0 (annotated) -- C:v1.2.1 (lightweight) -- DDefault git describe considers annotated tags, so the complete clone measured:
v1.2.0-2-g5c37297Adding --tags permits lightweight tags, making C eligible:
v1.2.1-1-g5c37297That difference follows the documented selection policy. Git states that git describe finds a reachable tag, that annotated tags are used by default, and that --tags allows lightweight tags as well. Its documented search procedure also gives exact matches priority and describes history traversal/candidate selection; it is not specified as “select the largest SemVer” or “pick whichever tag has the newest timestamp.”
The exact-match control makes the distinction sharper. Commit C has the lightweight tag v1.2.1.
Measured commands were:
git describe --exact-match C
rc=128
git describe --tags --exact-match C
rc=0
stdout=v1.2.1Git documents --exact-match as equivalent to permitting no non-exact candidates; combined with the annotated-only default, that means the lightweight tag at C is intentionally invisible until --tags is supplied.
Now consider the synthetic v9.0.0 tag.
Sorting the complete clone’s tags using Git’s version-aware ref ordering produced:
v9.0.0
v1.2.1
v1.2.0A release script that takes the first value would name D as v9.0.0, even though the v9.0.0 commit is on the unmerged side branch. In the measured control:
git merge-base --is-ancestor \
'refs/tags/v9.0.0^{commit}' \
HEADreturned status 1.The negative control must remain in the repository. Deleting v9.0.0 merely to make a naive sorting algorithm pass would remove the evidence that the algorithm asks the wrong question.
For an exact-tag release, do not stop after proving that the name exists. Resolve the fully qualified ref to the commit it denotes and bind it to both the declared target and the checked-out object:
required_ref='refs/tags/v1.2.1'
expected_commit='a8419c1e3425c76b00e19e3ae02525d17fd79d5a'
tag_commit="$(
git rev-parse --verify "${required_ref}^{commit}"
)"
head_commit="$(
git rev-parse --verify 'HEAD^{commit}'
)"
test "$tag_commit" = "$expected_commit"
test "$head_commit" = "$expected_commit"
git describe \
--tags \
--exact-match \
"$expected_commit"Git’s revision syntax documents ^{commit} as dereferencing a tag as necessary and requiring the result to be a commit-ish object. That is preferable to comparing abbreviated display strings.
In the detached positive control at C, those comparisons passed and git describe --tags --exact-match C returned exactly v1.2.1.
At D, an exact-release policy for v1.2.1 must fail even though the tag is the nearest permitted ancestor. That is not a CI defect. It is the expected difference between “this commit is release v1.2.1” and “this later build is described relative to v1.2.1.”
Implement a version-evidence gate with explicit repair verdicts
A release-version check should make incomplete evidence visible in machine-readable output instead of collapsing every problem into “version lookup failed.”
For the controlled lab, maintain two simple policy manifests:
# descriptor.refs
refs/tags/v1.2.0
refs/tags/v1.2.1
refs/tags/v9.0.0# exact.refs
refs/tags/v1.2.1The unrelated v9.0.0 belongs in the descriptor test population because the policy must prove that visibility of a larger unrelated version does not alter the answer.
In a real repository, the equivalent policy needs a defined tag namespace and a way to establish its required coverage. Checking one known tag cannot prove that no omitted, closer eligible tag exists. A common operational pattern is to synchronize the policy’s tag namespace from the authoritative origin before derivation, then inspect the resulting refs. Git’s fetch documentation distinguishes explicit refspecs, automatic tag following, --tags, and --no-tags; choose one defined mechanism and verify its result rather than treating the fetch option itself as proof.
The following verifier was executed against the lab cases. It takes the expected commit and policy revision as inputs, records object format and shallow state, checks the required ref manifest, validates the policy basis tag, and emits one of the requested decisions.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
usage() {
echo \
"usage: $0 EXPECTED_COMMIT POLICY_REV MODE REQUIRED_TAG REQUIRED_REFS_FILE" \
>&2
echo "MODE: descriptor-tags | exact-tag" >&2
exit 64
}
[[ $# -eq 5 ]] || usage
expected_commit=$1
policy_rev=$2
mode=$3
required_tag=$4
required_refs_file=$5
required_ref="refs/tags/$required_tag"
[[ -r "$required_refs_file" ]] || {
echo "required refs file is unreadable" >&2
exit 64
}
errfile="$(mktemp "${TMPDIR:-/tmp}/version-gate.XXXXXX")"
trap 'rm -f "$errfile"' EXIT
emit() {
printf '%s=%s\n' "$1" "$2"
}
verdict() {
emit verdict "$1"
emit reason "$2"
exit "$3"
}
head_commit="$(
git rev-parse --verify 'HEAD^{commit}'
)" || verdict HOLD 'HEAD is not a commit' 20
object_format="$(
git rev-parse --show-object-format
)" || verdict HOLD 'cannot determine object format' 20
shallow="$(
git rev-parse --is-shallow-repository
)" || verdict HOLD 'cannot determine shallow state' 20
emit policy_rev "$policy_rev"
emit mode "$mode"
emit expected_commit "$expected_commit"
emit head_commit "$head_commit"
emit object_format "$object_format"
emit shallow "$shallow"
emit required_ref "$required_ref"
[[ "$head_commit" == "$expected_commit" ]] ||
verdict HOLD \
'checked-out commit differs from declared target' \
20
missing_refs=0
while IFS= read -r ref || [[ -n "$ref" ]]; do
[[ -z "$ref" || "$ref" == \#* ]] && continue
case "$ref" in
refs/tags/*) ;;
)
verdict HOLD \
'policy manifest contains a non-tag ref' \
20
;;
esac
if git show-ref --verify --quiet "$ref"; then
emit "ref_present_${ref#refs/tags/}" true
else
emit "ref_present_${ref#refs/tags/}" false
missing_refs=$((missing_refs + 1))
fi
done <"$required_refs_file"
if [[ "$shallow" != false ]]; then
verdict DEEPEN_AND_RETEST \
"repository history is shallow; missing_required_refs=$missing_refs" \
21
fi
if (( missing_refs > 0 )); then
verdict FETCH_AND_RETEST \
"required tag refs are missing; count=$missing_refs" \
22
fi
if ! git show-ref --verify --quiet "$required_ref"; then
verdict FETCH_AND_RETEST \
'basis tag ref is missing' \
22
fi
tag_type="$(
git cat-file -t "$required_ref"
)" || verdict HOLD \
'required ref cannot be typed' \
20
tag_commit="$(
git rev-parse --verify "${required_ref}^{commit}"
)" || verdict HOLD \
'required tag cannot be peeled to a commit' \
20
emit tag_type "$tag_type"
emit tag_commit "$tag_commit"
case "$mode" in
exact-tag)
[[ "$tag_commit" == "$expected_commit" ]] ||
verdict HOLD \
'exact tag does not identify the declared target' \
20
if version="$(
git describe \
--tags \
--exact-match \
"$expected_commit" \
2>"$errfile"
)"; then
:
else
rc=$?
emit describe_rc "$rc"
emit describe_stderr "$(tr '\n' ' ' <"$errfile")"
verdict HOLD \
'exact-match policy could not derive a tag' \
20
fi
[[ "$version" == "$required_tag" ]] ||
verdict HOLD \
'exact-match selected a different tag name' \
20
;;
descriptor-tags)
if git merge-base \
--is-ancestor \
"$tag_commit" \
"$expected_commit"
then
:
else
rc=$?
if [[ $rc -eq 1 ]]; then
verdict HOLD \
'required tag commit is not an ancestor of target' \
20
fi
verdict HOLD \
'ancestry check failed operationally' \
20
fi
if version="$(
git describe \
--tags \
--match 'v[0-9]' \
"$expected_commit" \
2>"$errfile"
)"; then
:
else
rc=$?
emit describe_rc "$rc"
emit describe_stderr "$(tr '\n' ' ' <"$errfile")"
verdict HOLD \
'descriptor derivation failed' \
20
fi
case "$version" in
"$required_tag"|"$required_tag"-*)
;;
)
verdict HOLD \
'descriptor basis differs from required policy tag' \
20
;;
esac
;;
)
usage
;;
esac
emit version "$version"
verdict APPROVE \
'repository evidence satisfies the selected version policy' \
0The executed outcomes were:
Case | Gate process status | Verdict | Significant evidence | |||||||||||||||||||||
Full clone at D | 0 | APPROVE | all fixture refs present; shallow=false; version v1.2.1-1-g5c37297 | |||||||||||||||||||||
Raw shallow/no-tag clone | 21 | DEEPEN AND RETEST | shallow=true; three required refs absent | |||||||||||||||||||||
Tags-only repair | 21 | DEEPEN AND RETEST | all refs present but shallow=true | |||||||||||||||||||||
History-only repair | 22 | FETCH AND RETEST | shallow=false; three required refs absent | |||||||||||||||||||||
Independent positive clone at D | 0 | APPROVE | same descriptor as expected policy | |||||||||||||||||||||
Detached exact control at C | 0 | APPROVE | v1.2.1 peels to C; exact output v1.2.1 | |||||||||||||||||||||
The ordering is deliberate. A shallow repository receives DEEPEN_AND_RETEST even when refs are also missing, because ancestry must be made inspectable before any final descriptor can be accepted. After deepening, the next run can deterministically expose a remaining ref problem as FETCH_AND_RETEST.
Do not interpret these exit codes as a universal standard; they are the lab’s contract. The important property is that incomplete evidence is not transformed into a nominal success.
A broader operational decision table should handle failures beyond the four lab clones:
Evidence | Decision | Repair owner | Required action | ||||||||||||||||||||
Expected commit equals HEAD; required refs present; ancestry complete; output matches policy | APPROVE | Build engineering | Store evidence with build metadata | ||||||||||||||||||||
History complete but required tag refs absent | FETCH AND RETEST | Build engineering | Fetch policy-required refs, then rerun the entire gate | ||||||||||||||||||||
Relevant history is shallow/unknown | DEEPEN AND RETEST | Build engineering | Deepen/unshallow or replace with clean complete clone | ||||||||||||||||||||
Exact tag exists but peels to another commit | HOLD | Repository maintainer + release owner | Resolve tag/target discrepancy; do not retag casually | ||||||||||||||||||||
git describe default selects v1.2.0 but policy requires lightweight-aware v1.2.1 | HOLD | Repository maintainer | Correct command or explicitly revise policy | ||||||||||||||||||||
“Highest” visible tag is unrelated v9.0.0 | HOLD | Version-policy owner | Replace global tag sorting with reachability-aware policy | ||||||||||||||||||||
--always emitted an abbreviated hash under an official release policy | HOLD | Build/release owner | Recover evidence or classify as non-release metadata | ||||||||||||||||||||
HEAD differs from declared source commit | HOLD | Build engineering | Stop before version derivation and fix checkout target | ||||||||||||||||||||
Repository is complete but selected version algorithm contradicts policy | HOLD | Repository maintainer | Fix policy/implementation; completeness cannot cure semantics | ||||||||||||||||||||
A hold is not a request to find a more convenient command. It means the evidence cannot support the requested release statement.
Map the same evidence into CI without trusting checkout settings
GitHub Actions is useful as an integration example because its checkout action makes the history/ref distinction operationally visible, but the local fixture should remain the primary test.
The official actions/checkout documentation says a single commit is fetched by default. At the reviewed revision used here, action.yml declares fetch-depth default 1, fetch-tags default false, and defines depth 0 as all history for all branches and tags.
Do not convert that moving documentation into “use whichever major is currently shown.” For this article’s 2026-09-23 batch, the reviewed release was v7.0.1; GitHub’s release page identified commit 3d3c42e, and the corresponding full commit ID is:
3d3c42e5aac5ba805825da76410c181273ba90b1
The exact commit’s action.yml declares a Node 24 runtime. The repository documentation states that the Node 24 migration requires GitHub Actions Runner v2.327.1 or later. It separately notes that authenticated Git commands from a Docker container with the v6-and-later credential arrangement require runner v2.329.0 or later. This example sets persist-credentials: false and does not perform authenticated Git operations from a container, so that latter capability is outside the example.
GitHub’s hosted-runner documentation listed ubuntu-24.04 as a supported x64 workflow label at the research cutoff.
A deliberately narrow branch-build integration is:
name: version-evidence
on:
push:
branches:
- main
permissions:
contents: read
jobs:
verify-version:
runs-on: ubuntu-24.04
steps:
- name: Checkout reviewed revision
id: checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Record checkout evidence
shell: bash
env:
EVENT_REF: ${{ github.ref }}
EVENT_SHA: ${{ github.sha }}
CHECKOUT_REF: ${{ steps.checkout.outputs.ref }}
CHECKOUT_COMMIT: ${{ steps.checkout.outputs.commit }}
run: |
set -euo pipefail
printf 'event_ref=%s\n' "$EVENT_REF"
printf 'event_sha=%s\n' "$EVENT_SHA"
printf 'checkout_ref=%s\n' "$CHECKOUT_REF"
printf 'checkout_commit=%s\n' "$CHECKOUT_COMMIT"
printf 'head=%s\n' \
"$(git rev-parse --verify 'HEAD^{commit}')"
printf 'shallow=%s\n' \
"$(git rev-parse --is-shallow-repository)"
printf 'object_format=%s\n' \
"$(git rev-parse --show-object-format)"
git for-each-ref \
--format='%(refname) %(objecttype) %(objectname) %(*objectname)' \
refs/tags
test "$(git rev-parse HEAD)" = "$EVENT_SHA"
- name: Enforce build descriptor policy
shell: bash
env:
EXPECTED_COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
./ci/verify-version.sh \
"$EXPECTED_COMMIT" \
"version-policy/2026-09-23" \
"descriptor-tags" \
"v1.2.1" \
"ci/descriptor.refs"fetch-depth: 0 and fetch-tags: true express the desired checkout configuration. The following step establishes the observed repository state. That distinction matters more than the YAML.
The example is intentionally constrained to a push on main. Pull-request events, pull_request_target, tag pushes, workflow reuse, and manually selected refs can have different relationships between the event’s ref/SHA and the object that ends up checked out. The action itself exposes checked-out ref and commit outputs at the pinned implementation, so retain those alongside github.ref, github.sha, and an independently resolved HEAD.
This source-evidence check is complementary to the broader CI/CD tool landscape. Nothing in the acceptance logic depends on ranking GitHub Actions against another orchestrator. The same repository-state gate can run after Jenkins, GitLab CI, Buildkite, or another system has prepared a Git worktree.
The release-engineering rule remains: configuration is intention; repository inspection is evidence.
Repair, retest, and own the version contract
Choose the repair from the failed evidence field rather than applying every Git fetch option until the version string looks familiar.
When ancestry is incomplete and the authoritative origin is complete, a controlled repair can be:
git fetch --unshallow origin main
git rev-parse --is-shallow-repository
git merge-base --is-ancestor \
'refs/tags/v1.2.1^{commit}' \
HEADIf only the required tag ref is missing after history is complete, fetch that ref explicitly and retest:git fetch origin \
refs/tags/v1.2.1:refs/tags/v1.2.1
git show-ref --verify refs/tags/v1.2.1For a nearest-tag policy whose correctness depends on the entire eligible tag namespace, fetching just one desired tag is weaker than the policy. Synchronize the defined namespace or create a clean control clone according to the repository’s release contract, then prove the refs you require are present before deriving the descriptor. Git documents --tags, explicit refspecs, deepening, and unshallowing as distinct controls; there is no basis for assuming that one automatically repairs the other.
If both dimensions are uncertain, a clean clone is often easier to audit than a long repair sequence:
git clone \
--branch main \
"file://$ORIGIN" \
"$LAB_ROOT/retest-clean"
git -C "$LAB_ROOT/retest-clean" \
rev-parse --is-shallow-repository
git -C "$LAB_ROOT/retest-clean" \
for-each-ref \
--format='%(refname) %(objecttype) %(objectname) %(*objectname)' \
refs/tagsIn production the transport will normally be HTTPS or SSH rather than file://; file:// is used here only to make the local depth experiment exercise transport semantics instead of the optimized local-path clone behavior documented by Git.
Preserve the original failing evidence. A repaired second attempt should not erase:
original HEAD
original shallow state
original ref inventory
original describe stdout
original describe stderr
original exit code
repair command
post-repair HEAD
post-repair shallow state
post-repair ref inventory
post-repair version
policy revision
final verdictThat record distinguishes “the checkout was initially insufficient and was repaired before the build” from “the build originally had valid version evidence.”
If an artifact has already been assigned the wrong version, this playbook stops at source-side diagnosis. Do not rewrite an external registry label, move a release tag to a different commit, or overwrite an already published artifact merely because the checkout has now been repaired. Those actions belong to the existing release-governance process. The same separation is useful around versioned database delivery workflows: repairing source metadata does not retroactively redefine downstream state.
Ownership should also be explicit.
Repository maintainers own the meaning of version tags: eligible namespaces, whether lightweight tags are allowed, whether exact-tag releases are mandatory, and which policy revision is current.
Build engineers own checkout evidence: actual HEAD, tag-ref coverage, shallow status, object format, version-command invocation, exit status, and successful execution of positive and negative controls.
Release owners consume the resulting source version evidence and decide what downstream naming action is permitted. They should not weaken a failed build-side gate merely because a release deadline exists.
Changes that deserve revalidation include action revisions, clone/fetch refspecs, fetch-depth, tag-fetch settings, tag conventions, default branch handling, and CI trigger configuration. For cloud-native pipeline delivery context, this is the useful boundary: the pipeline should carry an explicit, reproducible source identity forward rather than asking later deployment stages to reconstruct it from incomplete Git state.
During rollout, retain the last validated checkout policy as a comparison control. Run the proposed configuration against the same synthetic fixture before switching release jobs. If the new configuration causes the full positive control to fail or permits an intended negative control to pass, roll back the configuration and investigate. Do not grant an exception that relabels an invalid result as valid.
The fixture should remain small. Its value comes from containing the exact failure discriminators:
annotated reachable tag
lightweight reachable tag
untagged mainline tip
unmerged higher-version tag
depth-one no-tag checkout
tag-only repair
history-only repair
independent full positive controlThose cases catch more policy regressions than a large production repository whose expected answer is hard to reason about independently.
Store the graph/ref evidence with normal build metadata for the retention period your organization already applies. At minimum, the useful acceptance record is:
version_policy=version-policy/2026-09-23
target_commit=5c3729783b892bc0873a35d9edc1e86b5db68d53
head_commit=5c3729783b892bc0873a35d9edc1e86b5db68d53
object_format=sha1
shallow=false
required_ref_v1.2.0=true
required_ref_v1.2.1=true
required_ref_v9.0.0=true
basis_tag=v1.2.1
basis_tag_type=commit
basis_commit=a8419c1e3425c76b00e19e3ae02525d17fd79d5a
version=v1.2.1-1-g5c37297
verdict=APPROVEThat is materially stronger than storing only VERSION=v1.2.1-1-g5c37297, because it records the commit and policy evidence under which the string was accepted.
Connect repository evidence to DevOps engineering practice
Reliable release metadata sits at the intersection of Git topology, shell discipline, and CI execution. The key skill is not memorizing another git fetch incantation. It is learning to state a version contract, construct a graph that can falsify it, inspect the repository state CI actually received, and refuse to turn missing evidence into a successful-looking version.
For readers building those foundations, Refonte Learning’s DevOps Engineering programme currently describes a three-month programme at 12 to 14 hours per week and lists Linux/scripting, Git and GitHub, CI/CD, Docker/Kubernetes, Terraform, cloud platforms, monitoring, and capstone work among its stated competencies. Its eligibility text refers to bachelor’s or postgraduate study, and the page describes practical guidance and internship opportunities. Those are relevant foundations for this kind of build-and-release work; they should not be read as a claim that this exact shallow-clone or git describe acceptance lab is a dedicated curriculum module.
The operational artifact to carry forward is deliberately modest: one version string bound to one checked-out commit, one known tag/ref state, and one explicit version-policy revision. That is enough for a build system to say what it knows and, when the graph or refs are incomplete, to say that the release version is still unknown.
