:latest images ship InformationalVersion 0.0.0 — a --depth=2 fetch grafts the build job's full clone shallow #836

Closed
opened 2026-08-26 18:22:19 +02:00 by timothy · 2 comments
Owner

Found while verifying #746's fetch changes; pre-existing, unrelated to that PR, and currently shipping.

Measured

The ersatztv-test container on jazz (tracking :latest) carries:

$ docker exec ersatztv-test sh -c 'grep -ao "0\.0\.0-[a-f0-9]*" /app/ErsatzTV.dll | head -3'
0.0.0-5a7a4ed7

No 26.x.y-<sha> string is present in the assembly at all. So every :latest image built from a push to main reports InformationalVersion = 0.0.0-<sha> instead of 26.3.1-<sha>.

Re-confirmed from CI 2026-08-29, run 2416 / job 10345 (push to main at 8aeacd534): INFO_VERSION=0.0.0-8aeacd53.

Mechanism

docker-build.yml's build job runs, in this order:

  1. Checkout with fetch-depth: 0 — full history, tags included.
  2. Detect docs-only changesscripts/ci-detect-docs-only.sh, whose push path runs git fetch --no-tags --depth=2 origin "${GITHUB_REF_NAME:-main}" (scripts/ci-detect-docs-only.sh:70).
  3. Compute version and tagsDESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0).

git fetch --depth=N grafts a complete clone shallow — .git/shallow is created and history is cut at N even though the objects are present. The tag git describe needs is beyond that boundary, so step 3's git describe fails, 2>/dev/null || echo v0.0.0 swallows it, and the build proceeds with v0.0.0.

The graft behaviour is the same one measured in #746 (150-commit repo, branch point 130 back, --depth=100.git/shallow created → git diff origin/main...HEADfatal: no merge base; the same fetch without --depth resolves).

Why it has stayed invisible

  • Releases are unaffected. The tag path returns at ci-detect-docs-only.sh:34-37, before the fetch, and takes its version from GITHUB_REF_NAME. So :prod / :<version> images are correctly versioned and only :latest is wrong.
  • || echo v0.0.0 is a fallback that cannot fail, so nothing ever goes red.

Fix sketch (decide, do not assume)

The --depth=2 exists so HEAD^1 resolves in the test/migrations jobs' shallow fetch-depth: 2 checkouts, where it is correct and necessary. In build's fetch-depth: 0 checkout it is both unnecessary and harmful. Options: skip the fetch when the clone is already complete (git rev-parse --is-shallow-repository), or unshallow after the detect step, or split the depth choice by checkout depth. Whichever is chosen, assert the resulting version rather than the exit status.

Done-when

Boxes 1 and 2 as originally written were unsatisfiable before the merge they gate: the build job only runs on a push to main, and :latest only exists after that push, so "demonstrated from a run log" and "read it back out of the image" could never be ticked while the PR was open. They are restated below as what is verifiable pre-merge — deliberately not weaker, since a reproduction against the real repository discriminates the mechanism better than a post-merge run log does — and the literal image read is moved to a post-merge confirmation recorded in the closing record.

  • The graft and its removal measured against the real repository, not a synthetic fixture alone: the predecessor detector grafts a real complete clone and git describe fails (-> 0.0.0-<sha>); the fixed detector leaves it complete and git describe resolves (-> 26.14.0-<sha>)
  • The build job's Compute version and tags step body executed against a grafted clone (exit 1, no image), a complete clone (26.14.0-<sha>), and the tag path (:prod + 26.14.0, unaffected even on a grafted clone)
  • The || echo v0.0.0 fallback either goes away or is accompanied by something that notices when it fires
  • A guard reproduces the defect on every suite run, ships a declared clause mutation re-run every suite, and carries a docs/guard-inventory.md row
  • docs/ci-cd.md records the depth-graft hazard where the detector scripts are described
  • Adversarial review passed

Post-merge confirmation (not a merge gate): read InformationalVersion back out of the newly built :latest image and record it in the ## Closing record.

