Files
ersatztv/docs/decisions/records/ci/gate-trigger-base-resolved.md
T
timothyandClaude Opus 5 f523fc535d
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
fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes
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>
2026-07-29 19:52:00 +02:00

7.2 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.gate-trigger-base-resolved 2026-07-28 — `review-verdict.yml` triggers on `pull_request_target` scoped to `branches: [main]`, so the PR under judgment cannot supply the gate's own definition (#672) active 2026-07-28 none none The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. Tracked in #697; the exemption path has its own separate defects in #698. workflow definition resolved from head, PR rewrites the gate that judges it, self-approve a required status check, pull_request_target vs pull_request, gate trigger branches filter, attacker-supplied base branch, how to test a change to review-verdict.yml, workflow not exercised by its own PR, gate edit goes live only on merge, required_approvals 0 does not bind an author, forged commit status inherited by sha · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #672, #663, #649, #622 `on: pull_request_target: {branches: [main], types: [opened, reopened, synchronize, ready_for_review, edited]}` (`edited` added by `ci.exemption-provenance` so a retarget reclassifies); asserted by `test_the_workflow_trigger_is_pull_request_TARGET_scoped_to_main` in `scripts/tests/test_pr_changed_files.py`; the job's own context is renamed to `... (pull_request_target)` and must stay OUT of branch protection's required list

ci.shared-pr-file-enumeration had this job check out the PR's base ref so the PR cannot supply the scripts that judge it — real but partial, as that record said: it does not bind the job definition. This closes that half.

What was actually wrong. Gitea, like GitHub, resolves a pull_request workflow definition from the PR's own head, so a PR editing review-verdict.yml ran its own rewritten copy — which could delete the base checkout or skip straight to posting review-verdict/h10=success for its head sha. Two things that look preventive were not: PROTECTED is defined by the same rewritten file, and branch protection requires the context, not an author, while carrying required_approvals: 0.

Measured, not inferred. The premise is a claim about someone else's software, so it was settled on this instance (Gitea 1.25.4) with four scratch PRs, not by analogy to GitHub: pull_request ran the head's rewrite and never wrote the real h10; pull_request_target ignored the identical rewrite and the base definition posted h10=pending on opened and synchronize alike, secrets available; branches: [main] produced no run at all from a non-main base; and the fourth — the negative one establishing the residual below — is counted because omitting it turns an honest partial into an overclaim. Probes posted only probe-named contexts, never a forged h10. Full results in #699.

Why branches: [main] is load-bearing rather than tidy. The base branch supplies the definition, and anyone who can push a branch can make it a base — so dropping the filter trades a head-supplied gate for a base-supplied one and closes nothing. Worse than lateral: a commit status is repo-global per sha (#663), so a success forged against a scratch base is inherited by a later genuine PR into main with the same head.

Why pull_request_target is not the footgun it usually is. Its standard danger is executing untrusted head code with a privileged token; this job executes none, checking out base.sha with persist-credentials: false and running only that tree's scripts. Trigger and checkout are one decision — under this trigger a head checkout would be strictly worse than #672 was.

Options not taken. required_approvals: 1, the cheapest mechanical fix, is unusable here: Gitea forbids approving your own PR and this is effectively a single-maintainer repo, so it deadlocks every PR instead of gating the dangerous ones. Verifying the status author needs an actor the PR cannot control, and the tampered workflow holds the same GITEA_TOKEN.

Severity, stated plainly. Never remotely exploitable — pushing a branch requires write access, so the threat model is a compromised contributor, who has other paths. Fixed because a gate whose authority the judged thing can assert is not a gate, not because an attack was expected.

The class is NOT closed, and this record must not be read as claiming otherwise. This fixed one instance of "a ref-resolved workflow can obtain credentials that POST a commit status", and that inventory is not a short list: Gitea injects GITEA_TOKEN into every job, defaulting to read/write, so head-resolved, push-triggered and workflow_dispatch workflows alike are routes (1.24+ loads a dispatched definition from the selected branch). A collaborator's own API token is a route with no workflow at all — branch protection binds the context, not its issuer. Full inventory in #697; the exemption path's own defects are #698. No in-repository test can establish status-authority isolation: the sibling guard added here catches only plain-text naming of the context.

The gate is no longer exercised by its own PR — base resolution cuts both ways, so an edit here goes live only on merge, repo-wide, untested. Verify one safely per docs/ci-cd.md → Review-verdict gate.

Residual. The job's own context is renamed to ... (pull_request_target), safe only because it was never one of branch protection's required contexts (the two docker-build.yml contexts plus review-verdict/h10); adding it would let the workflow satisfy the gate by merely running. A trap for #697: those two carry the literal (pull_request) suffix, so giving docker-build.yml the same treatment renames them and deadlocks merges unless branch protection is edited in the same operation. A non-main base now yields no status where it previously got one — fail-closed, removing a #663 hazard. The edited gap this section once recorded as a mere inconvenience ("statusless until its next synchronize") was the persistence half of a live forgery; RESOLVED in ci.exemption-provenance (#698).