--- 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 `GITEA_TOKEN` into EVERY job and it USED to be write-capable everywhere, so any ref-resolved workflow could forge `review-verdict/h10`; since #748 (2026-08-27) all six workflows here declare `permissions:` — five `code: read`, and `review-verdict.yml`''s gate job `code: write`, deliberately, because it IS the gate (the five rest on the TOP-LEVEL form, which is INFERRED rather than probed — the gate job''s JOB-LEVEL form is the one measured; see `ci.actions-credential-scoping`). That narrows the INJECTED token and only that: deliberately NO "only X can post a status" summary is stated here, because any such summary generalizes from the injected token, which `permissions:` scopes, to every credential a job can reach, which it does not touch — `RENOVATE_TOKEN` is in the same secret store and is referenced by `renovate.yml`, one of the five holding `code: read`. `ci-image.yml` was the last holdout — its unfiltered `push:` made it the worst-placed of the six — and #744 closed that trigger route (`ci.toolchain-image-publish-is-a-dispatch`) while #748 scoped its token. So the route is NARROWED, not closed, and the SHAPE is open — a newly added workflow declaring write, or omitting a declaration and inheriting the owner default, is a route again, which is why `test_pr_changed_files.py` asserts every tracked workflow declares a block. And a collaborator''s own API token still can, since branch protection binds the context and not its issuer. 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 (an ALLOW-LISTED user credential''s forgery carries a matching `creator` and is inherited as a verdict; an Actions job''s carries `creator: null` and is re-derived, as since #742 is every account outside `H10_REVIEWERS` — 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 owner-level Restricted default (server-management#714) remains a separate OPEN lever — its effect on a declared `code: write` is unmeasured, and the version half of that condition has been satisfied since the 1.25.4 -> 1.27.1 upgrade on 2026-08-05, so it is not something to wait on a Gitea release for. 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. **The DEFINITION-RESOLUTION half re-established FROM THE v1.27.1 SOURCE, 2026-09-02 (#869)** — the mechanism behind the measurement, without spending four more scratch PRs. **Be precise about which half**, because an earlier draft of this block overclaimed and cold review caught it: `notify()` establishes only WHICH COMMIT each trigger reads its definition from. It says nothing about which `types` produce a run, and nothing about secrets availability — the file's only other `pull_request_target` reference is `ifNeedApprovalWith`, an APPROVAL gate, not a secrets gate. **What IS established on 1.27.1, stated no wider than it was measured:** `pull_request_target` fires for `review-verdict.yml` continuously — **210 such runs across 189 distinct head shas**, counting only runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). All 210 belong to this workflow. The WINDOW is part of the claim: the all-time REPO-WIDE figure is 258 runs / 233 shas (this workflow's own all-time count is 250 — the difference is **8 runs** from three since-deleted probe workflows, all pre-upgrade), which splits 44 before the upgrade date + 4 ON it + 210 after, so citing 258 here would import 1.25.4 evidence into a 1.27.1 sentence. The 4 same-day runs are excluded rather than assigned — the upgrade landed mid-session and their 17:41-22:36 **UTC** start times cannot be placed either side of it without the exact upgrade time, so they go to the side that weakens the claim. Read those times as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while other Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in winter — so a UI comparison shows different clock times. **What that deliberately does NOT establish:** WHICH `types:` fire, since a run count identifies no event type; and whether the STORED secret store is reachable under this trigger, since the job references only the injected `secrets.GITEA_TOKEN`. Both stay 1.25.4-dated. Two earlier attempts at this paragraph are worth not repeating. The first re-dated BOTH halves — over-retreating past what production already shows. The second over-corrected into a per-PR completeness claim ("every PR merged since the upgrade carries this job's context"), which is false and refuted by this record's own PR #863 observation — that PR merged carrying "no review-verdict context of any kind", because a PR into a scratch base does not match `branches: [main]`, the very filter this record exists to explain. (Cited by content rather than by position: a "two paragraphs down" pointer was wrong the one time it was written, and a positional reference breaks whenever anything is inserted above it.) A blunt run count needs no per-PR census and cannot rot that way. Now the mechanism itself. In `services/actions/notifier_helper.go`, one function resolves both. For a PR event `input.Ref` resolves to the PR's own HEAD ref — `WithPullRequest` sets `input.Ref = git.RefName(pr.GetGitHeadRefName())` when the caller has not already supplied one; that ref's commit is passed to `DetectWorkflows`, and every workflow found there is kept **only if** its trigger is NOT `pull_request_target` (`if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget`). Then, separately and only when `input.PullRequest != nil`, it resolves `baseRef := git.BranchPrefix + input.PullRequest.BaseBranch`, calls `DetectWorkflows` again on that commit, and from THAT set keeps only workflows whose trigger **is** `pull_request_target`. So the two triggers read their definitions from two SEPARATELY RESOLVED refs — head and base — and a head rewrite cannot reach a `pull_request_target` definition at all. (Separately resolved, not necessarily different: nothing forbids the two refs resolving to the same commit, which is simply the uninteresting case where the rewrite is not a rewrite.) The same lines make the `branches: [main]` filter's necessity concrete rather than cautionary: the base is resolved by BRANCH NAME (`BranchPrefix + BaseBranch`), not by a pinned sha, so "the base definition" is whatever that branch points at when the event fires. That is why a PR opened into an attacker-pushed base branch would run that branch's gate, and it is the same re-resolution the retarget fence exists to fence (`ci.verdict-write-retarget-fence`). That third item was **re-confirmed at the STATUS level on 1.27.1, 2026-08-28 (#747)**, as a by-product of an unrelated probe (PR #863): a PR from a scratch head into a scratch base carried thirteen `pull_request` contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the observation is also consistent with a run that started and posted nothing. **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, and it used to default to read/**write**, so head-resolved, `push`-triggered and `workflow_dispatch` workflows alike were routes (1.24+ loads a dispatched definition from the selected branch). Narrowed since #748 (2026-08-27): all six workflows here now declare `permissions:` explicitly — five `code: read`, and `review-verdict.yml`'s gate job `code: write`, deliberately, because it IS the gate. (The five rest on the TOP-LEVEL declaration form, which is inferred rather than probed; the gate job's JOB-LEVEL form is the one that was measured — see `ci.actions-credential-scoping`.) `ci-image.yml` was the last holdout: its unfiltered `push:` trigger made it the worst-placed of the six, and #744 closed that route (`ci.toolchain-image-publish-is-a-dispatch`) in the same week #748 scoped its token. **What that narrows is the INJECTED token, and only that.** No one-line "only X can post a status" summary belongs here: every such summary quietly generalizes from the injected token, which `permissions:` scopes, to every credential a job can reach, which it does not touch. The falsifying case is concrete and current — `RENOVATE_TOKEN` still writes statuses and is referenced by `renovate.yml`, one of the five holding `code: read`. The SHAPE is open too: a new workflow that declares write, or omits a declaration and inherits the owner default, is a route again, which is why `test_pr_changed_files.py` asserts every tracked workflow declares a block. 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. Its status carries a real creator, and until `#742` that alone made it INHERITED as a verdict; the provenance test is now membership in `H10_REVIEWERS`, so a `renovate`-posted verdict is re-derived like an Actions-token one. The token still writes statuses — what it lost is durability, not the ability to POST. 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).