--- key: ci.gate-trigger-base-resolved title: '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)' status: active since: '2026-07-28' supersedes: none superseded-by: none rule: '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`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI''s registry secret was the ADMIN account''s basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential''s route (a user credential''s forgery carries a real `creator` and is inherited as a human verdict; an Actions job''s carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger''s `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator''s own token remains unfixable; the exemption path has its own separate defects in #698.' signals: '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' mechanics: '`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`, whose credential half is resolved in `ci.actions-credential-scoping` — the registry secret no longer carries status-write. That does NOT leave the workflow routes provenance-free: any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store, whose status carries a real creator and IS inherited (`#742`). 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).