Files
ersatztv/scripts/ci-detect-docs-only.sh
T
timothyandClaude Opus 5 58d2c147a4
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 21s
review-verdict/h10 Review-verdict: MERGEABLE @ 58d2c14 (base: main)
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 18s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Failing after 8s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
fix(836): never pass --depth to a checkout that may already be complete
`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.
`scripts/ci-detect-docs-only.sh` has four consumers in docker-build.yml and they
do not agree on checkout depth: test/migrations/functional-e2e check out
`fetch-depth: 2`, while `build` (and api-docs/format on the PR arm) check out
`fetch-depth: 0`. The script applied a depth chosen for the first group to all of
them.

For `build` that grafted its complete clone on every push to main, so the
`git describe --tags` in the immediately following `Compute version and tags`
step found no reachable tag, and a `|| echo v0.0.0` fallback that cannot fail
turned that into a version: every `:latest` image shipped
`InformationalVersion 0.0.0-<sha>`.

Both fetch sites now go through `fetch_ref`, which passes `--depth` only when
`git rev-parse --is-shallow-repository` answers `true`. That also closes the PR
arm's `--depth=200` — the same defect with a larger N, grafting api-docs/format.
An unknown answer passes no depth: the query fails only where there is no
readable repository, and a depth cannot help there either.

Second half: `Compute version and tags` no longer defaults. A failed
`git describe` on the non-tag path fails the job rather than stamping 0.0.0, so
no `:latest` is published instead of a mislabelled one. `build` is not a required
status context, so this cannot block a merge. The tag path never calls
`describe`, so `:prod`/`:<version>` releases are unaffected — which is also why
they were correctly versioned throughout.

Measured, not inferred:
- The live defect, from run 2416 / job 10345 (push to main 8aeacd534):
  `INFO_VERSION=0.0.0-8aeacd53`.
- Against a real anonymous clone of this repository: the predecessor detector
  grafts it and `describe` fails -> `0.0.0-1afad085`; the fixed detector leaves
  it complete -> `26.14.0-1afad085`.
- The workflow step body executed on both clones: grafted -> exit 1 with a
  diagnostic, complete -> `26.14.0-1afad085`, tag path -> `26.14.0` + `:prod`
  even on a grafted clone.

New guard `scripts/tests/test_docs_only_detector_clone_depth.py` drives the real
script over real `file://` clones (a plain path clone silently ignores --depth,
so it carries a negative control that requires the fixture to be able to graft at
all). It was witnessed red against the predecessor. Its clause mutation is
declared in mutation_manifest.py, which promotes the detector's guard-inventory
row from NONE to MUTATION; the stated reason it was undeclared — that a
skip-gate script's effect is visible only in a workflow run — was itself wrong.

fixes #836

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HETQK67q1bEpDZpHLZNsp9
2026-08-29 22:54:23 +02:00

