Files
ersatztv/docs/decisions/records/ci/shared-pr-file-enumeration.md
T
90f96c14a5
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m37s
fix(803,664): fence the HEAD alias on the PR timeline's pull_push count (#873)
A force-push H1 -> H2 -> H1 spanning `pr-changed-files.sh`'s paging leaves its final
`.head.sha` comparison equal while the middle pages came from H2, so a mixed file list
could produce a docs-only exemption `success` no single head ever justified. The base
alias had been fenced since #706 by a monotonic `change_target_branch` count; the head
axis had nothing, and three contracts asserted otherwise.

`count_retargets` becomes `count_pr_mutations`: one timeline walk, two tallies, one shared
trust flag, a separate fence arm and diagnostic per axis. The advisory hook re-reads
`.head.sha` at the same hoist and off the same response as the base re-read. All three
overclaiming contracts are corrected, plus four paraphrases the first sweep missed.

Measured, not assumed: Gitea 1.27.1 still serves no `files` on `compare/{base}...{head}`;
every push is a `pull_push` event and its count cannot alias; PR #761 really went
`8798a1d -> 830a407 -> 8798a1d`; and Gitea creates the push comment BEFORE emitting the
synchronize notification, so a run cannot abstain on its own trigger.

Two pre-existing fail-opens in the shared walk were found by review and fixed: an empty
ARRAY first page was trusted on any page while the `null` arm required `page > 1`, and no
row was validated before `.type` was selected on.

