"""`scripts/ci-detect-docs-only.sh` must never graft a COMPLETE clone shallow (ersatztv#836). `git fetch --depth=N` writes `.git/shallow` and cuts history at N even when every object is already present. Exactly four `docker-build.yml` jobs run the detector — `test`, `migrations` and `functional-e2e` at `fetch-depth: 2`, and `build` at `fetch-depth: 0` — so `build` is the only complete-clone consumer, and the detector asks the repository which shape it is in rather than assuming. (`api-docs` and `format` have a step with `id: detect` too, but it runs their own inline diff, not this script. Do not add them here.) WHAT WENT WRONG WITHOUT THIS. `build`'s next step after the detector is `Compute version and tags`, whose `git describe --tags --abbrev=0` needs a reachable tag. The graft cut it off, `describe` failed, and a `|| echo v0.0.0` fallback that cannot fail turned that into a version string: every `:latest` image published from `main` carried `InformationalVersion 0.0.0-` instead of `26.x.y-`, from 2026-07-17 (#416) until #836, with nothing red anywhere. It was found by reading the string out of a running container. That is why the assertion here is on the RESULTING VERSION and on the shallow flag — not on the detector's exit status, which was `0` throughout the defect. WHY THE FIXTURE USES `file://`. `git clone /path` and `git fetch /path` use the local transport, which IGNORES `--depth` outright. A fixture built on a plain path would therefore never graft, every assertion below would hold, and the whole file would pass while testing nothing — `testing.eight-ways-a-test-passes-for-the-wrong-reason`. `test_the_fixture_can_actually_graft` is the negative control that keeps that honest: it performs the raw depth fetch the detector is forbidden to perform and REQUIRES the graft to happen. If git or the transport ever stops grafting, that control goes red and says so, instead of the other tests quietly becoming vacuous. """ from __future__ import annotations import os import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] DETECTOR = REPO_ROOT / "scripts" / "ci-detect-docs-only.sh" TAG = "v26.3.1" def _env(**extra: str) -> dict[str, str]: """Ambient `GIT_*` removed, everything else kept. Exported `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE` override `cwd` and would aim these git calls at the real repository. The rest of `os.environ` is carried deliberately rather than built from scratch: `conftest.py`'s isolation puts `ETV_HOOK_FIRE_LOG_DIR` there, and a from-scratch environment would drop it and write into the production hook-fire log. `GITHUB_` is dropped alongside `GIT_` because it does NOT match the `GIT_` prefix (`"GITHUB_OUTPUT".startswith("GIT_")` is False), and every `GITHUB_*` the detector reads decides which arm it takes. Pinning only the one variable that bites today would leave the class open for the next arm that starts reading a sibling — the whole prefix goes, and `_run_detector` puts back exactly what each test means to set. THE MACHINE'S GIT CONFIG IS NEUTRALISED TOO, and that is not belt-and-braces — it is what keeps this file from passing here and failing in CI. A developer checkout has a global `user.email`; the CI container has none, and git refuses to commit without one ("Author identity unknown"). The fixtures below commit, so without this the file is green locally and red in CI on exactly that. Pointing the global and system config at `os.devnull` makes the ambient configuration UNAVAILABLE rather than merely overridden, so a missing `-c` flag in `_git` fails everywhere instead of only where the machine is unhelpful — the local run reproduces CI by construction. It also disarms a global `core.hooksPath` (which would fire this repo's husky hooks against throwaway fixtures) and a global `commit.gpgsign` (which would block on a pinentry prompt no pytest timeout is watching). """ env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "GITHUB_"))} env["GIT_CONFIG_GLOBAL"] = os.devnull env["GIT_CONFIG_SYSTEM"] = os.devnull env.update(extra) return env # Passed to every git invocation, so no repository here can inherit an identity or a hook path from # the machine. `_env()` makes the ambient config unavailable; these supply what the commits need. _GIT_CONF = [ "-c", "user.email=test@example.com", "-c", "user.name=Test", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=", # Forbids git from INVENTING an identity from the OS user and hostname. Without it the two # settings above are not enough to reproduce CI: a Mac happily auto-detects one, so dropping the # identity flags stays green locally and fails in the container. With it, a missing flag fails # everywhere — verified by removing the identity pair and watching this file go red. "-c", "user.useConfigOnly=true", ] def _git(args: list[str], cwd: Path) -> str: r = subprocess.run(["git", *_GIT_CONF, *args], cwd=str(cwd), env=_env(), capture_output=True, text=True) assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}" return r.stdout.strip() def _git_rc(args: list[str], cwd: Path) -> subprocess.CompletedProcess: return subprocess.run(["git", *_GIT_CONF, *args], cwd=str(cwd), env=_env(), capture_output=True, text=True) def _is_shallow(repo: Path) -> str: return _git(["rev-parse", "--is-shallow-repository"], repo) @pytest.fixture(scope="module") def origin_url(tmp_path_factory) -> str: """An origin shaped like ours: the newest tag is well back, and the tip is a merge commit. Both properties matter. The tag has to be further back than the fetch depth or the graft cannot cut it off, and the tip has to be a merge commit or the detector's push arm reports `non-merge push` and returns before reaching anything this file is about. """ root = tmp_path_factory.mktemp("detector-depth") origin = root / "origin" origin.mkdir() _git(["init", "-q", "-b", "main", "."], origin) for i in range(1, 13): (origin / f"f{i}.txt").write_text(f"{i}\n") _git(["add", "-A"], origin) _git(["commit", "-q", "-m", f"c{i}"], origin) _git(["tag", TAG], origin) for i in range(13, 31): (origin / f"f{i}.txt").write_text(f"{i}\n") _git(["add", "-A"], origin) _git(["commit", "-q", "-m", f"c{i}"], origin) # A docs-only branch merged with --no-ff, i.e. exactly how an update to `main` lands here. _git(["checkout", "-q", "-b", "feature"], origin) (origin / "docs").mkdir() (origin / "docs" / "a.md").write_text("doc\n") _git(["add", "-A"], origin) _git(["commit", "-q", "-m", "docs change"], origin) _git(["checkout", "-q", "main"], origin) _git(["merge", "-q", "--no-ff", "feature", "-m", "merge feature (#1)"], origin) return f"file://{origin}" @pytest.fixture(scope="module") def deep_origin_url(tmp_path_factory) -> str: """An origin with MORE than 200 commits after its newest tag, for the PR arm's `--depth=200`. Built separately, and the extra ~3s is the price of a test that is not vacuous. The other fixture's 31 commits all fit inside a depth of 200, so a `--depth=200` fetch there has nothing to cut: no `.git/shallow` is written, `describe` keeps resolving, and a PR-arm test built on it passes against the UNFIXED script — measured, which is how this fixture came to exist. A guard whose positive case cannot occur is the defect it is meant to catch, one level up. """ root = tmp_path_factory.mktemp("detector-depth-deep") origin = root / "origin" origin.mkdir() _git(["init", "-q", "-b", "main", "."], origin) (origin / "a.txt").write_text("a\n") _git(["add", "-A"], origin) _git(["commit", "-q", "-m", "c0"], origin) _git(["tag", TAG], origin) # Empty commits: the depth boundary counts commits, and 210 real ones would only be slower. for i in range(1, 211): _git(["commit", "-q", "--allow-empty", "-m", f"c{i}"], origin) return f"file://{origin}" def _clone(origin_url: str, dest: Path, depth: int | None = None) -> Path: args = ["clone", "-q"] if depth is not None: args += [f"--depth={depth}"] args += [origin_url, str(dest)] _git(args, dest.parent) return dest def _run_detector(repo: Path, tmp_path: Path, **github_env: str) -> tuple[subprocess.CompletedProcess, str]: """Drive the real detector. `GITHUB_REF_TYPE` is pinned to `branch` rather than left to the ambient environment. `_env()` deliberately carries the rest of `os.environ` (for `ETV_HOOK_FIRE_LOG_DIR`), and the detector's FIRST branch returns early on `GITHUB_REF_TYPE=tag` — before any fetch — so an exported `tag` turns every test here red for a reason that has nothing to do with what they assert. Not live today, stated precisely rather than dramatised: the `scan` job DOES run pytest on tag pushes, but over two named files, and this is not one of them; `script-tests`, which does run this file, is `on: pull_request`. Adding this file to `scan`'s argument list is the one edit that would make it live. It is a default (`env.update(github_env)` follows), so a future test can still drive the tag arm deliberately. """ out_file = tmp_path / f"gh-output-{repo.name}" out_file.write_text("") env = {"GITHUB_REF_TYPE": "branch"} env.update(github_env) r = subprocess.run( ["bash", str(DETECTOR)], cwd=str(repo), env=_env(GITHUB_OUTPUT=str(out_file), **env), capture_output=True, text=True, ) return r, out_file.read_text() # -------------------------------------------------------------------------------------------- # The negative control. Everything else here asserts that a graft does NOT happen, so it is all # vacuous if the fixture cannot graft in the first place. # -------------------------------------------------------------------------------------------- @pytest.mark.parametrize( "fixture_name, depth", [("origin_url", 2), ("deep_origin_url", 200)], ids=["push-arm-depth-2", "pr-arm-depth-200"], ) def test_the_fixture_can_actually_graft(request, fixture_name, depth, tmp_path): """The raw `--depth=N` fetch the detector must not perform DOES graft this fixture. Parametrised over BOTH fixtures and BOTH depths, because a control that covers only one of them leaves the other free to go vacuous unreported — and that is MEASURED: the PR arm against the 31-commit origin, where a depth of 200 has nothing to cut, passed against the unfixed script. One control per (fixture, depth) pair the file relies on. """ origin = request.getfixturevalue(fixture_name) repo = _clone(origin, tmp_path / f"control-{depth}") assert _is_shallow(repo) == "false", "a plain clone should be complete" assert _git(["describe", "--tags", "--abbrev=0"], repo) == TAG _git(["fetch", "--no-tags", f"--depth={depth}", "origin", "main"], repo) assert _is_shallow(repo) == "true", ( f"a --depth={depth} fetch did not graft this fixture, so the test that relies on it is " "vacuous — most likely the clone/fetch fell back to the local transport, which ignores " "--depth (use file://), or the fixture has fewer commits than the depth" ) described = _git_rc(["describe", "--tags", "--abbrev=0"], repo) assert described.returncode != 0, ( "the graft happened but `git describe` still resolved, so the version this file protects " "is not actually reachable from the shallow boundary — the fixture's tag is too close to " f"the tip. stdout={described.stdout!r}" ) # -------------------------------------------------------------------------------------------- # The guard proper. # -------------------------------------------------------------------------------------------- def test_the_push_arm_leaves_a_COMPLETE_clone_complete(origin_url, tmp_path): """`build`'s shape: `fetch-depth: 0`, push to main. The version must survive the detector. This is the proof named by `scripts/ci-detect-docs-only.sh`'s `MUTATION` row in `docs/guard-inventory.md`; the declared mutation in `mutation_manifest.py` disarms the shallow condition, which puts the `--depth=2` fetch back and reddens the two assertions below. """ repo = _clone(origin_url, tmp_path / "build-shape") assert _is_shallow(repo) == "false" r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main") assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}" assert _is_shallow(repo) == "false", ( "the detector GRAFTED the complete clone shallow — this is ersatztv#836: the next step in " "`build` is `Compute version and tags`, whose `git describe` then finds no reachable tag " f"and the image ships InformationalVersion 0.0.0-.\ndetector said:\n{r.stdout}" ) described = _git_rc(["describe", "--tags", "--abbrev=0"], repo) assert described.returncode == 0 and described.stdout.strip() == TAG, ( "after the detector, `git describe --tags --abbrev=0` no longer resolves, so `build` would " f"stamp this image 0.0.0. git said: {described.stdout.strip()!r} {described.stderr.strip()!r}" ) # The detector still has to do its actual job on this shape. assert "docs_only=true" in output, ( f"the merge tip is docs-only but the detector did not say so:\n{r.stdout}\n{output}" ) def test_the_pull_request_arm_leaves_a_COMPLETE_clone_complete(deep_origin_url, tmp_path): """A complete checkout on the PR arm, N=200. NO PRODUCTION CONSUMER TODAY — deliberately. MEASURED: on `pull_request` the only jobs running this script are the three `fetch-depth: 2` ones, so the PR arm's `--depth=200` is inert rather than latently firing. What this pins is the SHARED CLAUSE — the next `fetch-depth: 0` consumer added to this arm must not silently inherit the graft that cost #836. It is a guard against a future shape, not a reproduction of a shipped one, and grading it as the latter would overstate what the file proves. The deep fixture is what makes the case real at all: with the 32-commit origin a depth of 200 has nothing to cut, and this test passed against the unfixed script. """ repo = _clone(deep_origin_url, tmp_path / "pr-shape") assert _is_shallow(repo) == "false" # A PR head: one docs commit on top of the base branch. _git(["checkout", "-q", "-b", "pr-branch"], repo) (repo / "docs").mkdir(exist_ok=True) (repo / "docs" / "b.md").write_text("doc\n") _git(["add", "-A"], repo) _git(["commit", "-q", "-m", "docs on the PR head"], repo) r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="pull_request", GITHUB_BASE_REF="main") assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}" assert _is_shallow(repo) == "false", ( "the PR arm GRAFTED a complete clone shallow (ersatztv#836). No job runs this arm against a " f"complete checkout today, so this is the shared clause regressing.\n{r.stdout}" ) described = _git_rc(["describe", "--tags", "--abbrev=0"], repo) assert described.returncode == 0 and described.stdout.strip() == TAG, ( "after the PR arm, `git describe --tags --abbrev=0` no longer resolves: " f"{described.stdout.strip()!r} {described.stderr.strip()!r}" ) # FETCH_HEAD is what the PR arm diffs against, so the depth-less fetch must still write it. assert _git_rc(["rev-parse", "--verify", "-q", "FETCH_HEAD"], repo).returncode == 0, ( "the PR arm no longer writes FETCH_HEAD, so its base revision is unresolvable" ) assert "docs_only=true" in output, ( f"the PR head is docs-only but the detector did not say so:\n{r.stdout}\n{output}" ) def test_a_SHALLOW_checkout_still_gets_its_depth_and_still_decides(origin_url, tmp_path): """The other half: `test`/`migrations`/`functional-e2e` at `fetch-depth: 2` must be unchanged. Removing the graft must not turn into removing the depth. A shallow checkout stays shallow, and the detector still resolves `HEAD^1` and classifies the docs-only merge — the behaviour the `--depth=2` was added for in the first place. """ repo = _clone(origin_url, tmp_path / "shallow-shape", depth=2) assert _is_shallow(repo) == "true", "the fixture clone was meant to be shallow" r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main") assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}" assert _is_shallow(repo) == "true", "a shallow checkout was silently unshallowed" assert "docs_only=true" in output, ( "the shallow checkout no longer classifies the docs-only merge — the depth stopped being " f"applied where it IS needed:\n{r.stdout}\n{output}" ) assert "fetching with --depth=2" in r.stdout, f"the shallow checkout did not take the depth branch:\n{r.stdout}" assert "fetching without --depth" not in r.stdout, ( f"the shallow checkout took the complete-clone branch:\n{r.stdout}" ) def test_an_UNKNOWN_shallow_answer_passes_no_depth(tmp_path): """Fail-direction, stated and executed: `--is-shallow-repository` unanswerable => no depth. Defaulting the other way would re-graft on exactly the paths nobody can observe, so the branch is pinned rather than argued. Running outside a repository is one way to make the query unanswerable and the one this drives; it is NOT the only one — on a git older than 2.15 the flag is unrecognised and rev-parse echoes it back verbatim with status 0. That shape is not covered here (it needs an ancient git), and it reaches the same no-depth branch. WHAT THIS DELIBERATELY DOES NOT ASSERT. Outside a repository the detector exits 128 with an empty `$GITHUB_OUTPUT`, because `set -o pipefail` makes the `git rev-list ... | wc -w` assignment below the fetch abort under `set -e`. That is PRE-EXISTING and unchanged here — measured against `8aeacd534`, the commit this fix branched from, which exits 128 the same way. It is also not a fail-open: the step goes red, so the job does, and no `docs_only=true` is ever emitted. Asserting a clean exit here would be asserting a behaviour the script has never had. """ notarepo = tmp_path / "notarepo" notarepo.mkdir() r, _output = _run_detector(notarepo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main") assert "is-shallow=unknown" in r.stdout, f"the unknown answer was not reached or not reported:\n{r.stdout}" assert "fetching without --depth" in r.stdout, f"an unknown shallow answer must not ask for a depth:\n{r.stdout}"