#!/usr/bin/env bash # Exhaustively enumerate a PR's changed file paths, or fail closed. # # ersatztv#649. This is the ONE implementation of the security-critical half of the merge gate. # It exists because the same logic was written twice — once in `.claude/hooks/pretooluse-merge-consent.sh` # (advisory: a failure produces a human prompt) and once in `.gitea/workflows/review-verdict.yml` # (ENFORCED: it writes the branch-protection-required `review-verdict/h10` status). The advisory copy # accumulated four rounds of hardening (ersatztv#643) that the enforced copy never received, leaving the # copy with real authority strictly weaker than the copy without. Two copies of a security predicate # drift; one cannot. # # SCOPE — mechanism, not policy. This script answers exactly one question: "what is the complete set of # paths this PR touches, with no head or base movement observable from in here, or can we not tell?" # ("at one head" until 2026-08-28 — the same overclaim as the CONTRACT below, in the paraphrase that # survived the first sweep for it.) It deliberately does NOT classify the PR. # The two callers' allow-lists differ ON PURPOSE and must stay separate: # * the hook's docs-only pattern also lets .claude/ .gitea/ .husky/ through, which is safe there only # because it falls through to a HUMAN PROMPT; # * the workflow's is narrower, because there a match posts a green status with nobody in the loop. # Sharing the enumeration fixes the drift; sharing the classification would erase an intended difference. # # CONTRACT # Usage: pr-changed-files.sh # stdout: newline-delimited paths, BOTH sides of every rename, no blank lines. May be empty. # exit 0 the enumeration is COMPLETE, and both and were # observed unchanged at BOTH ends of it. stdout is authoritative. # # READ THAT AS THE ONE-WAY GUARANTEE IT IS (ersatztv#803/#664). This line said "bound to # " until 2026-08-28, which claims more than the code can do. Every # binding below compares a value against ITSELF, so it detects movement that is still in # effect at the end and is blind to an ALIAS: `H1 -> H2 -> H1` across the paging round # trips, or `main -> S -> main`, restores the expected value while the middle pages were # enumerated against the other one. Exit 0 means "no movement was OBSERVABLE from here", # not "this list belongs to one head". # # A caller needing the stronger property must fence it with a MONOTONIC key, because a # count cannot alias where a value can. The ENFORCED caller # (`.gitea/workflows/review-verdict.yml`) does exactly that on both axes — it counts # `change_target_branch` and `pull_push` timeline events before and after, and writes # nothing if either moved (`ci.verdict-write-retarget-fence`). The advisory hook does not, # and does not need to: its failure mode is a human prompt, not a green status. # exit 1 the enumeration could NOT be completed or verified. stdout is meaningless — the caller # MUST fail closed (withhold any exemption). A diagnostic goes to stderr. # exit 2 usage error. # Callers must treat any non-zero exit as "no exemption". Never read stdout without checking the status. # # WHY THE BASE REF IS AN ARGUMENT, AND WHY IT IS NOT OPTIONAL (ersatztv#698 route 1). # `/pulls/{n}/files` computes the diff against the PR's **live** base, which is mutable. Retargeting a # PR changes the enumerated file set without moving the head sha, so head-binding alone does not bind # the ANSWER — only the commit it is nominally about. Reproduced live on this instance: a PR opened # into `main` and retargeted mid-run to a scratch base enumerated as docs-only and was granted # `review-verdict/h10=success`, while its diff against `main` carried a C# file (probe PR #703). # # REQUIRED rather than optional on purpose. An optional binding on a shared security primitive is an # opt-out, and the caller that forgets it is precisely the caller that needed it — silently. Five # arguments or exit 2. # # This NARROWS the window, it does not erase it. The base is re-read after the paging round trips # alongside the head, so a retarget that is still in effect at that point fails closed; a retarget # that opens and closes strictly between the files call and the re-read is not observable from here. # Pinning the diff to two shas would close it, and Gitea cannot serve that: `compare/{base}... # {head}` returns `total_commits`/`commits` and NO `files` (measured on 1.25.4, re-confirmed on # 1.27.1 2026-08-28 ersatztv#747). Separately: a `--depth=1` fetch of the two shas has no merge base, # so a three-dot diff is impossible while a two-dot one over-reports every commit `main` gained since # the branch point (measured, #698). That one is a GIT property, not a Gitea one — it carried a Gitea # version stamp until ersatztv#869 re-probed it on 2026-09-02 (git 2.55.0: `merge-base` exits 1, the # three-dot diff exits 128 `no merge base`) and re-filed it against the right axis. Independently re-probed again on # 1.27.1, 2026-08-28 (ersatztv#803): still `total_commits`/`commits` only, over a 12-commit range. # # The remainder is covered one level up instead, and since ersatztv#803 that cover is explicit on BOTH # axes rather than the base alone: the enforced caller fences its WRITE on the monotonic count of # `change_target_branch` AND `pull_push` timeline events (`ci.verdict-write-retarget-fence`) — which # is the thing an ALIAS cannot defeat — in addition to reclassifying on `edited` rather than trusting # a machine-written success. # # AUTH/TRANSPORT is caller-supplied via env, because the two callers authenticate differently: # ETV_GITEA_TOKEN | GITEA_TOKEN -> `Authorization: token` # ETV_GITEA_BASICAUTH -> curl -u user:pass # ETV_GITEA_URL | GITEA_BASE_URL -> API base; defaults to the homelab Gitea. A value ending in # /api/v1 is used as-is, otherwise /api/v1 is appended. # # jq COMPATIBILITY (ersatztv#648). This runs on the CI runner, which ships **jq 1.6**, while it is # authored on Macs shipping 1.8.x. It is therefore written to the 1.6-compatible subset: # * never rely on `jq -e`'s exit status over EMPTY input — 1.6 exits 0 where >=1.7 exits 4, which is # precisely the fail-open that ersatztv#647 found live in the enforced gate. Emptiness is always # checked explicitly in shell FIRST. # * never use `contains()` for substring tests — on 1.6 `contains("")` is true for every string. # * never distinguish a parse error from "no output" by exit code — 1.6 returns 4 for both. # See docs/ci-cd.md -> "The jq contract". set -euo pipefail if [ "$#" -ne 5 ]; then echo "usage: pr-changed-files.sh " >&2 exit 2 fi owner=$1 repo=$2 pr=$3 expected_sha=$4 expected_base=$5 if [ -z "$owner" ] || [ -z "$repo" ] || [ -z "$pr" ] || [ -z "$expected_sha" ] || [ -z "$expected_base" ]; then echo "pr-changed-files: empty owner/repo/pr/sha/base argument" >&2 exit 2 fi base_url="${ETV_GITEA_URL:-${GITEA_BASE_URL:-http://192.168.1.95:3000}}" case "$base_url" in */api/v1) : ;; */) base_url="${base_url}api/v1" ;; *) base_url="${base_url}/api/v1" ;; esac # Empty output on ANY failure, so every caller path treats a transport error the same way. The # emptiness is then rejected explicitly below — never inferred from a jq exit code. gq() { local path="$1" if [ -n "${ETV_GITEA_TOKEN:-}" ]; then curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true elif [ -n "${GITEA_TOKEN:-}" ]; then curl -sf -H "Authorization: token $GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true else printf '' fi } if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then echo "pr-changed-files: no Gitea credentials in env — cannot enumerate, failing closed" >&2 exit 1 fi # Bind the BASE before the first page is requested (ersatztv#698 route 1). Checking only afterwards # would leave the common case — a PR retargeted before the enumeration even starts — indistinguishable # from an honest one, because every page would agree with every other page while all of them described # a diff against the wrong base. Both ends are checked; neither alone is sufficient. prjson_before=$(gq "repos/$owner/$repo/pulls/$pr") if [ -z "${prjson_before//[[:space:]]/}" ]; then echo "pr-changed-files: could not read PR #$pr to bind the base ref before enumerating — failing closed" >&2 exit 1 fi base_before=$(printf '%s' "$prjson_before" | jq -r '.base.ref // ""' 2>/dev/null || true) if [ -z "$base_before" ] || [ "$base_before" != "$expected_base" ]; then echo "pr-changed-files: PR #$pr targets '${base_before:-}', not the expected '$expected_base' — the diff would be computed against a different base, failing closed" >&2 exit 1 fi # Also capture the base's TIP at this same read (ersatztv#707). This costs no extra round trip — # `prjson_before` is already fetched above for the `.base.ref` check. It answers a DIFFERENT # question than that check does, and the two are not interchangeable: # * `.base.ref` (above) answers "did this PR RETARGET to a different branch" — comparing branch # NAMES is deliberate there (ersatztv#698 route 1 / ersatztv#632), because comparing tip shas # for that purpose would self-deadlock: `main` advancing on every unrelated merge would fail # every open enumeration even though the PR still targets the same branch it always did. # * `.base.sha` (here) answers "did `$expected_base` ADVANCE while THIS enumeration was running." # `/pulls/{n}/files` diffs against the base's LIVE tip and is offset-paged over several round # trips; if `main` gains a commit mid-enumeration, Gitea recomputes each subsequent page against # the new tip independently, so rows can drop out of the result entirely (a file `main` no longer # differs on) while later rows shift into offset ranges already consumed on the old tip. The # result reads as a complete, ordinary list — `.base.ref` never changed, `.head.sha` never # changed, page count and termination all look normal — while silently omitting a page's worth of # changed paths, including possibly the only code file in the diff. This is a narrower, additional # check layered on top of the ref check, not a replacement for it. base_sha_before=$(printf '%s' "$prjson_before" | jq -r '.base.sha // ""' 2>/dev/null || true) if [ -z "$base_sha_before" ]; then echo "pr-changed-files: could not read PR #$pr's base tip sha before enumerating — failing closed" >&2 exit 1 fi PAGE_SIZE=50 MAX_PAGES=40 # 2000 files; beyond this we refuse rather than guess files="" page=1 complete=no while [ "$page" -le "$MAX_PAGES" ]; do raw=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=${PAGE_SIZE}&page=${page}") # An EMPTY body is rejected in SHELL, before jq sees it. `jq -e` over empty input exits 4 on # jq >= 1.7 but 0 on jq 1.6, and the runner ships 1.6 — leaving this to jq's exit status is the # exact fail-open ersatztv#647 found in the enforced copy. A transport failure must never # masquerade as a legitimate short final page. if [ -z "${raw//[[:space:]]/}" ]; then echo "pr-changed-files: empty/unreadable response for page ${page}" >&2 complete=no; break fi # VALIDATE EVERY FIELD THE EXTRACTION BELOW CONSUMES, on EVERY row. # # * Top-level type alone is not enough: `[{}]` is a well-formed array whose rows carry no # `filename`, so it contributes no paths, looks like a short page, and would complete the # enumeration from a PARTIAL list — the same failure one level down. It also rejects arrays of # scalars, which would otherwise make the extraction fail under `set -e`. # * CR/LF in a path is rejected outright. `chunk` flattens paths into newline-delimited text, so a # filename containing a newline splits into TWO lines matched against the allow-list separately: # "safe.md\ndocs/Program.cs" yields `safe.md` and `docs/Program.cs`, both of which pass, while the # real single path ends in `.cs`. Git permits newlines in filenames, so this is reachable and was # reproduced against the hook. # * `previous_filename` is validated on EVERY row, not only `renamed` ones, because `chunk` emits it # for every row regardless of `.status`. Validating it only where it is semantically "supposed to" # appear left a hole one predicate wide: a `status: "modified"` row carrying a newline in # `previous_filename` was reproducibly exempted. The validation domain must match the CONSUMPTION # domain. # * `..` is rejected because the callers' allow-lists anchor `^docs/`, so `docs/../ErsatzTV/Program.cs` # matches one. Git will not produce such a path; this guard's job is to fail closed on unexpected # 2xx shapes rather than assume a well-behaved peer. # * `.status` is checked against a CLOSED set. Be precise about what this does and does not do: # the extraction below emits `(.previous_filename // empty)` UNCONDITIONALLY, so a present # `previous_filename` is never dropped on account of `.status`. What the closed set actually buys # is rejecting rows whose vocabulary we do not recognise — where a source path may be absent, or # carried in some other field we are not reading. Without it, `"Renamed"` with a capital R, or an # absent status, silently takes the `else true` branch of the clause below and skips the # "renamed rows MUST carry previous_filename" requirement entirely. The source path is NOT # "dropped" — that is not the mechanism, and a maintainer who tested that claim would find it # false and might conclude the check is redundant. # `modified` is accepted alongside `changed` deliberately. Live Gitea emits `changed`; a re-derivation # on 1.27.1 (2026-08-28, ersatztv#747) over the file rows of the 200 most recently updated PRs saw # `changed`, `added`, `renamed` and `deleted` — all four already in the list below. `copied` and # `modified` did not appear in that corpus, which does not show they are never emitted. `modified` # is retained because a closed allow-list built from the wrong vocabulary is a worse failure than # the hole it closes — it would gate every genuine docs-only PR on any version that spells it # differently. The property is # "reject values we do not recognise", not "enumerate one version exactly". if ! printf '%s' "$raw" \ | jq -e 'def ok: type == "string" and length > 0 and (test("[\\r\\n]") | not) and (split("/") | index("..") | not); type == "array" and all(.[]; (.filename | ok) and (.previous_filename == null or (.previous_filename | ok)) and ((.status // "") as $s | ($s | type) == "string" and (["added","deleted","changed","modified","renamed","copied"] | index($s)) != null) and (if .status == "renamed" then (.previous_filename | type == "string" and length > 0) else true end))' \ >/dev/null 2>&1; then echo "pr-changed-files: page ${page} failed row validation" >&2 complete=no; break fi # BOTH sides of a rename: Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION, # with the source only in `previous_filename`. Reading `filename` alone lets a PR move a protected # file INTO docs/ and pass as docs-only (verified live: `.gitea/workflows/renovate.yml` -> # `docs/innocuous-note.md` showed no protected path). One renamed row is therefore ONE row but TWO # paths, which is why the two counts below are computed differently. n=$(printf '%s' "$raw" | jq -r 'length') chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)') [ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk") # Terminate ONLY on an explicitly validated EMPTY page — never on a merely SHORT one. # "Fewer than 50 rows means last page" assumes the server's page size is the 50 we asked for, but # Gitea caps `limit` at the server-wide MAX_RESPONSE_ITEMS (default 50, configurable) and is free to # return fewer. A 30-row page followed by a page of code would complete the enumeration over a # PARTIAL list — the same fail-open, reached without any transport error. Costs one extra request; # the MAX_PAGES cap still fails closed. if [ "$n" -eq 0 ]; then complete=yes; break; fi page=$((page + 1)) done if [ "$complete" != yes ]; then echo "pr-changed-files: enumeration incomplete (stopped at page ${page}) — failing closed" >&2 exit 1 fi # Re-read the head and refuse if it MOVED. Paging is several round-trips; a force-push between them # means page 1 came from head A and page 2 from head B, so the assembled list belongs to no single # commit — B's code page can be skipped entirely while B's docs page reads as a clean short tail. # # ONE-WAY ONLY. This said "Bind the enumeration to ONE head" until 2026-08-28, which is the overclaim # ersatztv#664 was filed against and ersatztv#803 carried: `H1 -> H2 -> H1` across the paging window # passes the comparison below, because the value re-read is the value expected, while pages 1 and 2 # came from different trees. Nothing checkable HERE closes that — the check would have to compare # against something the API does not offer (see the `compare/` probe above) — so it lives at the # caller as a monotonic event count instead (`ci.verdict-write-retarget-fence`). prjson=$(gq "repos/$owner/$repo/pulls/$pr") if [ -z "${prjson//[[:space:]]/}" ]; then echo "pr-changed-files: could not re-read PR head to bind the enumeration — failing closed" >&2 exit 1 fi sha_after=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true) if [ -z "$sha_after" ] || [ "$sha_after" != "$expected_sha" ]; then echo "pr-changed-files: head moved during enumeration (${expected_sha:0:7} -> ${sha_after:0:7}) — failing closed" >&2 exit 1 fi # The same round-trip window applies to the BASE, and the head check cannot see it: retargeting moves # the diff without moving the head sha (ersatztv#698 route 1). Comparing `.base.ref` — the branch NAME, # never its tip — is deliberate and matches `post-review-verdict.sh` (ersatztv#632): a base that merely # ADVANCES is ordinary churn, while comparing tips would fail every enumeration on every unrelated # merge to `main`. base_after=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true) if [ -z "$base_after" ] || [ "$base_after" != "$expected_base" ]; then echo "pr-changed-files: base moved during enumeration ('$expected_base' -> '${base_after:-}') — the enumerated diff is against a base this PR no longer targets, failing closed" >&2 exit 1 fi # Same window, the tip-advance question this time (ersatztv#707; see the comment at # `base_sha_before` above for why this is a DIFFERENT check from `.base.ref`, not a duplicate of # it). `prjson` is already fetched above to re-read the head sha, so this is the same read, not a # new round trip. `$expected_base`'s branch name can be unchanged across the whole enumeration # while its TIP moved partway through — the exact #707 window: no retarget, no head movement, # nothing the ref check or the head-sha check can see, yet later pages were diffed against a base # earlier pages never saw. base_sha_after=$(printf '%s' "$prjson" | jq -r '.base.sha // ""' 2>/dev/null || true) if [ -z "$base_sha_after" ] || [ "$base_sha_after" != "$base_sha_before" ]; then echo "pr-changed-files: base '$expected_base' advanced during enumeration (${base_sha_before:0:7} -> ${base_sha_after:0:7}) — later pages may have been diffed against a base earlier pages were not, failing closed" >&2 exit 1 fi printf '%s\n' "$files" | grep -v '^$' || true exit 0