PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 28s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m8s
review-verdict/h10 Review-verdict: BLOCKED @ f523fc5 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 23m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The `review-verdict/h10` exemption path decided from mutable or unattributed PR state, and a machine-written `success` was never revalidated. Three routes, one root cause, so one change. Route 1 (reproduced live as probe PR #703, closed unmerged): `/pulls/{n}/files` diffs against the PR's LIVE base, so retargeting moves the answer without moving the head sha. A PR opened into `main` and retargeted mid-run enumerated docs-only and was granted `h10=success` while its diff against `main` carried a C# file; retargeting back reclassified nothing. `scripts/pr-changed-files.sh` now takes the expected base branch as a REQUIRED 5th argument (optional would be a silent opt-out) and checks it before and after paging; the workflow passes it from the `pull_request_target` payload, which a retarget cannot rewrite, and `edited` is in `types:` so a retarget reclassifies. A pinned two-sha diff would close route 1 outright but Gitea 1.25.4 cannot serve one: `compare/{base}...{head}` returns no `files`, and a `--depth=1` fetch of the two shas has no merge base. Measured, not assumed. The residual window is stated in the code and the record rather than papered over. Route 2: `pull_request.user.login` is the PR's immutable CREATOR while its head is not, so pushing code onto an open Renovate branch kept the exemption. The bot exemption now also requires EVERY path to be a dependency manifest — a set measured across all 11 Renovate PRs this repo has had, not guessed. Route 3: the never-overwrite short-circuit exited on ANY `success`, so a forgery obtained once was inherited forever. It now fires only for a status positively identified as a human verdict (non-null `.creator.login` AND a `Review-verdict:` description — measured: user-posted statuses carry a creator, Actions-posted ones carry null). Written in the positive direction so an unrecognised shape is re-derived rather than trusted. The two exemptions are composed, not chained: as an `elif` chain a Renovate docs-only PR lost the docs-only exemption. Caught before commit and pinned by a test. Tests: 17 new cases in scripts/tests/test_pr_changed_files.py, each verified by mutating the clause it covers (8 mutations, 8 kills). Both records trimmed under the 60-line prose ceiling so the corpus tail-boundary check stays calibrated. Does NOT close the class: anyone who can POST a status directly can still impersonate a verdict — that is #697, deliberately left open. Refs: #698 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
14 KiB
Bash
Executable File
245 lines
14 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
|
|
|
|
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
|
|
|
|
printf '%s\n' "$files" | grep -v '^$' || true
|
|
exit 0
|