NOT closed, and documented rather than overclaimed: the walk's `null` terminator is
defeatable, because Gitea pages before it filters (#870). The fence closes the ABA on a
timeline with no truncating block, not the ABA outright.

fixes #803
fixes #664
Refs #870

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 23:29:47 +00:00

13 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.shared-pr-file-enumeration 2026-07-26 — `scripts/pr-changed-files.sh` is the ONE enumeration of a PR's changed files; the advisory hook and the enforced gate share mechanism, never policy (#649) active 2026-07-26 none none A PR's complete set of changed file paths is computed by exactly one implementation, `scripts/pr-changed-files.sh`, called by both `.claude/hooks/pretooluse-merge-consent.sh` (advisory — a failure falls through to a human prompt) and `.gitea/workflows/review-verdict.yml` (enforced — a failure must fail closed, because a match here posts the branch-protection-required `review-verdict/h10` status with nobody in the loop). The script owns exhaustiveness (pagination, rename/path validation, head-sha and base-ref MOVEMENT DETECTION — one-way, never a binding: an A->B->A alias on either axis passes, #664/#803 — see `ci.exemption-provenance` — and base-TIP binding, #707: the ref answers "did this PR RETARGET", the tip answers "did the base ADVANCE mid-enumeration", and only the second can see `/pulls/{n}/files` recomputing each offset-paged page against a moved base and dropping a path out of an already-consumed range; both ends of the window are read and compared, and an advance BEFORE the window is deliberately not an error, or ordinary churn on `main` would fail every open PR) and returns exit 0 only for a verified-complete list; it does NOT classify paths — each caller keeps its own docs-only allow-list, and the two allow-lists differ on purpose and stay separate. duplicated PR file enumeration, enforced gate weaker than advisory hook, docs-only allow-list drift, shared mechanism not shared policy, pr-changed-files.sh, checkout base ref not PR head, gate judging its own PR, exhaustiveness bug in a security predicate · paths: `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `.gitea/workflows/review-verdict.yml` · issues: #643, #648, #649 `scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` -> stdout newline-delimited paths, exit 0 only if complete AND neither the given sha nor the given base branch was observed to move across the paging round trips — ONE-WAY detection, not a binding: an A->B->A alias on either axis passes (#664/#803), which is why the enforced caller adds a monotonic event-count fence; the 5th argument is REQUIRED and a 4-arg call exits 2 (`ci.exemption-provenance`); callers: `.claude/hooks/pretooluse-merge-consent.sh`, `.gitea/workflows/review-verdict.yml`

Before #649, the PR changed-file enumeration existed as two independent implementations. That would be an ordinary duplication smell anywhere else; here it was actively dangerous, because the two copies had unequal consequence. The advisory hook's failure mode is a human permission prompt — a missed guard there just means a person gets asked instead of an automatic decision. The enforced workflow's failure mode is a success write to review-verdict/h10, the one status branch protection actually requires — a missed guard there merges an unreviewed PR with nobody asked at all.

The drift that motivated this. Four rounds of #643 hardening landed entirely on the copy with the lower stakes. The hook accumulated CR/LF rejection, .. rejection, a closed .status allow-list, previous_filename validation on every row (not just renamed), termination only on a validated empty page, and head-sha binding — while the enforced workflow kept the original, weaker logic. Its fail-closed behavior on a garbage API response was incidental (an empty n erroring a bash conditional to false), not a designed property. The gate with real authority was strictly weaker than the gate with none, which is the wrong way around by construction, not by anyone's mistake in a single review — nothing in the original layout forced the two to move together.

Why the fix is "one script, two callers" rather than "copy the hardening across." Copying keeps the two-implementation shape; the next hardening round would only need to happen twice again, and there is no mechanism that would surface a second drift before it mattered. Extracting scripts/pr-changed-files.sh makes the enumeration a single artifact with a single test suite (scripts/tests/test_pr_changed_files.py), so a future guard is added once and both callers get it atomically.

Mechanism, not policy — the two allow-lists stay separate on purpose. The extracted script answers exactly one question: "what is the complete set of paths this PR touches, with no head or base movement observable from inside the enumeration, or can we not tell?" It does not decide whether that set makes the PR docs-only. Each caller keeps its own classification:

  • The hook's docs-only pattern also lets .claude/, .gitea/, .husky/ through, which is safe there only because a non-match falls through to a human prompt rather than an auto-grant.
  • The workflow's is narrower, because there a match posts a green status with nobody in the loop, and both docs-only and Renovate exemptions are void when the PR touches .claude/, .gitea/, .husky/, scripts/ or docker/ci/ — the gate must not be able to exempt itself from review by editing itself.

Merging the two allow-lists would have quietly widened the enforced exemption to match the advisory one, turning a difference that exists for a reason into an accident of refactoring. Sharing the enumeration closes the drift that actually caused harm without touching the part that was correctly different.

What the shared script owns. Six guards, all now exercised by one test suite instead of a subset in each caller:

  • CR/LF rejection and .. rejection on every path.

  • A closed .status allow-list — added/deleted/changed/modified/renamed/copied, not an open denylist. The emitted vocabulary was re-derived on 1.27.1, 2026-08-28 (#747) over every file row of the 200 most recently updated PRs. That is a large sample, not a constructed case — worth stating because a 30-PR pass over the same corpus showed only changed/added and would have licensed a false negative; widening it, not constructing a case, is what surfaced the rest. Four values appear in that corpus: changed (1262), added (489), renamed (2), deleted (2) — all four already in the allow-list, so those four are evidenced rather than presumed. copied and modified did NOT appear, which does not show Gitea never emits them: no PR was constructed to force either. The earlier 1.25.4 measurement recorded here saw only changed and deleted; the 1.27.1 corpus is wider. modified is accepted alongside changed because a closed list built from the wrong vocabulary would gate every genuine docs-only PR. GitHub's removed is deliberately not in the list — an earlier draft of this record said it was, which would have sent a maintainer looking for a value the code rejects.

  • previous_filename validated on every row the extraction consumes, not only rows whose .status is renamed — a modified/copied row can still carry it, and an earlier fix that validated only the renamed case was found incomplete for exactly this reason (see ci.script-tests-job for the review trail).

  • Termination only on a validated empty page — Gitea's paging can return fewer rows than requested well before the real end of the list, so "short page" is not a valid termination signal.

  • Head-sha binding: the head is re-read after enumeration, and the caller must refuse to trust the list if it moved mid-enumeration, since paging is several round-trips and a force-push between them would otherwise yield a list belonging to no single commit. This detects ONE-WAY movement only. An A→B→A force-push round trip restores the expected sha, so the binding holds while the pages came from two different states — see #664. Closing that needs a commit-pinned files endpoint (Gitea has none — re-probed at 1.27.1 on 2026-08-28: compare/{base}...{head} still returns no files key) or a local diff, not a tighter check here; the guarantee is stated narrowly rather than left to read as complete.

    Fenced at the ENFORCED caller since 2026-08-28 (#803), and only there. The script's contract is unchanged and still one-way, because nothing checkable inside it can do better. What changed is that review-verdict.yml — the caller whose match posts a green required status with nobody in the loop — now refuses to write if the PR timeline's pull_push count moved while it classified, a monotonic key an alias cannot defeat (ci.verdict-write-retarget-fence). The advisory hook is deliberately not given that fence: its failure mode is a human prompt, and it pays for the gap differently, by re-reading .head.sha off the same response as its base re-read and DENYING if the head moved. So the mechanism stays shared and single while the two callers keep buying different amounts of protection with it — the same mechanism-not-policy split this record is about.

Base-ref checkout — binds the SCRIPTS to the base, not the workflow itself. review-verdict.yml checks out the PR's BASE ref (ref: ${{ github.event.pull_request.base.sha }}, persist-credentials: false), never the head, so the scripts the job executes — above all scripts/pr-changed-files.sh — come from the already-reviewed base rather than from the PR under judgment. The checkout and its ref are the security-relevant parts: a bare run: calling the script would not have been sufficient, since the script would then have come from wherever the runner happened to be.

It does NOT mean a PR cannot rewrite the gate that judges it (#672). Gitea resolves a pull_request workflow definition from the PR's own head, so a PR editing review-verdict.yml runs its own rewritten copy — which can delete this checkout, or simply post review-verdict/h10=success for its head sha and stop. Branch protection does not close that: it requires the context, not an author, and carries required_approvals: 0. An earlier revision of this paragraph said the workflow "cannot be rewritten by that same PR to weaken its own judgment", which is true of the scripts and false of the workflow — and stated in the one sentence a reader resolving this record from the catalog is most likely to stop at.

That half is now closed, elsewhere — see ci.gate-trigger-base-resolved (#672). The workflow triggers on pull_request_target scoped to branches: [main], so Gitea resolves its definition from the base rather than the head. The paragraph above is kept in the past tense rather than deleted because it names the distinction this record turns on: the base-ref checkout binds the scripts, and only the trigger binds the definition. Note the dependency runs the other way too — that checkout is what makes pull_request_target safe to use at all here, since this job never executes head-supplied code.

An earlier revision of this record stated the requirement in the future tense, because the wiring was staged over two PRs: the workflow runs the BASE version of the gate, and until the shared script existed on main a wired workflow would have exited 127 on its own PR and blocked the merge gate through the combined status. That staging is complete. Both halves are asserted by scripts/tests/test_pr_changed_files.py, which parses the workflow YAML rather than substring- matching it — head.sha for base.sha is a nine-character diff, and a text-level check would still pass if a second checkout step took the head afterwards and won.

What is deliberately NOT claimed. The PROTECTED path list remains the guard that stops a bot-authored PR from editing the gate and exempting itself, and mutation testing was what established that PROTECTED is load-bearing only on the BOT path — it and DOCS_ONLY are disjoint patterns, so on the docs-only path that clause can never fire. A test written against a docs-only-plus-protected file list passed with the clause deleted.

A commit status is repo-global, which this record does not fix either. review-verdict/h10 is attached to a sha in the repository, not to a pull request, so a success earned on one PR is inherited by any other PR with the same head — including one opened against a different base after the first is closed (#663). That is the same property that makes #622's per-sha binding work, read from the other end. Out of scope here; noted so the enumeration's guarantees are not mistaken for a guarantee about which PR a verdict belongs to.

Severity, stated honestly. Every enumeration defect found in this area (#643) downgraded a mechanical deny/ask to a human prompt on the hook side; none produced a silent self-merge on their own. It is still a real weakening worth fixing — the whole point of #649 is that the same class of bug on the enforced copy would not have been merely a downgrade.