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>
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/ordocker/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
.statusallow-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 onlychanged/addedand 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.copiedandmodifieddid 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 onlychangedanddeleted; the 1.27.1 corpus is wider.modifiedis accepted alongsidechangedbecause a closed list built from the wrong vocabulary would gate every genuine docs-only PR. GitHub'sremovedis 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_filenamevalidated on every row the extraction consumes, not only rows whose.statusisrenamed— amodified/copiedrow can still carry it, and an earlier fix that validated only therenamedcase was found incomplete for exactly this reason (seeci.script-tests-jobfor 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 nofileskey) 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'spull_pushcount 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.shaoff 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.