182 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/ci-detect-docs-only.sh — emit `docs_only=true|false` to $GITHUB_OUTPUT for the
# docker-build.yml heavy-job gate (ersatztv#416). A change is "docs" iff every changed path is
# under docs/ or is a *.md file anywhere. docs_only=true ONLY when EVERY changed path is docs;
# any code path, an undeterminable diff, a tag build, or a non-PR/push event => false (run the
# full matrix).
#
# The bias is ALWAYS toward running MORE, never less: a false 'true' would skip the real
# test/migrations/build work on a code change, so every ambiguous case resolves to
# docs_only=false. It is fine (just wasteful) to run the full matrix on a docs change; it is a
# correctness bug to skip it on a code change.
#
# Why this is the merge-gate-safe half of #416: the two REQUIRED contexts (`Build & test (.NET)`,
# `EF migration integrity (SQLite + MySql)`) are gated by SKIPPING STEPS inside a job that always
# runs and always reports `success` — never by an `if:`-skipped job. On Gitea 1.25.4 an
# `if:`-skipped job reports commit-status state `skipped` (verified, PR #418; re-confirmed on
# 1.27.1, 2026-08-28, ersatztv#747), and how branch
# protection treats a `skipped` REQUIRED context is not something we rely on. Non-required jobs may
# skip freely (production already proves a `skipped` non-required context — e.g. `build` on every
# PR — does not block merge).
#
# Runs identically locally and in CI. Locally (no $GITHUB_OUTPUT) it prints the decision to stdout;
# e.g. GITHUB_EVENT_NAME=pull_request GITHUB_BASE_REF=main scripts/ci-detect-docs-only.sh
set -euo pipefail
out="${GITHUB_OUTPUT:-/dev/stdout}"
event="${GITHUB_EVENT_NAME:-}"
emit() {
echo "docs_only=$1" >> "$out"
echo "-> docs_only=$1"
}
# NEVER PASS `--depth` TO A COMPLETE CLONE (ersatztv#836). `git fetch --depth=N` GRAFTS one shallow:
# it writes `.git/shallow` and cuts history at N even though every object is already present. Both
# fetch sites below go through this one function, because they differ only in N.
#
# THE CONSUMERS, enumerated rather than assumed — `grep -rn ci-detect-docs-only .gitea/` returns
# exactly four call sites: `test`, `migrations` and `functional-e2e` (`fetch-depth: 2`, genuinely
# shallow) and `build` (`fetch-depth: 0`, complete). `api-docs` and `format` also have a step with
# `id: detect`, but it is their OWN inline diff, not this script. Enumerate by the grep above, never
# by the step id — that is the trap, and it points at the wrong two jobs. (The grep returns five
# hits: these four call sites plus docker-build.yml's own header comment.)
#
# `build` is therefore the only complete-clone consumer, and the push arm is the only arm it takes.
# That is where the defect was: the depth grafted its clone on every push to `main`, so the
# `git describe --tags` in the very next step ("Compute version and tags") found no reachable tag,
# and every `:latest` image carried `InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>`.
#
# THE PR ARM'S `--depth=200` HAS NO COMPLETE-CLONE CONSUMER TODAY. On `pull_request` the only
# consumers are the three `fetch-depth: 2` jobs, so that depth was inert, not latently firing. It is
# dropped anyway because the clause is shared: the next `fetch-depth: 0` consumer added to this arm
# would inherit the graft silently, and finding it again would cost what #836 cost.
#
# WHY THE DEPTH IS KEPT FOR SHALLOW CHECKOUTS, 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.
#
# Measured on a real `file://` clone: complete -> `git describe --tags --abbrev=0` = `v26.3.1`; after
# `git fetch --no-tags --depth=2` -> `.git/shallow` written, `fatal: No tags can describe`.
#
# ANY ANSWER BUT `true` PASSES NO DEPTH, and there is more than one such answer. `git rev-parse`
# fails outright where there is no readable repository; on a git older than 2.15 the flag is not
# recognised 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 both a depth would have been useless anyway. Defaulting the other way would re-graft on exactly
# the paths nobody can observe.
fetch_ref() { # $1 = ref to fetch; $2 = depth to use ONLY IF this checkout is already shallow
local is_shallow
is_shallow="$(git rev-parse --is-shallow-repository 2>/dev/null || echo unknown)"
if [ "$is_shallow" = "true" ]; then
echo "checkout is shallow; fetching with --depth=$2 (ersatztv#836)"
git fetch --no-tags "--depth=$2" origin "$1" 2>/dev/null
else
echo "checkout is not shallow (is-shallow=$is_shallow); fetching without --depth (ersatztv#836)"
git fetch --no-tags origin "$1" 2>/dev/null
fi
}
# A release tag must NEVER be treated as docs-only, whatever it touches.
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
echo "tag build (${GITHUB_REF_NAME:-?}); never docs-only"
emit false
exit 0
fi
# Resolve a concrete BASE COMMIT to diff HEAD against. SHALLOW-CHECKOUT SAFE (ersatztv#416
# follow-up): the `test`/`migrations`/`functional-e2e` jobs check out shallow (`fetch-depth: 2`), and
# a shallow clone has NO
# `origin/<base>` remote-tracking ref and no merge-base — so the old `origin/<base>...HEAD`
# (three-dot) errored, the diff came back empty, and EVERY docs-only PR silently ran the full
# matrix (safe but useless). `git fetch` ALWAYS writes FETCH_HEAD (the fetched base tip), which
# resolves even in a shallow clone; we diff against it with a TWO-dot tree diff below (no merge-base
# required). Verified in a real shallow `file://` clone. (`api-docs`/`format` only worked because
# they use `fetch-depth: 0`.)
base_rev=""
case "$event" in
pull_request)
base="${GITHUB_BASE_REF:-}"
if [ -z "$base" ]; then
echo "pull_request with no base ref; running full matrix (safe default)"
emit false
exit 0
fi
if ! fetch_ref "$base" 200; then
echo "fetch of base '$base' failed; running full matrix (safe default)"
emit false
exit 0
fi
base_rev="$(git rev-parse --verify -q FETCH_HEAD || true)"
;;
push)
# Only branch pushes reach here (tags handled above). Skip only when the range is UNAMBIGUOUS —
# HEAD is a merge commit: its first parent is the pre-merge tip, so HEAD^1 is the base. This is
# NOT how most updates to main land: 91 of the last 100 `origin/main` commits have ONE parent
# (measured 2026-08-29), so the arm below usually bails to the full matrix. Safe, and it means
# the docs-only skip rarely fires on a push at all.
#
# `fetch_ref ... 2` asks for depth 2 so the first-parent tree exists in a SHALLOW checkout, and
# for no depth at all in a complete one (see `fetch_ref`); if the base still can't be
# resolved/diffed we fall through to the full matrix. A non-merge (direct/multi-commit) push is
# ambiguous here -> full matrix.
fetch_ref "${GITHUB_REF_NAME:-main}" 2 || true
nfields="$(git rev-list --parents -n1 HEAD 2>/dev/null | wc -w | tr -d ' ')" # 1 (self) + parents
if [ "${nfields:-0}" -ge 3 ]; then
base_rev="$(git rev-parse --verify -q 'HEAD^1' || true)"
else
echo "non-merge push (parents=$(( nfields - 1 ))); running full matrix (safe default)"
emit false
exit 0
fi
;;
*)
echo "event '${event:-<none>}' is not pull_request/push; running full matrix (safe default)"
emit false
exit 0
;;
esac
if [ -z "$base_rev" ]; then
echo "could not resolve a base revision (shallow/offline); running full matrix (safe default)"
emit false
exit 0
fi
# TWO-dot tree diff `git diff <base> HEAD` (NOT three-dot) so no merge-base is needed — that is what
# makes this work in a shallow checkout. `--no-renames` is load-bearing: with rename detection ON
# (git's default) a code->docs rename (e.g. Foo.cs -> docs/Foo.md) shows ONLY the destination
# `docs/Foo.md`, hiding that a source file left the build -> misclassified as docs-only -> required
# tests skipped on a code change. --no-renames surfaces the deletion (`Foo.cs`, non-docs) so it
# correctly forces the full matrix, keeping the "any code path => run everything" invariant total.
changed="$(git diff --no-renames --name-only "$base_rev" HEAD 2>/dev/null || true)"
echo "Base: $base_rev (event=$event)"
echo "Changed files:"
printf '%s\n' "$changed"
if [ -z "$changed" ]; then
echo "empty/undeterminable diff; running full matrix (safe default)"
emit false
exit 0
fi
# docs_only unless SOME changed path is NOT docs. "docs" = under docs/ OR ends in .md (anywhere:
# README.md, CLAUDE.md, AGENTS.md, docs/handoffs/*.md, ...). Everything else — .cs, web/**,
# .gitea/**, Dockerfiles, scripts, csproj — is a code change and forces the full matrix.
#
# Capture the non-docs lines and test for emptiness rather than `grep -qv`: the combination of
# `-q` and `-v` early-exits inconsistently across grep implementations (BSD grep on macOS returned
# the wrong exit code here). `|| true` guards `set -e` when grep matches nothing (exit 1).
nondocs="$(printf '%s\n' "$changed" | grep -vE '(^docs/|\.md$)' || true)"
if [ -n "$nondocs" ]; then
echo "non-docs path(s) present in the diff -> NOT docs-only:"
printf '%s\n' "$nondocs" | sed 's/^/ /'
emit false
else
echo "every changed path is docs/ or *.md -> docs-only"
emit true
fi