Found while verifying #746's fetch changes; **pre-existing, unrelated to that PR, and currently shipping.** ## Measured The `ersatztv-test` container on jazz (tracking `:latest`) carries: ``` $ docker exec ersatztv-test sh -c 'grep -ao "0\.0\.0-[a-f0-9]*" /app/ErsatzTV.dll | head -3' 0.0.0-5a7a4ed7 ``` No `26.x.y-<sha>` string is present in the assembly at all. So every `:latest` image built from a push to `main` reports `InformationalVersion = 0.0.0-<sha>` instead of `26.3.1-<sha>`. Re-confirmed from CI 2026-08-29, run 2416 / job 10345 (push to `main` at `8aeacd534`): `INFO_VERSION=0.0.0-8aeacd53`. ## Mechanism `docker-build.yml`'s `build` job runs, in this order: 1. `Checkout` with `fetch-depth: 0` — full history, tags included. 2. `Detect docs-only changes` → `scripts/ci-detect-docs-only.sh`, whose **push** path runs `git fetch --no-tags --depth=2 origin "${GITHUB_REF_NAME:-main}"` (scripts/ci-detect-docs-only.sh:70). 3. `Compute version and tags` → `DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)`. `git fetch --depth=N` grafts a **complete** clone shallow — `.git/shallow` is created and history is cut at N even though the objects are present. The tag `git describe` needs is beyond that boundary, so step 3's `git describe` fails, `2>/dev/null || echo v0.0.0` swallows it, and the build proceeds with `v0.0.0`. The graft behaviour is the same one measured in #746 (150-commit repo, branch point 130 back, `--depth=100` → `.git/shallow` created → `git diff origin/main...HEAD` → `fatal: no merge base`; the same fetch without `--depth` resolves). ## Why it has stayed invisible - **Releases are unaffected.** The tag path returns at ci-detect-docs-only.sh:34-37, *before* the fetch, and takes its version from `GITHUB_REF_NAME`. So `:prod` / `:<version>` images are correctly versioned and only `:latest` is wrong. - `|| echo v0.0.0` is a fallback that cannot fail, so nothing ever goes red. ## Fix sketch (decide, do not assume) The `--depth=2` exists so `HEAD^1` resolves in the `test`/`migrations` jobs' shallow `fetch-depth: 2` checkouts, where it is correct and necessary. In `build`'s `fetch-depth: 0` checkout it is both unnecessary and harmful. Options: skip the fetch when the clone is already complete (`git rev-parse --is-shallow-repository`), or unshallow after the detect step, or split the depth choice by checkout depth. Whichever is chosen, assert the resulting version rather than the exit status. ## Done-when Boxes 1 and 2 as originally written were **unsatisfiable before the merge they gate**: the `build` job only runs on a push to `main`, and `:latest` only exists after that push, so "demonstrated from a run log" and "read it back out of the image" could never be ticked while the PR was open. They are restated below as what is verifiable pre-merge — deliberately not weaker, since a reproduction against the real repository discriminates the mechanism better than a post-merge run log does — and the literal image read is moved to a post-merge confirmation recorded in the closing record. - [x] The graft and its removal **measured against the real repository**, not a synthetic fixture alone: the predecessor detector grafts a real complete clone and `git describe` fails (`-> 0.0.0-<sha>`); the fixed detector leaves it complete and `git describe` resolves (`-> 26.14.0-<sha>`) - [x] The `build` job's `Compute version and tags` **step body executed** against a grafted clone (exit 1, no image), a complete clone (`26.14.0-<sha>`), and the tag path (`:prod` + `26.14.0`, unaffected even on a grafted clone) - [x] The `|| echo v0.0.0` fallback either goes away or is accompanied by something that notices when it fires - [x] A guard reproduces the defect on every suite run, ships a declared clause mutation re-run every suite, and carries a `docs/guard-inventory.md` row - [x] `docs/ci-cd.md` records the depth-graft hazard where the detector scripts are described - [x] Adversarial review passed **Post-merge confirmation (not a merge gate):** read `InformationalVersion` back out of the newly built `:latest` image and record it in the `## Closing record`.
timothy added the ci-cdpriority: medium labels 2026-08-26 18:22:20 +02:00
Author
Owner

