`git fetch --depth=N` grafts a complete clone shallow. `scripts/ci-detect-docs-only.sh` applied a depth chosen for its three `fetch-depth: 2` consumers to `build`'s `fetch-depth: 0` checkout, so the `git describe --tags` in the next step found no reachable tag and a `|| echo v0.0.0` fallback turned that into a version: every `:latest` image shipped `InformationalVersion 0.0.0-<sha>` from 2026-07-17 (#416) until now. Both fetch sites now go through `fetch_ref`, which passes `--depth` only when the checkout is already shallow. `Compute version and tags` fails the job instead of defaulting, so no `:latest` is published rather than a mislabelled one; releases are unaffected because the tag path never calls `describe`. Ships a guard that drives the real script over real `file://` clones with a negative control, a declared clause mutation, and a decision record `ci.fetch-depth-never-grafts-a-complete-clone`. fixes #836 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
5.8 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| ci.fetch-depth-never-grafts-a-complete-clone | 2026-08-29 — A CI fetch passes --depth only to a checkout that is already shallow (#836) | active | 2026-08-29 | none | none | Any `git fetch` in CI tooling passes `--depth` only when `git rev-parse --is-shallow-repository` already answers `true`, and passes none on any other answer including an unreadable one. `git fetch --depth=N` GRAFTS a complete clone shallow: it writes `.git/shallow` and cuts history at N even though every object is already present, so a depth chosen for one consumer silently breaks every other consumer that checks out `fetch-depth: 0`. A shared script whose consumers disagree on checkout depth must ask the repository rather than assume. And whatever the fetch feeds — a version, a diff, a base revision — must be ASSERTED rather than defaulted: a fallback that cannot fail converts the graft into a plausible wrong answer that never reddens. | git fetch --depth, .git/shallow, grafted clone, git describe finds no tag, InformationalVersion 0.0.0, fetch-depth 0 vs fetch-depth 2 · paths: `scripts/ci-detect-docs-only.sh`, `.gitea/workflows/docker-build.yml` · issues: #836, #746 | docs/ci-cd.md → "A depth is passed only to a checkout that is already shallow"; guard `scripts/tests/test_docs_only_detector_clone_depth.py`, a MUTATION-graded row in `docs/guard-inventory.md` |
scripts/ci-detect-docs-only.sh has exactly four consumers in docker-build.yml, and they do not
agree on checkout depth: test, migrations and functional-e2e check out fetch-depth: 2, and
build checks out fetch-depth: 0. The script carried a --depth chosen for the first group and
applied it to all of them. build is the only complete-clone consumer. api-docs and format
also check out fetch-depth: 0 and also have a step with id: detect, but that step runs their own
inline diff, not this script. Enumerate the consumers with grep -rn ci-detect-docs-only .gitea/,
never by looking for a step with id: detect — that is the trap, and it points at the wrong two
jobs. The grep returns five hits: the four run: call sites plus docker-build.yml's own header
comment.
The harm is not theoretical. The push arm is the only arm build ever FETCHES on — it also takes
the tag early-exit on a release cut and the workflow_dispatch arm, neither of which fetches — and
that arm grafted build's complete clone on every push to main;
the very next step, Compute version and tags, runs git describe --tags --abbrev=0, which then
found no reachable tag; and its || echo v0.0.0 turned that into a version string. Every :latest
image published from main carried InformationalVersion 0.0.0-<sha> instead of 26.x.y-<sha>
from 2026-07-17, when #416 introduced the depth, until this change. Nothing went red at any point —
the defect was found by reading the string out of a running container.
Why the depth is kept for the shallow group, stated as measured rather than as load-bearing. A
depth-less fetch into a --depth=2 clone leaves .git/shallow in place, still writes FETCH_HEAD,
and still lets git rev-list --parents -n1 HEAD report the parents the push arm reads. So dropping
the depth outright would not have broken those three jobs either. It is retained because it is what
they were given and nothing argues for widening their fetch — not because removing it was shown to
break anything.
The PR arm's --depth=200 had no complete-clone consumer at all. On pull_request the only
jobs running this script are the three shallow ones, so that depth was inert rather than latently
firing. It is fixed in the same change anyway, because the clause is shared: the next
fetch-depth: 0 consumer added to that arm would inherit the graft silently, and rediscovering it
would cost what #836 cost (process.fix-the-boundary-not-the-site). That is a guard against a
future shape, and grading it as a reproduction of a shipped one would overstate it.
Why any answer but true passes no depth. There is more than one such answer, and enumerating
them is the point: git rev-parse fails outright where there is no readable repository, and on a
git older than 2.15 the flag is unrecognised and rev-parse ECHOES IT BACK verbatim with status 0
(verified on 2.55 — an unknown --is-... flag prints itself and exits 0). Neither is true, both
take the no-depth branch, and in neither could a depth have helped: the base stays unresolvable and
every arm falls through to the full matrix, which is the safe direction for this script. Defaulting
the other way would re-graft on exactly the paths nobody can observe.
The second half is the loud failure, and it is not decoration. Removing the graft removes
today's cause; the fallback is what made any cause invisible. Compute version and tags now fails
the job when git describe fails on the non-tag path. No :latest at all is visible and
recoverable; a mislabelled one is neither, and :latest is what ersatztv-test tracks. The tag
path never calls describe, so a release cut is unaffected — which is also why :prod and
:<version> images were correctly versioned throughout.
Fixture note for anyone testing this. git clone /path and git fetch /path use the local
transport, which ignores --depth outright. A reproduction built on a plain path never grafts, so
every assertion holds and the test proves nothing; use file://. The guard carries a negative
control that performs the raw depth fetch and requires the graft to happen, so that if this ever
stops being true it is reported rather than silently making the rest vacuous.
Related: ci.docs-only-detect-shallow-safe covers the other half of this script's shallow
behaviour — which revision it diffs against, and why it is FETCH_HEAD with a two-dot diff.