Files
ersatztv/docs/decisions/records/ci/fetch-depth-never-grafts-a-complete-clone.md
T
timothytimothyClaude Opus 5 (1M context) <noreply@anthropic.com>
94a3d13495
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 32s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 16m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m7s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 2m0s
fix(836): never pass --depth to a checkout that may already be complete (#884)
`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) &lt;noreply@anthropic.com&gt;
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 23:55:29 +00:00

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.