Claiming this (Claude Code / Opus 5, orchestrator session, 2026-08-29).

Pre-claim checks per process.parallel-session-claim, all clear:

  • open PRs referencing #836: none (#878, #879, #882 are #830/#823/#824/#812)
  • git ls-remote --heads origin '*836*': no branch
  • comments on this issue predating the label: none (comment count 0)
  • fresh git fetch origin main: at 8aeacd534

Bundle scan (all three axes): no milestone; no cross-reference comment; shared label ci-cd has #853, #855, #869 open. Not folding them in — #855 is a whole new coupling guard (parser + mutation + inventory row) and #869 is a claim re-probe sweep; neither shares a mechanism or a file with the depth-graft fix, so bundling would only widen the review surface.

Claiming this (Claude Code / Opus 5, orchestrator session, 2026-08-29). Pre-claim checks per `process.parallel-session-claim`, all clear: - open PRs referencing #836: none (`#878`, `#879`, `#882` are #830/#823/#824/#812) - `git ls-remote --heads origin '*836*'`: no branch - comments on this issue predating the label: none (comment count 0) - fresh `git fetch origin main`: at `8aeacd534` Bundle scan (all three axes): no milestone; no cross-reference comment; shared label `ci-cd` has #853, #855, #869 open. Not folding them in — #855 is a whole new coupling guard (parser + mutation + inventory row) and #869 is a claim re-probe sweep; neither shares a mechanism or a file with the depth-graft fix, so bundling would only widen the review surface.
timothy added the in-progress label 2026-08-29 22:15:15 +02:00
Author
Owner

Closing record

Outcome: Fixed and merged — PR #884, squashed to 94a3d1349. scripts/ci-detect-docs-only.sh routes both fetch sites through fetch_ref, which passes --depth only when git rev-parse --is-shallow-repository answers true; docker-build.yml's Compute version and tags now fails the job on a git describe failure instead of stamping 0.0.0. Ships scripts/tests/test_docs_only_detector_clone_depth.py (6 cases), a declared clause mutation in mutation_manifest.py, and the decision record ci.fetch-depth-never-grafts-a-complete-clone.

