Files
ersatztv/scripts/pr-changed-files.sh
T
timothy 8f6d4f4432
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
review-verdict/h10 Awaiting review verdict for 8f6d4f4
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(706,707,711): fence the review-verdict write on the timeline retarget count
Three related defects in the `review-verdict/h10` gate, all surfaced by the
cross-family review of PR #705.

#706 race 1 — a stale run could overwrite a fresher verdict, permanently. The
race was reproduced live rather than reasoned about (Gitea 1.25.4): with every
other workflow stripped, probe PR #722 showed run 7520 (`opened`) finishing 20s
AFTER run 7521 (`synchronize`) started. `pull_request_target` runs for one PR
genuinely overlap, older finishing last.

The issue proposed serializing with a non-cancelling concurrency group. That is
REFUTED by measurement: with the group active, runs 7528/7529 still overlapped
and 7528 ended 36s after 7529 began. A first probe appeared to show the group
working — a negative control with no `concurrency:` key at all showed the same
cancellations, revealing Gitea auto-cancels superseded `push` runs on its own
and the probe had measured that, not the group. The auto-cancel does not extend
to `pull_request_target`.

The fix leaves the runs unserialized and instead makes an overtaken run decline
to write: count `change_target_branch` events on the PR timeline at start and
again just before the POST, and post nothing if the count moved. The COUNT is
the key because the branch NAME is ABA-vulnerable (`main -> S -> main` reads
`main` at both ends — how #698 route 1 forged its exemption). Abstaining is a
handoff, not a stall: every retarget fires `edited`, so the event that makes a
run abstain has already queued its successor. `updated_at` was rejected as the
key precisely because it moves for comments/labels, which queue nothing.

#706 race 2 — a human BLOCKED landing in the unclosable window between the
pre-POST re-read and the POST was silently turned green. After an exemption
`success` the job now re-reads the per-POST history and repairs its own status
to `pending` if a human verdict appeared above a high-water mark taken just
before the write. The repair is `pending`, never a copy of the human's state.
The id comparison is load-bearing: a presence test would fire forever on a
base-mismatched verdict and deadlock that PR's exemption.

#707 — `pr-changed-files.sh` bound `.base.ref` and `.head.sha` across the
enumeration but never `.base.sha`, so an ordinary advance of `main` mid-paging
could drop a code path from an offset-paged diff and leave a complete-looking
docs-only list. Now bound from the JSON already fetched (no new round trips).

#711 — `.codex/` added to PROTECTED. It mirrors `.claude/hooks/` byte for byte,
including the merge-consent hook, so the "a PR that can weaken the gate cannot
exempt itself" rule had an incomplete path list. Latent today (untracked), live
the moment anyone tracks it.

Residuals are stated, not implied: a retarget inside the final round-trip, and
the repair being itself a read-then-write. Gitea's status API has no
compare-and-set, so neither reaches zero; both now fail toward `pending`.

Tests: 398 pass in scripts/tests. Each new guard was mutation-checked — the
fence's motion comparison, the untrusted-count gate, the repair POST and the id
high-water mark were each neutered in turn and the intended test went red while
its positive control stayed green.

fixes #706
fixes #707
fixes #711

Decisions-Edit: yes
2026-08-03 21:41:16 +02:00

279 lines
17 KiB
Bash
Executable File

#!/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, at one head, or can we not tell?" 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 <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>
# stdout: newline-delimited paths, BOTH sides of every rename, no blank lines. May be empty.
# exit 0 the enumeration is COMPLETE and bound to <expected-head-sha> AND <expected-base-ref>.
# stdout is authoritative.
# 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 1.25.4 cannot serve that: `compare/{base}...
# {head}` returns `total_commits`/`commits` and NO `files`, and 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 (both measured, #698). The remainder is covered one level up
# instead, by the workflow 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("<NUL>")` 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 <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>" >&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:-<unreadable>}', 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. (An earlier version of this
# comment claimed the source path would be "dropped", which is not the mechanism; 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 1.25.4 emits `changed`, but 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
# Bind the enumeration to ONE head. 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. Re-read the
# head and refuse if it moved.
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:-<unreadable>}') — 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 bind the head sha, so this is the same re-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