Root cause: 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. The script has exactly four consumers and they disagree on checkout depth (test/migrations/functional-e2e at fetch-depth: 2, build at fetch-depth: 0); it applied a depth chosen for the first group to all of them. The tag git describe needed was beyond the boundary, and || echo v0.0.0 — a fallback that cannot fail — converted the failure into a version. Live from 2026-07-17 (#416, which introduced the depth) until this change.

Decisions/conventions changed: Added ci.fetch-depth-never-grafts-a-complete-clone. Promoted scripts/ci-detect-docs-only.sh in docs/guard-inventory.md from GUARD | NONE to GUARD | MUTATION, and removed it from the prose "needs its own harness" backlog. Its recorded reason for being undeclared — "it feeds the skip gate, so its effect is visible only in a workflow run" — was measured false: the graft is a flag on a real clone and is observable in-process.

Reusable knowledge:

  • A depth on a shared fetch helper is a property of the CALLER's checkout, not of the script. Enumerate consumers with grep -rn <script> .gitea/, never by looking for a step with id: detectapi-docs and format have one of those and run their own inline diff. An earlier draft of this change read them as consumers and said so in the decision record; cold review caught it.
  • A file:// remote is mandatory when testing shallow behaviour. git clone /path uses the local transport, which ignores --depth outright, so a fixture built on a plain path never grafts and every assertion holds vacuously. The guard carries a negative control that requires the fixture to graft.
  • A fixture must exceed the depth under test. The PR-arm case was first written against a 32-commit origin, where --depth=200 has nothing to cut; it passed against the unfixed script. The control is now parametrised over both (fixture, depth) pairs.
  • Git-writing tests must not inherit the machine's identity. Pass -c user.email/-c user.name on every call plus user.useConfigOnly=true; pointing GIT_CONFIG_GLOBAL at os.devnull is not enough, because macOS git then invents an identity from the OS user and a dropped flag still passes locally.

Verification:

  • Proven on main, post-merge. Run 2476 / job 10631 log: checkout is not shallow (is-shallow=false); fetching without --depth (ersatztv#836) then INFO_VERSION=26.14.0-94a3d134, and docker buildx build --build-arg INFO_VERSION=26.14.0-94a3d134. The immediately preceding main build (run 2472, e8f80c42c) shows INFO_VERSION=0.0.0-e8f80c42 — a clean A/B across the merge.
  • Pre-merge: the predecessor detector grafts a real anonymous clone of this repository and describe fails (-> 0.0.0-1afad085); the fixed one leaves it complete (-> 26.14.0-1afad085). The Compute version and tags step body executed against a grafted clone (exit 1, no image), a complete clone, and the tag path (:prod + 26.14.0, unaffected even when grafted).
  • Guard witnessed red against the real predecessor (4 failed / 2 passed; the two passing are the negative control and the shallow-unchanged case, both correctly script-independent). Declared mutation reddens its named proof for the right diagnostic.
  • CI green on d44d9eb55: 14 success + 1 correctly-skipped, both required contexts and review-verdict/h10. Local: ruff clean over 52 tracked files with the CI-pinned 0.12.11; scripts/tests 1250 passed, 2 skipped.
  • Two cold review rounds (isolated worktree, review-only brief). Round 1 raised a HIGH; round 2 returned nothing at BLOCKER/HIGH/MEDIUM. No cross-family review — Codex was out of quota this session.

Deferred: The promised post-merge step "read InformationalVersion back out of the newly built :latest image" could not be performed, and this is stated rather than quietly dropped: no new image was published, because Build & push image (amd64) fails at the Dockerfile's web stage for an unrelated, pre-existing reason — the same failure is on the preceding main commit. Filed as #887 (priority: high, since it also breaks the tag/release path). The version evidence above comes from the run log and the --build-arg actually passed to docker buildx, which is the same value that would have been read back out of the image. The registry's newest :latest still reads 0.0.0-761e5758 and will until #887 is fixed.

Docs updated: docs/ci-cd.md (its prose described the defect in the present tense and is now the end state), docs/guard-inventory.md (row regrade + new PROOF row + summary counts), docs/decisions/records/ci/fetch-depth-never-grafts-a-complete-clone.md (new), docs/decisions/README.md (regenerated catalog).

## Closing record **Outcome:** Fixed and merged — PR #884, squashed to `94a3d1349`. `scripts/ci-detect-docs-only.sh` routes both fetch sites through `fetch_ref`, which passes `--depth` only when `git rev-parse --is-shallow-repository` answers `true`; `docker-build.yml`'s `Compute version and tags` now fails the job on a `git describe` failure instead of stamping `0.0.0`. Ships `scripts/tests/test_docs_only_detector_clone_depth.py` (6 cases), a declared clause mutation in `mutation_manifest.py`, and the decision record `ci.fetch-depth-never-grafts-a-complete-clone`. **Root cause:** `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. The script has exactly four consumers and they disagree on checkout depth (`test`/`migrations`/`functional-e2e` at `fetch-depth: 2`, `build` at `fetch-depth: 0`); it applied a depth chosen for the first group to all of them. The tag `git describe` needed was beyond the boundary, and `|| echo v0.0.0` — a fallback that cannot fail — converted the failure into a version. Live from 2026-07-17 (#416, which introduced the depth) until this change. **Decisions/conventions changed:** Added `ci.fetch-depth-never-grafts-a-complete-clone`. Promoted `scripts/ci-detect-docs-only.sh` in `docs/guard-inventory.md` from `GUARD | NONE` to `GUARD | MUTATION`, and removed it from the prose "needs its own harness" backlog. Its recorded reason for being undeclared — *"it feeds the skip gate, so its effect is visible only in a workflow run"* — was measured false: the graft is a flag on a real clone and is observable in-process. **Reusable knowledge:** - **A depth on a shared fetch helper is a property of the CALLER's checkout, not of the script.** Enumerate consumers with `grep -rn <script> .gitea/`, never by looking for a step with `id: detect` — `api-docs` and `format` have one of those and run their *own* inline diff. An earlier draft of this change read them as consumers and said so in the decision record; cold review caught it. - **A `file://` remote is mandatory when testing shallow behaviour.** `git clone /path` uses the local transport, which ignores `--depth` outright, so a fixture built on a plain path never grafts and every assertion holds vacuously. The guard carries a negative control that requires the fixture to graft. - **A fixture must exceed the depth under test.** The PR-arm case was first written against a 32-commit origin, where `--depth=200` has nothing to cut; it passed against the unfixed script. The control is now parametrised over both (fixture, depth) pairs. - **Git-writing tests must not inherit the machine's identity.** Pass `-c user.email`/`-c user.name` on every call plus `user.useConfigOnly=true`; pointing `GIT_CONFIG_GLOBAL` at `os.devnull` is not enough, because macOS git then invents an identity from the OS user and a dropped flag still passes locally. **Verification:** - **Proven on `main`, post-merge.** Run 2476 / job 10631 log: `checkout is not shallow (is-shallow=false); fetching without --depth (ersatztv#836)` then `INFO_VERSION=26.14.0-94a3d134`, and `docker buildx build --build-arg INFO_VERSION=26.14.0-94a3d134`. The immediately preceding `main` build (run 2472, `e8f80c42c`) shows `INFO_VERSION=0.0.0-e8f80c42` — a clean A/B across the merge. - Pre-merge: the predecessor detector grafts a real anonymous clone of this repository and `describe` fails (`-> 0.0.0-1afad085`); the fixed one leaves it complete (`-> 26.14.0-1afad085`). The `Compute version and tags` step body executed against a grafted clone (exit 1, no image), a complete clone, and the tag path (`:prod` + `26.14.0`, unaffected even when grafted). - Guard witnessed red against the real predecessor (4 failed / 2 passed; the two passing are the negative control and the shallow-unchanged case, both correctly script-independent). Declared mutation reddens its named proof for the right diagnostic. - CI green on `d44d9eb55`: 14 success + 1 correctly-skipped, both required contexts and `review-verdict/h10`. Local: ruff clean over 52 tracked files with the CI-pinned 0.12.11; `scripts/tests` 1250 passed, 2 skipped. - Two cold review rounds (isolated worktree, review-only brief). Round 1 raised a HIGH; round 2 returned nothing at BLOCKER/HIGH/MEDIUM. **No cross-family review — Codex was out of quota this session.** **Deferred:** The promised post-merge step "read `InformationalVersion` back out of the newly built `:latest` image" **could not be performed, and this is stated rather than quietly dropped**: no new image was published, because `Build & push image (amd64)` fails at the Dockerfile's web stage for an unrelated, pre-existing reason — the same failure is on the preceding `main` commit. Filed as **#887** (`priority: high`, since it also breaks the tag/release path). The version evidence above comes from the run log and the `--build-arg` actually passed to `docker buildx`, which is the same value that would have been read back out of the image. The registry's newest `:latest` still reads `0.0.0-761e5758` and will until #887 is fixed. **Docs updated:** `docs/ci-cd.md` (its prose described the defect in the present tense and is now the end state), `docs/guard-inventory.md` (row regrade + new PROOF row + summary counts), `docs/decisions/records/ci/fetch-depth-never-grafts-a-complete-clone.md` (new), `docs/decisions/README.md` (regenerated catalog).
timothy removed the in-progress label 2026-08-30 02:36:07 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#836