From 566a320cf22d7ec897fffa7ff028510b03e12eba Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 28 Aug 2026 17:08:12 +0200 Subject: [PATCH 1/6] docs(747): re-probe the Gitea CI claims on 1.27.1, and measure the two that were source-attested only The instance moved 1.25.4 -> 1.27.1 during #743, which left a set of deliberately version-pinned CI claims dated rather than wrong. The population was re-derived from `git ls-files` instead of from the issue's own 9-key list, which found ~21 claim sites across workflows, scripts, tests, docs and records. Re-confirmed unchanged on 1.27.1: * an `if:`-skipped job still reports commit-status `skipped`, a distinct state * `compare/{base}...{head}` still serves no `files` * no agent-side cancel route (POST 404, and live swagger has no cancel path at all) * `review-verdict.yml`'s `branches: [main]` still suppresses the run off a non-main base Re-derived from a constructed corpus rather than a sample, because two arbitrary PRs showed only two of the values: the PR file-status vocabulary is changed/added/renamed/ deleted over 200 PRs. All four were already in the closed allow-list, so that set is now evidenced rather than partly presumed. Newly measured, replacing source-attestation. All three arms ran on throwaway scratch-base protection rules, so `main`'s rule was never touched (its updated_at is unchanged at 2026-08-05): * `block_admin_merge_override: false` lets an admin force_merge past an ABSENT required context (HTTP 200); flipping only that field to `true` refuses the identical call (HTTP 405) - the negative control that makes it attributable * an absent required context BLOCKS, so the merge gate does not fail open when review-verdict.yml never runs * `enable_bypass_allowlist` is NOT a better-targeted substitute: enabled with an empty username list, the admin force_merge still succeeded * trap: the PR API reports `mergeable: true` while that same merge is refused, because the field reflects conflict-freedom and ignores branch protection Left explicitly dated rather than silently refreshed, each with its reason: push-supersession auto-cancel is no longer observable on this repo (no workflow triggers on a non-main push, and main refuses direct pushes), and the scope-enum / reqRepoWriter / write:package-403 items need a scoped PAT rather than a read-only probe. review-verdict.yml carries three more 1.25.4-dated comments and is deliberately untouched here: #763 is in-progress on that file and its paging work overlaps them. Refs: #747 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/docker-build.yml | 3 ++- docs/ci-cd.md | 24 ++++++++++++++----- .../records/ci/actions-credential-scoping.md | 7 +++++- .../ci/batch-pushes-no-cancel-route.md | 16 ++++++++----- .../records/ci/docs-only-skip-steps.md | 5 +++- .../records/ci/exemption-provenance.md | 4 +++- .../records/ci/gate-trigger-base-resolved.md | 5 +++- .../records/ci/shared-pr-file-enumeration.md | 7 +++++- .../release/main-direct-push-disabled.md | 21 +++++++++++++++- docs/decisions/workflow-process.md | 2 +- scripts/ci-detect-docs-only.sh | 3 ++- scripts/pr-changed-files.sh | 8 +++++-- scripts/tests/test_merge_consent_exemption.py | 8 ++++--- 13 files changed, 87 insertions(+), 26 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 7e2d2971f..1b54d36af 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -38,7 +38,8 @@ name: Build ErsatzTV Image # report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration # integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT # `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped` -# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped +# (verified, throwaway PR #418; re-confirmed on 1.27.1, 2026-08-28, ersatztv#747 — `Build & push +# image (amd64)` still reports `skipped` on every PR head) and we don't rely on how branch protection treats a skipped # REQUIRED context. See docs/ci-cd.md -> "Docs-only skip". # # RELEASE-PATH DELIMITER GATE (ersatztv#767): the `scan` job runs the PyYAML-based delimiter-ban diff --git a/docs/ci-cd.md b/docs/ci-cd.md index f2f0fb235..472c9e34d 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -761,8 +761,9 @@ The jobs are **not** `if:`-skipped. That is deliberate and it is the whole trap or an `if:`-skipped job), those contexts would never report and the PR could **never merge** — the naive fix *bricks* docs PRs rather than speeding them up. - On Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`**, a distinct - state (verified with a throwaway probe, PR #418) — not `success`. We do **not** rely on how branch - protection treats a `skipped` **required** context. Keeping the job running and gating its *steps* + state (verified with a throwaway probe, PR #418) — not `success`. **Still true on 1.27.1** + (re-confirmed 2026-08-28, ersatztv#747, from production PR heads rather than a new probe PR). We do + **not** rely on how branch protection treats a `skipped` **required** context. Keeping the job running and gating its *steps* makes the required context report `success` unconditionally, which is safe by construction. - Non-required jobs may skip freely: production already proves a `skipped` **non-required** context does not block merge (`build` is `skipped` on every PR). So `build` skips its image steps on a @@ -1300,10 +1301,13 @@ entire gate was skippable with no forgery at all, which was cheaper than every r - `block_admin_merge_override: true` — without it (the default is `false`), a repo admin could `POST /pulls/{n}/merge` with `force_merge: true` and merge straight past a missing or red `review-verdict/h10`. Disabling push alone just moves the bypass from the push path to the merge - path, since `timothy` is admin and is the identity every session already uses. **Source-attested, - not probed** (Gitea 1.27 `CanBypassBranchProtection`): verifying it by experiment means merging an - unreviewed PR, so the field was set rather than measured. Setting it is safe under either - semantics; re-confirming the bypass itself rides with ersatztv#747. + path, since `timothy` is admin and is the identity every session already uses. **Measured + 2026-08-28 (ersatztv#747)**, replacing the earlier source-attested-only note: on throwaway + `scratch/747{a,b}-base` rules, `false` let an admin `force_merge` past an ABSENT required context + (HTTP 200) and `true` refused the identical call (HTTP 405 `Not all required status checks + successful`). It never required merging an unreviewed PR into `main` — a scratch BASE branch carries + its own rule, so the experiment is disposable. `enable_bypass_allowlist` (new since 1.25.4) is + **not** a substitute: enabled with an empty username list it left the admin force-merge working. **Operator recovery when a required context gets stuck.** `block_admin_merge_override: true` removes the "Merge (admin)" / `force_merge: true` escape that used to unstick a PR whose required context was @@ -1567,6 +1571,14 @@ measured rather than assumed. Gitea 1.25.4 auto-cancels superseded `push` runs o statusless with nothing left to re-trigger it. Full measurements and the two surviving residuals: `ci.verdict-write-retarget-fence`. +The `push`-supersession half of that claim stays **1.25.4-dated on purpose** (ersatztv#747, +2026-08-28). It is not merely unchecked, it is no longer observable on this repo: no workflow triggers +on a push to a non-`main` branch (`ci-image.yml` and `docker-build.yml` filter their `push` trigger to +`main` plus `v*` tags; the other four have no `push` trigger at all), and `main` itself refuses direct +pushes since `release.main-direct-push-disabled`. So reproducing it would mean adding a throwaway +workflow trigger, which costs more than the claim is worth — nothing branches on it. The +`pull_request_target` half is what the fence relies on, and that is separately measured at 1.27.1. + Separately, after posting an exemption `success` the job re-reads the per-POST status history and, if a human `Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and logs an error. **That check runs only for a `success` write**, so "a human `BLOCKED` can diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index cf2ae2cb7..69c7b6a0b 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -139,7 +139,12 @@ The instance was upgraded mid-session on 2026-08-05 (#743). Everything above mea therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403 probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their date and are the best evidence available, but do not cite them as current behaviour without -re-probing. Re-verification of the 1.25.4-pinned claims across the CI docs is tracked separately. +re-probing. #747 carried out that re-verification across the CI docs on 2026-08-28 and **did not** +discharge this record's remaining items: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the +`write:package` 403 were left deliberately un-re-run, because each needs a scoped PAT minted against +the live instance rather than a read-only probe. They stay 1.25.4-dated on purpose. What #747 did +settle elsewhere is the merge-path half — see `release.main-direct-push-disabled` for the measured +`block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute. **Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same store and still cannot be scoped down, because Renovate needs diff --git a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md index d8fe67cbd..8cca4a37f 100644 --- a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md +++ b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md @@ -1,19 +1,23 @@ --- key: ci.batch-pushes-no-cancel-route -title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 (#542)' +title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 or 1.27.1 (#542)' status: active since: '2026-07-21' supersedes: none superseded-by: none rule: Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. -signals: 'cancel run 404 · Gitea 1.25.4 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542' +signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542' mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI. --- -Cancellation is impossible from the agent side on this Gitea (**1.25.4**): -`POST /api/v1/.../actions/runs/{id}/cancel` returns **404**, MCP `actions_run_write`'s `cancel_run` -returns **404**, and the web-UI route needs a session + CSRF that does not script (login 303s with no -session cookie). **Only the operator can cancel, in the browser** — so if you must supersede a live +Cancellation is impossible from the agent side on this Gitea, measured on **1.25.4** and +**re-confirmed on 1.27.1, 2026-08-28 (#747)**: `POST /api/v1/.../actions/runs/{id}/cancel` returns +**404**, MCP `actions_run_write`'s `cancel_run` returns **404**, and the web-UI route needs a session ++ CSRF that does not script (login 303s with no session cookie). The 1.27.1 re-probe also read the +live swagger, so the absence is structural rather than one 404: the only POST routes under +`actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. There is a +`DELETE /actions/runs/{run}`, which removes a finished run's record — it is not a cancel and does not +free a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and still works for re-triggering a **main** run.) diff --git a/docs/decisions/records/ci/docs-only-skip-steps.md b/docs/decisions/records/ci/docs-only-skip-steps.md index bde330652..8bfd595c6 100644 --- a/docs/decisions/records/ci/docs-only-skip-steps.md +++ b/docs/decisions/records/ci/docs-only-skip-steps.md @@ -20,7 +20,10 @@ docs-only PR produced **no run** for them, those contexts never report and the P — the naive fix bricks docs PRs instead of speeding them. A probe (throwaway PR #418) confirmed that on Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`** (a distinct state, not `success`); how branch protection treats a `skipped` *required* context is not something we rely -on. +on. **Re-confirmed unchanged on 1.27.1, 2026-08-28 (#747)**, without a new probe PR — `Build & push +image (amd64)` is `if:`-skipped on every PR, and reports `skipped` on the heads of PR #829 +(`e5a01dc84100`) and PR #828 (`02bb0393`) alongside `success` and `pending` on the rest. The distinct +state survived the upgrade, so the reason this decision gates STEPS rather than jobs still holds. **The decision.** Each heavy job (`test`, `migrations`, `functional-e2e`, `build`) runs `scripts/ci-detect-docs-only.sh` as its first post-checkout step (`id: detect`) and gates every real diff --git a/docs/decisions/records/ci/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md index fd0c4fc24..6f5b62806 100644 --- a/docs/decisions/records/ci/exemption-provenance.md +++ b/docs/decisions/records/ci/exemption-provenance.md @@ -21,7 +21,9 @@ mid-flight. The job enumerated against the moved base, read docs-only, and poste reclassified** (`created_at == updated_at`), leaving a PR into `main` whose diff carried a C# file behind a green required check. Closed unmerged, branches deleted, no forged `h10` left anywhere. **Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright; -Gitea 1.25.4 cannot serve that — measured: `compare/{base}...{head}` returns no `files`, and a +Gitea cannot serve that — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**: +`compare/{base}...{head}` returns an object whose only keys are `commits` and `total_commits`, no +`files` (probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits). And a `--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the branch point. So the base is read before the first page and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly diff --git a/docs/decisions/records/ci/gate-trigger-base-resolved.md b/docs/decisions/records/ci/gate-trigger-base-resolved.md index 8cdb12dc6..d1f2f90bf 100644 --- a/docs/decisions/records/ci/gate-trigger-base-resolved.md +++ b/docs/decisions/records/ci/gate-trigger-base-resolved.md @@ -24,7 +24,10 @@ branch protection requires the *context*, not an author, while carrying `require 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 +`branches: [main]` produced no run at all from a non-`main` base — **re-confirmed on 1.27.1, +2026-08-28 (#747)** as a by-product of an unrelated probe, where a PR from a scratch head into a +scratch base carried thirteen `pull_request` contexts and no review-verdict context of any kind; 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. diff --git a/docs/decisions/records/ci/shared-pr-file-enumeration.md b/docs/decisions/records/ci/shared-pr-file-enumeration.md index 696e3f4f1..c4c81972f 100644 --- a/docs/decisions/records/ci/shared-pr-file-enumeration.md +++ b/docs/decisions/records/ci/shared-pr-file-enumeration.md @@ -55,7 +55,12 @@ 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. Note `changed` and `deleted` are the values live Gitea 1.25.4 actually emits; + open denylist. The emitted vocabulary was re-derived on 1.27.1, 2026-08-28 (#747) from a + **constructed** corpus rather than a sample — every file row of the 200 most recently updated PRs, + because two arbitrary PRs had shown only `changed`/`added` and would have licensed a false negative. + Live 1.27.1 emits exactly four values: `changed` (1262), `added` (489), `renamed` (2), `deleted` (2). + All four are already in the allow-list, so the closed set is now evidenced rather than partly + presumed; the 1.25.4-dated note that only `changed` and `deleted` were observed understated it. `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 diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index 818998d89..ccc711177 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' -mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. NOT measured, source-attested only (Gitea 1.27 `CanBypassBranchProtection`, `services/pull/check.go`, `routers/private/hook_pre_receive.go`): that `block_admin_merge_override: false` would have let an admin `force_merge` past the required contexts — the field was set to true rather than probed, since probing it means merging an unreviewed PR. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout.' +mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field: on throwaway `scratch/747{a,b,c}-base` rules — never on `main`, whose rule `updated_at` is unchanged at 2026-08-05 throughout — an admin `force_merge: true` against an ABSENT required context MERGED (HTTP 200) with `block_admin_merge_override: false`, and the IDENTICAL call with only that field flipped to `true` was REFUSED HTTP 405 `Not all required status checks successful`. Probing it never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Third arm, same method: `enable_bypass_allowlist: true` with an EMPTY `bypass_allowlist_usernames` did NOT constrain the admin — the force-merge still returned HTTP 200 — so the 1.27 bypass allowlist is not a substitute for `block_admin_merge_override`. All #747 probe artifacts (four rules, eight branches — a base/head pair per arm — and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- **Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct @@ -31,6 +31,25 @@ repo admin and `POST /pulls/{n}/merge` with `force_merge: true` merged straight the only admin exempts everybody.** Both fields are now set; treat them as one control, and never cite `enable_push: false` alone as the reason the gate holds. +**Both halves are measured, and the 1.27 bypass allowlist is not a third option (#747).** The +admin-override half was set on source attestation alone, because probing it looked like it required +merging an unreviewed PR into `main`. It does not: a scratch BASE branch carries its own protection +rule, so the experiment is disposable and `main` is never touched. Measured that way on 2026-08-28, +with the control that makes it attributable — `block_admin_merge_override: false` let an admin +`force_merge` past an ABSENT required context (HTTP 200); flipping only that field to `true` refused +the identical call (HTTP 405). `enable_bypass_allowlist`, which did not exist on 1.25.4 and reads like +a better-targeted control, is **not** one: enabled with an empty username list it left the admin +force-merge working (HTTP 200). It answers "who may bypass when named", not "may an admin bypass at +all" — the implicit admin bypass is removed only by `block_admin_merge_override`. Do not swap them. + +**An absent required context BLOCKS, and `mergeable` does not tell you so.** Both halves of this were +asserted by our docs and unproven until #747. A required context that is never posted at all — the +state a PR is in whenever `review-verdict.yml` does not run — refuses the merge with HTTP 405 `Not all +required status checks successful`; the gate does not fail open on absence. The trap is the observable: +the PR API reported `mergeable: true` on that same PR while the merge was being refused, because that +field reflects conflict-freedom and ignores branch protection entirely. Never read `mergeable` to +decide whether the gate would admit a merge — attempt the merge, or read the required contexts. + **What this actually closes, and what it does not.** It closes the *write-only* credential routes, which is most of #743's own "who can do it" list: the injected `GITEA_TOKEN` (repo write at the time, narrowed to `code: read` in five of six workflows by #748; not admin either way), diff --git a/docs/decisions/workflow-process.md b/docs/decisions/workflow-process.md index c6a226366..ab400ed2b 100644 --- a/docs/decisions/workflow-process.md +++ b/docs/decisions/workflow-process.md @@ -96,7 +96,7 @@ another doc or an old issue comment should land here and then follow the link. - 2026-07-21 — An infra-shaped red under host load is not a code failure (#542) — [`ci.infra-shaped-red-under-load`](records/ci/infra-shaped-red-under-load.md) - 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542) — [`ci.monitor-armed-at-pr-open`](records/ci/monitor-armed-at-pr-open.md) - 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542) — [`process.bom-format-detection-recipe`](records/process/bom-format-detection-recipe.md) -- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md) +- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 or 1.27.1 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md) - 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542) — [`process.enumerate-workaround-behaviors-before-deleting`](records/process/enumerate-workaround-behaviors-before-deleting.md) - 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542) — [`process.build-concurrency-limits`](records/process/build-concurrency-limits.md) - 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542) — [`ci.verify-locally-ci-confirms`](records/ci/verify-locally-ci-confirms.md) diff --git a/scripts/ci-detect-docs-only.sh b/scripts/ci-detect-docs-only.sh index 01f046b13..11feb8581 100755 --- a/scripts/ci-detect-docs-only.sh +++ b/scripts/ci-detect-docs-only.sh @@ -13,7 +13,8 @@ # Why this is the merge-gate-safe half of #416: the two REQUIRED contexts (`Build & test (.NET)`, # `EF migration integrity (SQLite + MySql)`) are gated by SKIPPING STEPS inside a job that always # runs and always reports `success` — never by an `if:`-skipped job. On Gitea 1.25.4 an -# `if:`-skipped job reports commit-status state `skipped` (verified, PR #418), and how branch +# `if:`-skipped job reports commit-status state `skipped` (verified, PR #418; re-confirmed on +# 1.27.1, 2026-08-28, ersatztv#747), and how branch # protection treats a `skipped` REQUIRED context is not something we rely on. Non-required jobs may # skip freely (production already proves a `skipped` non-required context — e.g. `build` on every # PR — does not block merge). diff --git a/scripts/pr-changed-files.sh b/scripts/pr-changed-files.sh index 549ae27e5..0f0b84280 100755 --- a/scripts/pr-changed-files.sh +++ b/scripts/pr-changed-files.sh @@ -41,7 +41,8 @@ # This NARROWS the window, it does not erase it. The base is re-read after the paging round trips # alongside the head, so a retarget that is still in effect at that point fails closed; a retarget # that opens and closes strictly between the files call and the re-read is not observable from here. -# Pinning the diff to two shas would close it, and Gitea 1.25.4 cannot serve that: `compare/{base}... +# Pinning the diff to two shas would close it, and Gitea cannot serve that (measured on 1.25.4, +# re-confirmed on 1.27.1 2026-08-28 ersatztv#747): `compare/{base}... # {head}` returns `total_commits`/`commits` and NO `files`, and a `--depth=1` fetch of the two shas # has no merge base, so a three-dot diff is impossible while a two-dot one over-reports every commit # `main` gained since the branch point (both measured, #698). The remainder is covered one level up @@ -190,7 +191,10 @@ while [ "$page" -le "$MAX_PAGES" ]; do # "renamed rows MUST carry previous_filename" requirement entirely. (An earlier version of this # comment claimed the source path would be "dropped", which is not the mechanism; a maintainer # who tested that claim would find it false and might conclude the check is redundant.) - # `modified` is accepted alongside `changed` deliberately: live Gitea 1.25.4 emits `changed`, but a + # `modified` is accepted alongside `changed` deliberately. Live Gitea emits `changed`; the full + # emitted vocabulary was re-derived on 1.27.1 (2026-08-28, ersatztv#747) over the file rows of the + # 200 most recently updated PRs — exactly `changed`, `added`, `renamed`, `deleted`, all four already + # in the list below. `modified` is retained because a # closed allow-list built from the wrong vocabulary is a worse failure than the hole it closes — it # would gate every genuine docs-only PR on any version that spells it differently. The property is # "reject values we do not recognise", not "enumerate one version exactly". diff --git a/scripts/tests/test_merge_consent_exemption.py b/scripts/tests/test_merge_consent_exemption.py index fdf209ee8..c7c11f237 100644 --- a/scripts/tests/test_merge_consent_exemption.py +++ b/scripts/tests/test_merge_consent_exemption.py @@ -476,9 +476,11 @@ def test_a_rename_disguised_by_an_unknown_status_is_rejected(hook, status): def test_gitea_real_status_values_are_accepted(hook): - """Positive control for the closed set. The real Gitea 1.25.4 value for an edit is `changed`, - NOT `modified` — a closed allow-list built from the wrong vocabulary would reject every real - docs-only PR, which is a far worse failure than the hole it closes.""" + """Positive control for the closed set. The real Gitea value for an edit is `changed`, NOT + `modified` — a closed allow-list built from the wrong vocabulary would reject every real + docs-only PR, which is a far worse failure than the hole it closes. Measured on 1.25.4 and + re-derived on 1.27.1 (2026-08-28, ersatztv#747) across a 200-PR corpus, which emits exactly + `changed`, `added`, `renamed`, `deleted`.""" hook.set_pages( [ {"filename": "docs/a.md", "status": "changed"}, -- 2.47.3 From 66d62a2f5809aeaeb6b5bbe09a2faed877be7bac Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 28 Aug 2026 17:23:59 +0200 Subject: [PATCH 2/6] docs(747): scope every re-probe claim to the configuration actually measured Round 1 of independent review returned 21 findings. Nearly all were one mechanism: a conclusion stated more broadly than the single configuration it rested on. Fixed as a class rather than site by site. Corrections of fact: * ci-image.yml's push trigger is `main` + `docker/ci/**` with deliberately NO `tags:` key - it was described as "main plus v* tags", which conflated it with docker-build.yml and contradicted ci.toolchain-image-publish-is-a-dispatch * push-supersession is not "no longer observable": a PR merge IS a push to main, so two merges landing close together still produce it. What is gone is the ability to reproduce it ON DEMAND * the scope-enum and reqRepoWriter items were established by reading Gitea source at the version tag, not by a scoped PAT - only the write:package 403 needs one. The invented reason contradicted that record's own Measured-vs-inferred block Self-contradiction removed: "the gate does not fail open on absence" was stated unqualified three lines below ARM A, which showed an absent context force-merging with block_admin_merge_override:false. Now two claims - absence blocks an ORDINARY merge; only the override field blocks a FORCED one. Overclaims scoped to their evidence: * the 200-PR file-status pass is a large recency-biased SAMPLE, not a constructed case; `copied`/`modified` not appearing does not show they are never emitted. The prior pass that saw only changed/added was 30 PRs, not 2 * the cancel-route re-probe covered the REST route and swagger only; the MCP cancel_run and web-UI/CSRF findings stay 1.25.4-dated. DELETE /actions/runs/{run} was read from swagger, not probed - no claim that it frees a runner slot * bypass-allowlist result holds for ONE configuration (empty list, override false); a non-empty list was untested, and it cannot distinguish "does not govern admins" from "empty list treated as unconfigured" * branches:[main] re-confirmed at the STATUS level; the run list was not enumerated * `skipped` sampled on two heads (#829, #828), not "every PR head" Causal claims dropped where only an observation was made: `mergeable` reflecting conflict-freedom, and the vocabulary delta being caused by sampling rather than by the version change. The push-supersession finding is mirrored into ci.verdict-write-retarget-fence, the record that owns the claim, so a catalog reader does not re-open it next sweep. Partially declined with evidence: the ~100-col rewrap. The baseline already carries 406/2486 such lines in ci-cd.md and 25/79 in main-direct-push-disabled, so the premise does not hold; the one genuine outlier (a 432-col insertion) is wrapped. Refs: #747 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/docker-build.yml | 3 +- docs/ci-cd.md | 24 ++++++---- .../records/ci/actions-credential-scoping.md | 6 ++- .../ci/batch-pushes-no-cancel-route.md | 19 ++++---- .../records/ci/gate-trigger-base-resolved.md | 9 ++-- .../records/ci/shared-pr-file-enumeration.md | 14 +++--- .../ci/verdict-write-retarget-fence.md | 6 +++ .../release/main-direct-push-disabled.md | 48 ++++++++++++------- scripts/pr-changed-files.sh | 9 ++-- scripts/tests/test_merge_consent_exemption.py | 6 +-- 10 files changed, 89 insertions(+), 55 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 1b54d36af..f11141acf 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -39,7 +39,8 @@ name: Build ErsatzTV Image # integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT # `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped` # (verified, throwaway PR #418; re-confirmed on 1.27.1, 2026-08-28, ersatztv#747 — `Build & push -# image (amd64)` still reports `skipped` on every PR head) and we don't rely on how branch protection treats a skipped +# image (amd64)` is `if:`-skipped on every PR and reported `skipped` on the two heads sampled, +# PRs #829 and #828) and we don't rely on how branch protection treats a skipped # REQUIRED context. See docs/ci-cd.md -> "Docs-only skip". # # RELEASE-PATH DELIMITER GATE (ersatztv#767): the `scan` job runs the PyYAML-based delimiter-ban diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 472c9e34d..3e851ab24 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1303,11 +1303,14 @@ entire gate was skippable with no forgery at all, which was cheaper than every r `review-verdict/h10`. Disabling push alone just moves the bypass from the push path to the merge path, since `timothy` is admin and is the identity every session already uses. **Measured 2026-08-28 (ersatztv#747)**, replacing the earlier source-attested-only note: on throwaway - `scratch/747{a,b}-base` rules, `false` let an admin `force_merge` past an ABSENT required context + `scratch/747{a,b}-base` rules (arms A and B; a third arm C covered the bypass allowlist), `false` + let an admin `force_merge` past an ABSENT required context (HTTP 200) and `true` refused the identical call (HTTP 405 `Not all required status checks successful`). It never required merging an unreviewed PR into `main` — a scratch BASE branch carries - its own rule, so the experiment is disposable. `enable_bypass_allowlist` (new since 1.25.4) is - **not** a substitute: enabled with an empty username list it left the admin force-merge working. + its own rule, so the experiment is disposable. `enable_bypass_allowlist` is **not** a + substitute in the one configuration tested: enabled with an EMPTY username list (and + `block_admin_merge_override: false`) the admin force-merge still returned HTTP 200. A non-empty + list was not tested. **Operator recovery when a required context gets stuck.** `block_admin_merge_override: true` removes the "Merge (admin)" / `force_merge: true` escape that used to unstick a PR whose required context was @@ -1572,12 +1575,15 @@ statusless with nothing left to re-trigger it. Full measurements and the two sur `ci.verdict-write-retarget-fence`. The `push`-supersession half of that claim stays **1.25.4-dated on purpose** (ersatztv#747, -2026-08-28). It is not merely unchecked, it is no longer observable on this repo: no workflow triggers -on a push to a non-`main` branch (`ci-image.yml` and `docker-build.yml` filter their `push` trigger to -`main` plus `v*` tags; the other four have no `push` trigger at all), and `main` itself refuses direct -pushes since `release.main-direct-push-disabled`. So reproducing it would mean adding a throwaway -workflow trigger, which costs more than the claim is worth — nothing branches on it. The -`pull_request_target` half is what the fence relies on, and that is separately measured at 1.27.1. +2026-08-28). It is not unobservable, but it is no longer reproducible ON DEMAND. No workflow triggers +on a push to a *non-`main`* branch — `docker-build.yml` filters its `push` trigger to `main` plus `v*` +tags, `ci-image.yml` to `main` and `docker/ci/**` with deliberately no `tags:` key +(`ci.toolchain-image-publish-is-a-dispatch`), and the other four have no `push` trigger at all — and +`main` itself refuses direct pushes since `release.main-direct-push-disabled`. A PR merge IS a push to +`main`, so superseded `push` runs can still arise naturally from two merges landing close together; +what is gone is the ability to schedule one. Reproducing it deliberately would mean adding a throwaway +trigger, which costs more than the claim is worth — nothing branches on it. The `pull_request_target` +half is what the fence relies on, and that is separately measured at 1.27.1. Separately, after posting an exemption `success` the job re-reads the per-POST status history and, if a human `Review-verdict:` row appeared during the write window, overwrites its own status with diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index 69c7b6a0b..fd0a6f556 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -141,8 +141,10 @@ probe were all taken pre-upgrade and have NOT been re-run. They are recorded hon date and are the best evidence available, but do not cite them as current behaviour without re-probing. #747 carried out that re-verification across the CI docs on 2026-08-28 and **did not** discharge this record's remaining items: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the -`write:package` 403 were left deliberately un-re-run, because each needs a scoped PAT minted against -the live instance rather than a read-only probe. They stay 1.25.4-dated on purpose. What #747 did +`write:package` 403 were all left un-re-run, and they stay 1.25.4-dated. Only the 403 needs a scoped +PAT minted against the live instance; the scope enum and the `reqRepoWriter` gate were established by +reading Gitea's source at the version tag (see "Measured vs inferred" above), so re-running them needs +only a source read at `v1.27.1`. #747 did neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute. diff --git a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md index 8cca4a37f..66933e3c1 100644 --- a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md +++ b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md @@ -10,14 +10,17 @@ signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../ac mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI. --- -Cancellation is impossible from the agent side on this Gitea, measured on **1.25.4** and -**re-confirmed on 1.27.1, 2026-08-28 (#747)**: `POST /api/v1/.../actions/runs/{id}/cancel` returns -**404**, MCP `actions_run_write`'s `cancel_run` returns **404**, and the web-UI route needs a session -+ CSRF that does not script (login 303s with no session cookie). The 1.27.1 re-probe also read the -live swagger, so the absence is structural rather than one 404: the only POST routes under -`actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. There is a -`DELETE /actions/runs/{run}`, which removes a finished run's record — it is not a cancel and does not -free a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live +Cancellation is impossible from the agent side on this Gitea (measured on **1.25.4**): +`POST /api/v1/.../actions/runs/{id}/cancel` returns **404**, MCP `actions_run_write`'s `cancel_run` +returns **404**, and the web-UI route needs a session + CSRF that does not script (login 303s with no +session cookie). + +**Re-probed on 1.27.1, 2026-08-28 (#747) — the REST route only.** `POST .../cancel` still returns +**404**, and the live swagger shows the absence is structural rather than one 404: the only POST routes +under `actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. The MCP +`cancel_run` and web-UI/CSRF findings above were **not** re-run and stay 1.25.4-dated. Swagger also +lists `DELETE /actions/runs/{run}` (delete a run record); it was not probed — do not assume it cancels +a run or frees a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and still works for re-triggering a **main** run.) diff --git a/docs/decisions/records/ci/gate-trigger-base-resolved.md b/docs/decisions/records/ci/gate-trigger-base-resolved.md index d1f2f90bf..ba2e2a426 100644 --- a/docs/decisions/records/ci/gate-trigger-base-resolved.md +++ b/docs/decisions/records/ci/gate-trigger-base-resolved.md @@ -24,10 +24,11 @@ branch protection requires the *context*, not an author, while carrying `require 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 — **re-confirmed on 1.27.1, -2026-08-28 (#747)** as a by-product of an unrelated probe, where a PR from a scratch head into a -scratch base carried thirteen `pull_request` contexts and no review-verdict context of any kind; and -the fourth — the negative one +`branches: [main]` produced no run at all from a non-`main` base — **re-confirmed at the STATUS level +on 1.27.1, 2026-08-28 (#747)** as a by-product of an unrelated probe, where 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 that observation is also consistent with a run that posted +nothing; 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. diff --git a/docs/decisions/records/ci/shared-pr-file-enumeration.md b/docs/decisions/records/ci/shared-pr-file-enumeration.md index c4c81972f..1ac09689c 100644 --- a/docs/decisions/records/ci/shared-pr-file-enumeration.md +++ b/docs/decisions/records/ci/shared-pr-file-enumeration.md @@ -55,12 +55,14 @@ 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) from a - **constructed** corpus rather than a sample — every file row of the 200 most recently updated PRs, - because two arbitrary PRs had shown only `changed`/`added` and would have licensed a false negative. - Live 1.27.1 emits exactly four values: `changed` (1262), `added` (489), `renamed` (2), `deleted` (2). - All four are already in the allow-list, so the closed set is now evidenced rather than partly - presumed; the 1.25.4-dated note that only `changed` and `deleted` were observed understated it. + 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 1.25.4-dated + note naming only `changed` and `deleted` is narrower than this corpus shows. `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 diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index 71f67e12b..65c9772f5 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -16,6 +16,12 @@ status writes are not serialized, so a stale run can post over a fresher one. Th ## Measured, not reasoned (Gitea 1.25.4, 2026-08-03) +**The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and cannot be reproduced on +demand.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, +so a superseded `push` run can now only arise from two merges to `main` landing close together — +which is not schedulable. The `pull_request_target` half below is what this fence actually relies +on, and that IS re-measured at 1.27.1. + - **`pull_request_target` runs for one PR overlap, older finishing last.** Probe PR #722: run 7520 (`opened`) completed at 18:30:42, twenty seconds *after* run 7521 (`synchronize`) began. Race 1's mechanism, observed rather than argued. diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index ccc711177..44a67af84 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' -mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field: on throwaway `scratch/747{a,b,c}-base` rules — never on `main`, whose rule `updated_at` is unchanged at 2026-08-05 throughout — an admin `force_merge: true` against an ABSENT required context MERGED (HTTP 200) with `block_admin_merge_override: false`, and the IDENTICAL call with only that field flipped to `true` was REFUSED HTTP 405 `Not all required status checks successful`. Probing it never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Third arm, same method: `enable_bypass_allowlist: true` with an EMPTY `bypass_allowlist_usernames` did NOT constrain the admin — the force-merge still returned HTTP 200 — so the 1.27 bypass allowlist is not a substitute for `block_admin_merge_override`. All #747 probe artifacts (four rules, eight branches — a base/head pair per arm — and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' +mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field: on throwaway `scratch/747{a,b,c}-base` rules — never on `main`, whose rule `updated_at` is unchanged at 2026-08-05 throughout — an admin `force_merge: true` against an ABSENT required context MERGED (HTTP 200) with `block_admin_merge_override: false`, and the IDENTICAL call with only that field flipped to `true` was REFUSED HTTP 405 `Not all required status checks successful`. Probing it never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. A separate control on the same scratch base establishes the 405 is attributable to the ABSENCE: posting only the required context as `success` and repeating the identical merge returned HTTP 200. Third arm, same method: `enable_bypass_allowlist: true` with an EMPTY `bypass_allowlist_usernames` and `block_admin_merge_override: false` did NOT constrain the admin — force-merge still returned HTTP 200 — so in THAT configuration it does not substitute for `block_admin_merge_override`. A non-empty allowlist was not tested, and this does not distinguish `does not govern admins` from `empty list treated as unconfigured`. All #747 probe artifacts (four rules, eight branches — a base/head pair per arm — and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- **Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct @@ -31,24 +31,36 @@ repo admin and `POST /pulls/{n}/merge` with `force_merge: true` merged straight the only admin exempts everybody.** Both fields are now set; treat them as one control, and never cite `enable_push: false` alone as the reason the gate holds. -**Both halves are measured, and the 1.27 bypass allowlist is not a third option (#747).** The -admin-override half was set on source attestation alone, because probing it looked like it required -merging an unreviewed PR into `main`. It does not: a scratch BASE branch carries its own protection -rule, so the experiment is disposable and `main` is never touched. Measured that way on 2026-08-28, -with the control that makes it attributable — `block_admin_merge_override: false` let an admin -`force_merge` past an ABSENT required context (HTTP 200); flipping only that field to `true` refused -the identical call (HTTP 405). `enable_bypass_allowlist`, which did not exist on 1.25.4 and reads like -a better-targeted control, is **not** one: enabled with an empty username list it left the admin -force-merge working (HTTP 200). It answers "who may bypass when named", not "may an admin bypass at -all" — the implicit admin bypass is removed only by `block_admin_merge_override`. Do not swap them. +**The admin-override half is measurable without touching `main` (#747).** It was set on source +attestation because probing it appears to require merging an unreviewed PR into `main`. It does not: a +scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Measured +that way on 2026-08-28, on a scratch base requiring one probe-named context that was never posted, with +the control that makes it attributable — `block_admin_merge_override: false` let an admin `force_merge` +past that ABSENT context (HTTP 200); flipping only that field to `true` refused the identical call +(HTTP 405). `main` itself was never merge-probed and its rule was never PATCHed. -**An absent required context BLOCKS, and `mergeable` does not tell you so.** Both halves of this were -asserted by our docs and unproven until #747. A required context that is never posted at all — the -state a PR is in whenever `review-verdict.yml` does not run — refuses the merge with HTTP 405 `Not all -required status checks successful`; the gate does not fail open on absence. The trap is the observable: -the PR API reported `mergeable: true` on that same PR while the merge was being refused, because that -field reflects conflict-freedom and ignores branch protection entirely. Never read `mergeable` to -decide whether the gate would admit a merge — attempt the merge, or read the required contexts. +**Absence blocks an ORDINARY merge; only the override field blocks a FORCED one.** State this as two +claims, because collapsing them contradicts the arm above. On that same scratch base, with the required +context never posted, a plain merge was refused HTTP 405 `Not all required status checks successful` — +so the gate does not fail open merely because `review-verdict.yml` did not run. The load-bearing +control: posting **only** that context as `success` and repeating the identical call returned HTTP 200, +so the 405 is attributable to the absence and not to something else on the PR. But absence alone does +**not** stop a *force* merge — that is exactly what `block_admin_merge_override: false` allowed above. +`main` carries the field `true`, which is what makes absence blocking on both paths there. + +**The 1.27 bypass allowlist is not a substitute, in the one configuration tested.** +`enable_bypass_allowlist` reads like a better-targeted control. Enabled with an EMPTY +`bypass_allowlist_usernames` and `block_admin_merge_override: false`, the admin force-merge still +returned HTTP 200 — so in that configuration it did not substitute. A NON-empty list was not tested, +and this does not distinguish "the allowlist does not govern admins" from "an empty list is treated as +unconfigured". Enough to refuse the swap; not enough to describe the field's semantics. The version +claim that it postdates 1.25.4 comes from #747's issue body, not from a probe here. + +**Trap: `mergeable` does not answer the gate question.** On the absent-context PR the API reported +`mergeable: true` while that merge was being refused. Whatever that field does reflect, it does not +reflect required-context state — observed once, on one scratch PR, with other protection inputs held +fixed. Never read `mergeable` to decide whether the gate would admit a merge; attempt the merge, or +read the required contexts. **What this actually closes, and what it does not.** It closes the *write-only* credential routes, which is most of #743's own "who can do it" list: the injected `GITEA_TOKEN` (repo write at the time, diff --git a/scripts/pr-changed-files.sh b/scripts/pr-changed-files.sh index 0f0b84280..3d9c7a987 100755 --- a/scripts/pr-changed-files.sh +++ b/scripts/pr-changed-files.sh @@ -191,10 +191,11 @@ while [ "$page" -le "$MAX_PAGES" ]; do # "renamed rows MUST carry previous_filename" requirement entirely. (An earlier version of this # comment claimed the source path would be "dropped", which is not the mechanism; a maintainer # who tested that claim would find it false and might conclude the check is redundant.) - # `modified` is accepted alongside `changed` deliberately. Live Gitea emits `changed`; the full - # emitted vocabulary was re-derived on 1.27.1 (2026-08-28, ersatztv#747) over the file rows of the - # 200 most recently updated PRs — exactly `changed`, `added`, `renamed`, `deleted`, all four already - # in the list below. `modified` is retained because a + # `modified` is accepted alongside `changed` deliberately. Live Gitea emits `changed`; a re-derivation + # on 1.27.1 (2026-08-28, ersatztv#747) over the file rows of the 200 most recently updated PRs saw + # `changed`, `added`, `renamed` and `deleted` — all four already in the list below. `copied` and + # `modified` did not appear in that corpus, which does not show they are never emitted. `modified` + # is retained because a # closed allow-list built from the wrong vocabulary is a worse failure than the hole it closes — it # would gate every genuine docs-only PR on any version that spells it differently. The property is # "reject values we do not recognise", not "enumerate one version exactly". diff --git a/scripts/tests/test_merge_consent_exemption.py b/scripts/tests/test_merge_consent_exemption.py index c7c11f237..b4370d331 100644 --- a/scripts/tests/test_merge_consent_exemption.py +++ b/scripts/tests/test_merge_consent_exemption.py @@ -478,9 +478,9 @@ def test_a_rename_disguised_by_an_unknown_status_is_rejected(hook, status): def test_gitea_real_status_values_are_accepted(hook): """Positive control for the closed set. The real Gitea value for an edit is `changed`, NOT `modified` — a closed allow-list built from the wrong vocabulary would reject every real - docs-only PR, which is a far worse failure than the hole it closes. Measured on 1.25.4 and - re-derived on 1.27.1 (2026-08-28, ersatztv#747) across a 200-PR corpus, which emits exactly - `changed`, `added`, `renamed`, `deleted`.""" + docs-only PR, which is a far worse failure than the hole it closes. Measured on 1.25.4; a 200-PR + corpus on 1.27.1 (2026-08-28, ersatztv#747) showed `changed`, `added`, `renamed` and `deleted`. + `copied`/`modified` did not appear there, which does not show they are never emitted.""" hook.set_pages( [ {"filename": "docs/a.md", "status": "changed"}, -- 2.47.3 From 2cb8d417a2184f70433dcba777ccf6cbb523f202 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 28 Aug 2026 17:44:07 +0200 Subject: [PATCH 3/6] docs(747): bind each re-probe claim to the exact arm that produced it Round 2 reviewed the round-1 fix commit and returned 12 findings. The pattern was that the CORRECTIONS were themselves new claims, and inherited the same defect they were fixing. Two were the same class as round 1, reintroduced by the fix: * "the pull_request_target half IS re-measured at 1.27.1" was unsourced. That record's only 1.27.1 datum is a timeline-endpoint measurement on PR #752; every overlap/serialization probe in it is 1.25.4-era (PR #722, runs 7520/7521, 7528/7529). #747 did not re-measure it - now says so, in both copies * replacing "no longer observable" with "can only arise from two merges to main" substituted a new closed set that forgets a ref class: docker-build.yml also triggers on `v*` tag pushes, which are outside main's branch protection. The honest statement is that the cheap disposable-scratch-branch probe is gone, not that the event cannot occur Control bound to the right arm: the mechanics field attached "posting the context -> 200" immediately after arm B's force-merge 405, so it read as controlling that. The control was run on PR #863's ORDINARY merge. Arm B's force-merge 405 has no control, and the record now says so. Ordinary-merge arm pinned to its field value: it ran on scratch/747-base with block_admin_merge_override at its false default, which is what makes the claim well-defined - absence blocks an ordinary merge WITHOUT needing the override field; the field governs the force path only (arm A false -> merged, arm B true -> refused). The heading no longer asserts "only the override field blocks a FORCED one" as a general mechanism. Incomplete fixes completed: * the cancel-route record's TITLE still claimed 1.27.1 for the MCP and web-UI legs the body had just marked un-re-run; title and its workflow-process.md index copy now scope the re-probe to the REST route * pr-changed-files.sh's parenthetical governed a compound sentence including the `--depth=1` merge-base clause, which was never re-probed; now split Also: artifact inventory reconciled (four scratch bases - one ordinary-merge pair plus three force-merge arms - not "a pair per arm", which implied four arms); the gate-trigger caveat moved out of the middle of a four-item enumeration it had broken; a dangling "the 1.25.4-dated note" correcting a belief about a sentence this change itself deleted; and two negative universals softened to what was observed. Refs: #747 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) --- docs/ci-cd.md | 9 ++++--- .../ci/batch-pushes-no-cancel-route.md | 2 +- .../records/ci/docs-only-skip-steps.md | 6 ++--- .../records/ci/gate-trigger-base-resolved.md | 11 ++++---- .../records/ci/shared-pr-file-enumeration.md | 4 +-- .../ci/verdict-write-retarget-fence.md | 12 +++++---- .../release/main-direct-push-disabled.md | 25 ++++++++++--------- docs/decisions/workflow-process.md | 2 +- scripts/pr-changed-files.sh | 10 ++++---- 9 files changed, 43 insertions(+), 38 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 3e851ab24..4736d2293 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1580,10 +1580,11 @@ on a push to a *non-`main`* branch — `docker-build.yml` filters its `push` tri tags, `ci-image.yml` to `main` and `docker/ci/**` with deliberately no `tags:` key (`ci.toolchain-image-publish-is-a-dispatch`), and the other four have no `push` trigger at all — and `main` itself refuses direct pushes since `release.main-direct-push-disabled`. A PR merge IS a push to -`main`, so superseded `push` runs can still arise naturally from two merges landing close together; -what is gone is the ability to schedule one. Reproducing it deliberately would mean adding a throwaway -trigger, which costs more than the claim is worth — nothing branches on it. The `pull_request_target` -half is what the fence relies on, and that is separately measured at 1.27.1. +`main`, and a `v*` tag push also triggers `docker-build.yml`, so superseded `push` runs still arise from +either; what is gone is the cheap disposable probe — the scratch-branch route the other measurements +used. Re-running it would mean arranging real merges or a tag push, which costs more than the claim is +worth: nothing branches on it. The `pull_request_target` half the fence actually relies on was **not** +re-measured either and is 1.25.4-dated too. Separately, after posting an exemption `success` the job re-reads the per-POST status history and, if a human `Review-verdict:` row appeared during the write window, overwrites its own status with diff --git a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md index 66933e3c1..d0a8b3030 100644 --- a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md +++ b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md @@ -1,6 +1,6 @@ --- key: ci.batch-pushes-no-cancel-route -title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 or 1.27.1 (#542)' +title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542)' status: active since: '2026-07-21' supersedes: none diff --git a/docs/decisions/records/ci/docs-only-skip-steps.md b/docs/decisions/records/ci/docs-only-skip-steps.md index 8bfd595c6..6b3151a3a 100644 --- a/docs/decisions/records/ci/docs-only-skip-steps.md +++ b/docs/decisions/records/ci/docs-only-skip-steps.md @@ -21,9 +21,9 @@ docs-only PR produced **no run** for them, those contexts never report and the P on Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`** (a distinct state, not `success`); how branch protection treats a `skipped` *required* context is not something we rely on. **Re-confirmed unchanged on 1.27.1, 2026-08-28 (#747)**, without a new probe PR — `Build & push -image (amd64)` is `if:`-skipped on every PR, and reports `skipped` on the heads of PR #829 -(`e5a01dc84100`) and PR #828 (`02bb0393`) alongside `success` and `pending` on the rest. The distinct -state survived the upgrade, so the reason this decision gates STEPS rather than jobs still holds. +image (amd64)` is `if:`-skipped on every PR, and reports `skipped` — a state distinct from `success` +— on the heads of PR #829 (`e5a01dc84100`) and PR #828 (`02bb0393`). The distinct state survived the +upgrade, so the reason this decision gates STEPS rather than jobs still holds. **The decision.** Each heavy job (`test`, `migrations`, `functional-e2e`, `build`) runs `scripts/ci-detect-docs-only.sh` as its first post-checkout step (`id: detect`) and gates every real diff --git a/docs/decisions/records/ci/gate-trigger-base-resolved.md b/docs/decisions/records/ci/gate-trigger-base-resolved.md index ba2e2a426..570b5066f 100644 --- a/docs/decisions/records/ci/gate-trigger-base-resolved.md +++ b/docs/decisions/records/ci/gate-trigger-base-resolved.md @@ -24,14 +24,15 @@ branch protection requires the *context*, not an author, while carrying `require 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 — **re-confirmed at the STATUS level -on 1.27.1, 2026-08-28 (#747)** as a by-product of an unrelated probe, where 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 that observation is also consistent with a run that posted -nothing; and the fourth — the negative one +`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. +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: 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 diff --git a/docs/decisions/records/ci/shared-pr-file-enumeration.md b/docs/decisions/records/ci/shared-pr-file-enumeration.md index 1ac09689c..b94297b4a 100644 --- a/docs/decisions/records/ci/shared-pr-file-enumeration.md +++ b/docs/decisions/records/ci/shared-pr-file-enumeration.md @@ -61,8 +61,8 @@ in each caller: 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 1.25.4-dated - note naming only `changed` and `deleted` is narrower than this corpus shows. + 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 diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index 65c9772f5..fc8b79ac6 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -16,11 +16,13 @@ status writes are not serialized, so a stale run can post over a fresher one. Th ## Measured, not reasoned (Gitea 1.25.4, 2026-08-03) -**The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and cannot be reproduced on -demand.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, -so a superseded `push` run can now only arise from two merges to `main` landing close together — -which is not schedulable. The `pull_request_target` half below is what this fence actually relies -on, and that IS re-measured at 1.27.1. +**The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and the cheap probe route is +gone.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, so a +superseded `push` run now arises only from a `push` to `main` (i.e. a PR merge) or from a `v*` tag +push, which `docker-build.yml` also triggers on. Both are real, arrangeable events; neither is a +disposable scratch-branch probe of the kind every other measurement here used, which is why the claim +was not re-run rather than being unobservable. #747 did **not** re-measure the `pull_request_target` +half below either — it stays 1.25.4-dated (probe PR #722; runs 7520/7521 and 7528/7529). - **`pull_request_target` runs for one PR overlap, older finishing last.** Probe PR #722: run 7520 (`opened`) completed at 18:30:42, twenty seconds *after* run 7521 (`synchronize`) began. Race 1's diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index 44a67af84..26f321f39 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' -mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field: on throwaway `scratch/747{a,b,c}-base` rules — never on `main`, whose rule `updated_at` is unchanged at 2026-08-05 throughout — an admin `force_merge: true` against an ABSENT required context MERGED (HTTP 200) with `block_admin_merge_override: false`, and the IDENTICAL call with only that field flipped to `true` was REFUSED HTTP 405 `Not all required status checks successful`. Probing it never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. A separate control on the same scratch base establishes the 405 is attributable to the ABSENCE: posting only the required context as `success` and repeating the identical merge returned HTTP 200. Third arm, same method: `enable_bypass_allowlist: true` with an EMPTY `bypass_allowlist_usernames` and `block_admin_merge_override: false` did NOT constrain the admin — force-merge still returned HTTP 200 — so in THAT configuration it does not substitute for `block_admin_merge_override`. A non-empty allowlist was not tested, and this does not distinguish `does not govern admins` from `empty list treated as unconfigured`. All #747 probe artifacts (four rules, eight branches — a base/head pair per arm — and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' +mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field: on throwaway `scratch/747{a,b,c}-base` rules — never on `main`, whose rule `updated_at` is unchanged at 2026-08-05 throughout — an admin `force_merge: true` against an ABSENT required context MERGED (HTTP 200) with `block_admin_merge_override: false`, and the IDENTICAL call with only that field flipped to `true` was REFUSED HTTP 405 `Not all required status checks successful`. Probing it never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Separately, on the first scratch base (`scratch/747-base`, the field left at its `false` default), an ORDINARY merge with the required context absent was refused HTTP 405 on PR #863; posting only that context as `success` and repeating the identical ORDINARY merge returned HTTP 200, so THAT 405 is attributable to the absence. Arm B''s force-merge 405 has no such control. Third arm, same method: `enable_bypass_allowlist: true` with an EMPTY `bypass_allowlist_usernames` and `block_admin_merge_override: false` did NOT constrain the admin — force-merge still returned HTTP 200 — so in THAT configuration it does not substitute for `block_admin_merge_override`. A non-empty allowlist was not tested, and this does not distinguish `does not govern admins` from `empty list treated as unconfigured`. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- **Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct @@ -39,14 +39,16 @@ the control that makes it attributable — `block_admin_merge_override: false` l past that ABSENT context (HTTP 200); flipping only that field to `true` refused the identical call (HTTP 405). `main` itself was never merge-probed and its rule was never PATCHed. -**Absence blocks an ORDINARY merge; only the override field blocks a FORCED one.** State this as two -claims, because collapsing them contradicts the arm above. On that same scratch base, with the required -context never posted, a plain merge was refused HTTP 405 `Not all required status checks successful` — -so the gate does not fail open merely because `review-verdict.yml` did not run. The load-bearing -control: posting **only** that context as `success` and repeating the identical call returned HTTP 200, -so the 405 is attributable to the absence and not to something else on the PR. But absence alone does -**not** stop a *force* merge — that is exactly what `block_admin_merge_override: false` allowed above. -`main` carries the field `true`, which is what makes absence blocking on both paths there. +**Absence blocks an ORDINARY merge; absence alone does NOT block a FORCED one — in the arms tested, +`block_admin_merge_override: true` is what did.** State it as two claims, because collapsing them +contradicts the arm above. On `scratch/747-base`, with `block_admin_merge_override` at its `false` +default and the required context never posted, a plain merge was refused HTTP 405 `Not all required +status checks successful` — so the gate does not fail open merely because `review-verdict.yml` did not +run, and it does not need the override field to hold on that path. The load-bearing control: posting +**only** that context as `success` and repeating the identical ordinary merge returned HTTP 200, so the +405 is attributable to the absence and not to something else on the PR. A *force* merge is the other +path: at the same `false` value it went through (arm A), and only flipping the field to `true` refused +it (arm B). `main` carries `true`, so absence blocks both paths there. **The 1.27 bypass allowlist is not a substitute, in the one configuration tested.** `enable_bypass_allowlist` reads like a better-targeted control. Enabled with an EMPTY @@ -57,9 +59,8 @@ unconfigured". Enough to refuse the swap; not enough to describe the field's sem claim that it postdates 1.25.4 comes from #747's issue body, not from a probe here. **Trap: `mergeable` does not answer the gate question.** On the absent-context PR the API reported -`mergeable: true` while that merge was being refused. Whatever that field does reflect, it does not -reflect required-context state — observed once, on one scratch PR, with other protection inputs held -fixed. Never read `mergeable` to decide whether the gate would admit a merge; attempt the merge, or +`mergeable: true` while that merge was being refused, so there it did not track required-context state +— observed once, on one scratch PR, with the other protection inputs held fixed. Never read `mergeable` to decide whether the gate would admit a merge; attempt the merge, or read the required contexts. **What this actually closes, and what it does not.** It closes the *write-only* credential routes, diff --git a/docs/decisions/workflow-process.md b/docs/decisions/workflow-process.md index ab400ed2b..2ccf71e53 100644 --- a/docs/decisions/workflow-process.md +++ b/docs/decisions/workflow-process.md @@ -96,7 +96,7 @@ another doc or an old issue comment should land here and then follow the link. - 2026-07-21 — An infra-shaped red under host load is not a code failure (#542) — [`ci.infra-shaped-red-under-load`](records/ci/infra-shaped-red-under-load.md) - 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542) — [`ci.monitor-armed-at-pr-open`](records/ci/monitor-armed-at-pr-open.md) - 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542) — [`process.bom-format-detection-recipe`](records/process/bom-format-detection-recipe.md) -- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 or 1.27.1 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md) +- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md) - 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542) — [`process.enumerate-workaround-behaviors-before-deleting`](records/process/enumerate-workaround-behaviors-before-deleting.md) - 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542) — [`process.build-concurrency-limits`](records/process/build-concurrency-limits.md) - 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542) — [`ci.verify-locally-ci-confirms`](records/ci/verify-locally-ci-confirms.md) diff --git a/scripts/pr-changed-files.sh b/scripts/pr-changed-files.sh index 3d9c7a987..791403a7d 100755 --- a/scripts/pr-changed-files.sh +++ b/scripts/pr-changed-files.sh @@ -41,11 +41,11 @@ # This NARROWS the window, it does not erase it. The base is re-read after the paging round trips # alongside the head, so a retarget that is still in effect at that point fails closed; a retarget # that opens and closes strictly between the files call and the re-read is not observable from here. -# Pinning the diff to two shas would close it, and Gitea cannot serve that (measured on 1.25.4, -# re-confirmed on 1.27.1 2026-08-28 ersatztv#747): `compare/{base}... -# {head}` returns `total_commits`/`commits` and NO `files`, and a `--depth=1` fetch of the two shas -# has no merge base, so a three-dot diff is impossible while a two-dot one over-reports every commit -# `main` gained since the branch point (both measured, #698). The remainder is covered one level up +# Pinning the diff to two shas would close it, and Gitea cannot serve that: `compare/{base}... +# {head}` returns `total_commits`/`commits` and NO `files` (measured on 1.25.4, re-confirmed on +# 1.27.1 2026-08-28 ersatztv#747). Separately, and NOT re-probed since 1.25.4: a `--depth=1` fetch of +# the two shas has no merge base, so a three-dot diff is impossible while a two-dot one over-reports +# every commit `main` gained since the branch point (both measured, #698). The remainder is covered one level up # instead, by the workflow reclassifying on `edited` rather than trusting a machine-written success. # # AUTH/TRANSPORT is caller-supplied via env, because the two callers authenticate differently: -- 2.47.3 From 5b310f5587085e6ae2d130c3a856606d966fd810 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 28 Aug 2026 17:58:55 +0200 Subject: [PATCH 4/6] docs(747): replace the experiment's prose narration with an arm-bound table Round 3 returned 9 findings and confirmed the pattern held for a third time: the corrections themselves carried new defects. Three rounds, one mechanism - prose re-deriving relationships BETWEEN experimental arms, which regenerates a fresh mis-binding every time it is reworded. Per `fix-the-boundary-not-the-site`, this removes the mechanism rather than patching a fourth set of sentences. The five measurements are now a table in release.main-direct-push-disabled: one row per scratch base, each naming its rule config, merge kind, PR and result. A row is self-binding, so no wording can attach a control to the wrong arm or invent a single-field mutation. The prose that remains states only what specific row PAIRS license, and names the pairs. Round-3 findings, all addressed: * a `v*` tag push is NOT a route to a SUPERSEDED push run - supersession is per-ref and a release tag is pushed once. The round-2 correction had swapped one wrong closed set for another, and deleted the accurate alternative (adding a throwaway trigger) on the way out. Both restored, both copies * the body claimed "the control that makes it attributable" for arms A/B while the mechanics field, fixed in the same commit, said arm B's 405 has no control. The table plus "the pair is the evidence, neither arm alone" replaces both * "the IDENTICAL call with only that field flipped" described a single-rule mutation that never happened: each arm had its own base, rule, head and PR, and arm A merged so its PR could not be re-used. Corrected in all four sites * the `mergeable` trap's nearest antecedent was arm C, which returned 200; it is now bound to PR #863, the ordinary-merge row * round-2's --depth=1 split was applied to pr-changed-files.sh but not to ci.exemption-provenance, so the currency claim still reached a clause never re-probed. Now split there too * "#747 carried out that re-verification across the CI docs" was a completeness claim; it was not a corpus sweep. Replaced by an enumeration of what WAS re-probed plus an explicit note that other 1.25.4-dated claims remain, including the creator-attribution measurement the H10 allow-list rests on * three non-blocking: a negative universal about "every other measurement here", an on-demand/arrangeable framing tension across two copies, and the one #747 claim that did not name its artifact Refs: #747 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) --- docs/ci-cd.md | 24 ++++---- .../records/ci/actions-credential-scoping.md | 17 ++++-- .../records/ci/exemption-provenance.md | 6 +- .../records/ci/gate-trigger-base-resolved.md | 2 +- .../ci/verdict-write-retarget-fence.md | 14 +++-- .../release/main-direct-push-disabled.md | 57 +++++++++++-------- 6 files changed, 69 insertions(+), 51 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 4736d2293..35f55ded2 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1302,12 +1302,15 @@ entire gate was skippable with no forgery at all, which was cheaper than every r `POST /pulls/{n}/merge` with `force_merge: true` and merge straight past a missing or red `review-verdict/h10`. Disabling push alone just moves the bypass from the push path to the merge path, since `timothy` is admin and is the identity every session already uses. **Measured - 2026-08-28 (ersatztv#747)**, replacing the earlier source-attested-only note: on throwaway - `scratch/747{a,b}-base` rules (arms A and B; a third arm C covered the bypass allowlist), `false` - let an admin `force_merge` past an ABSENT required context - (HTTP 200) and `true` refused the identical call (HTTP 405 `Not all required status checks - successful`). It never required merging an unreviewed PR into `main` — a scratch BASE branch carries - its own rule, so the experiment is disposable. `enable_bypass_allowlist` is **not** a + 2026-08-28 (ersatztv#747)**, replacing the earlier source-attested-only note. Two throwaway scratch + bases, each with its own rule, head and PR — differing by construction, not by mutating one rule in + place: on `scratch/747a-base` with the field `false`, an admin `force_merge` past an ABSENT required + context MERGED (HTTP 200); on `scratch/747b-base`, whose rule was identical but for the field set + `true`, the same call was refused (HTTP 405 `Not all required status checks successful`). The pair is + the evidence — arm B's 405 has no attribution control of its own. It never required merging an + unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the experiment is + disposable. Full arm table, including the ordinary-merge arm and its control: + `release.main-direct-push-disabled`. `enable_bypass_allowlist` is **not** a substitute in the one configuration tested: enabled with an EMPTY username list (and `block_admin_merge_override: false`) the admin force-merge still returned HTTP 200. A non-empty list was not tested. @@ -1580,10 +1583,11 @@ on a push to a *non-`main`* branch — `docker-build.yml` filters its `push` tri tags, `ci-image.yml` to `main` and `docker/ci/**` with deliberately no `tags:` key (`ci.toolchain-image-publish-is-a-dispatch`), and the other four have no `push` trigger at all — and `main` itself refuses direct pushes since `release.main-direct-push-disabled`. A PR merge IS a push to -`main`, and a `v*` tag push also triggers `docker-build.yml`, so superseded `push` runs still arise from -either; what is gone is the cheap disposable probe — the scratch-branch route the other measurements -used. Re-running it would mean arranging real merges or a tag push, which costs more than the claim is -worth: nothing branches on it. The `pull_request_target` half the fence actually relies on was **not** +`main`, so two merges landing close together remain a route; a `v*` tag push triggers `docker-build.yml` +but is **not** one, because supersession is per-ref and a release tag is pushed once. What is gone is the +cheap disposable probe — the scratch-branch route the other measurements used. Re-running it would mean +arranging two real merges, or adding a throwaway trigger; both cost more than the claim is worth, +because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not** re-measured either and is 1.25.4-dated too. Separately, after posting an exemption `success` the job re-reads the per-POST status history and, if diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index fd0a6f556..0bd621969 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -139,12 +139,17 @@ The instance was upgraded mid-session on 2026-08-05 (#743). Everything above mea therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403 probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their date and are the best evidence available, but do not cite them as current behaviour without -re-probing. #747 carried out that re-verification across the CI docs on 2026-08-28 and **did not** -discharge this record's remaining items: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the -`write:package` 403 were all left un-re-run, and they stay 1.25.4-dated. Only the 403 needs a scoped -PAT minted against the live instance; the scope enum and the `reqRepoWriter` gate were established by -reading Gitea's source at the version tag (see "Measured vs inferred" above), so re-running them needs -only a source read at `v1.27.1`. #747 did neither. What #747 did +re-probing. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — +`release.main-direct-push-disabled`, `ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the +REST-route half of `ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` +and the `branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the +corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10 +allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own +remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403 +all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum +and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see +"Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did +neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute. diff --git a/docs/decisions/records/ci/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md index 6f5b62806..c63afbc18 100644 --- a/docs/decisions/records/ci/exemption-provenance.md +++ b/docs/decisions/records/ci/exemption-provenance.md @@ -21,9 +21,9 @@ mid-flight. The job enumerated against the moved base, read docs-only, and poste reclassified** (`created_at == updated_at`), leaving a PR into `main` whose diff carried a C# file behind a green required check. Closed unmerged, branches deleted, no forged `h10` left anywhere. **Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright; -Gitea cannot serve that — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**: -`compare/{base}...{head}` returns an object whose only keys are `commits` and `total_commits`, no -`files` (probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits). And a +Gitea cannot serve that: `compare/{base}...{head}` returns an object whose only keys are `commits` +and `total_commits`, no `files` — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**, +probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, and **not re-probed since 1.25.4**, a `--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the branch point. So the base is read before the first page and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly diff --git a/docs/decisions/records/ci/gate-trigger-base-resolved.md b/docs/decisions/records/ci/gate-trigger-base-resolved.md index 570b5066f..51ad7baa1 100644 --- a/docs/decisions/records/ci/gate-trigger-base-resolved.md +++ b/docs/decisions/records/ci/gate-trigger-base-resolved.md @@ -29,7 +29,7 @@ establishing the residual below — is counted because omitting it turns an hone overclaim. Probes posted only probe-named contexts, never a forged `h10`. Full results in #699. 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: a PR from a scratch head into a scratch base carried thirteen `pull_request` +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. diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index fc8b79ac6..9124320b5 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -17,12 +17,14 @@ status writes are not serialized, so a stale run can post over a fresher one. Th ## Measured, not reasoned (Gitea 1.25.4, 2026-08-03) **The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and the cheap probe route is -gone.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, so a -superseded `push` run now arises only from a `push` to `main` (i.e. a PR merge) or from a `v*` tag -push, which `docker-build.yml` also triggers on. Both are real, arrangeable events; neither is a -disposable scratch-branch probe of the kind every other measurement here used, which is why the claim -was not re-run rather than being unobservable. #747 did **not** re-measure the `pull_request_target` -half below either — it stays 1.25.4-dated (probe PR #722; runs 7520/7521 and 7528/7529). +gone.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, so the +only remaining route is two `push`es to `main` — i.e. two PR merges — landing close together. +Supersession is per-ref, so a `v*` tag push is not one: `docker-build.yml` triggers on it, but each +release tag is a ref pushed once, with nothing to supersede. That route is real and arrangeable, but it +is not the disposable scratch-branch probe this record's other push measurements used, and the +alternative is adding a throwaway trigger — which is why the claim was not re-run rather than being +unobservable. #747 did **not** re-measure the `pull_request_target` half below either — it stays +1.25.4-dated (probe PR #722; runs 7520/7521 and 7528/7529). - **`pull_request_target` runs for one PR overlap, older finishing last.** Probe PR #722: run 7520 (`opened`) completed at 18:30:42, twenty seconds *after* run 7521 (`synchronize`) began. Race 1's diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index 26f321f39..78217f615 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' -mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field: on throwaway `scratch/747{a,b,c}-base` rules — never on `main`, whose rule `updated_at` is unchanged at 2026-08-05 throughout — an admin `force_merge: true` against an ABSENT required context MERGED (HTTP 200) with `block_admin_merge_override: false`, and the IDENTICAL call with only that field flipped to `true` was REFUSED HTTP 405 `Not all required status checks successful`. Probing it never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Separately, on the first scratch base (`scratch/747-base`, the field left at its `false` default), an ORDINARY merge with the required context absent was refused HTTP 405 on PR #863; posting only that context as `success` and repeating the identical ORDINARY merge returned HTTP 200, so THAT 405 is attributable to the absence. Arm B''s force-merge 405 has no such control. Third arm, same method: `enable_bypass_allowlist: true` with an EMPTY `bypass_allowlist_usernames` and `block_admin_merge_override: false` did NOT constrain the admin — force-merge still returned HTTP 200 — so in THAT configuration it does not substitute for `block_admin_merge_override`. A non-empty allowlist was not tested, and this does not distinguish `does not govern admins` from `empty list treated as unconfigured`. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' +mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- **Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct @@ -34,33 +34,40 @@ cite `enable_push: false` alone as the reason the gate holds. **The admin-override half is measurable without touching `main` (#747).** It was set on source attestation because probing it appears to require merging an unreviewed PR into `main`. It does not: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Measured -that way on 2026-08-28, on a scratch base requiring one probe-named context that was never posted, with -the control that makes it attributable — `block_admin_merge_override: false` let an admin `force_merge` -past that ABSENT context (HTTP 200); flipping only that field to `true` refused the identical call -(HTTP 405). `main` itself was never merge-probed and its rule was never PATCHed. +2026-08-28. Each row below is a SEPARATE scratch base with its own rule, head branch and PR — they +differ by construction, not by mutating one rule in place (arm A merged, so its PR could not be +re-used). Every row had one probe-named required context that was never posted, except the control. -**Absence blocks an ORDINARY merge; absence alone does NOT block a FORCED one — in the arms tested, -`block_admin_merge_override: true` is what did.** State it as two claims, because collapsing them -contradicts the arm above. On `scratch/747-base`, with `block_admin_merge_override` at its `false` -default and the required context never posted, a plain merge was refused HTTP 405 `Not all required -status checks successful` — so the gate does not fail open merely because `review-verdict.yml` did not -run, and it does not need the override field to hold on that path. The load-bearing control: posting -**only** that context as `success` and repeating the identical ordinary merge returned HTTP 200, so the -405 is attributable to the absence and not to something else on the PR. A *force* merge is the other -path: at the same `false` value it went through (arm A), and only flipping the field to `true` refused -it (arm B). `main` carries `true`, so absence blocks both paths there. +| # | base (PR) | `block_admin_merge_override` | `enable_bypass_allowlist` | merge | result | +|---|---|---|---|---|---| +| 1 | `scratch/747-base` (#863) | `false` (default) | `false` | ordinary | **405** | +| 1c | same base/PR, after posting that context `success` | `false` (default) | `false` | ordinary | **200** | +| A | `scratch/747a-base` (#864) | `false` | `false` | force | **200** | +| B | `scratch/747b-base` (#865) | `true` | `false` | force | **405** | +| C | `scratch/747c-base` (#866) | `false` | `true`, empty list | force | **200** | -**The 1.27 bypass allowlist is not a substitute, in the one configuration tested.** -`enable_bypass_allowlist` reads like a better-targeted control. Enabled with an EMPTY -`bypass_allowlist_usernames` and `block_admin_merge_override: false`, the admin force-merge still -returned HTTP 200 — so in that configuration it did not substitute. A NON-empty list was not tested, -and this does not distinguish "the allowlist does not govern admins" from "an empty list is treated as -unconfigured". Enough to refuse the swap; not enough to describe the field's semantics. The version -claim that it postdates 1.25.4 comes from #747's issue body, not from a probe here. +405 is `Not all required status checks successful`. `main` itself was never merge-probed and its rule +was never PATCHed. -**Trap: `mergeable` does not answer the gate question.** On the absent-context PR the API reported -`mergeable: true` while that merge was being refused, so there it did not track required-context state -— observed once, on one scratch PR, with the other protection inputs held fixed. Never read `mergeable` to decide whether the gate would admit a merge; attempt the merge, or +**What the rows license, and nothing more.** Row 1 vs 1c is the only pair with an attribution control: +the single variable between them is the context, so row 1's 405 is attributable to its ABSENCE. So the +gate does not fail open merely because `review-verdict.yml` did not run — and on the ordinary path it +does not need `block_admin_merge_override`, which was at its `false` default throughout row 1. A and B +differ only in that field, so the pair is the evidence that it governs the FORCE path; neither arm +alone is, and B's 405 has no control of its own. Absence therefore blocks an ordinary merge but not a +forced one. `main` carries the field `true`, which is what closes the force path there. + +**The 1.27 bypass allowlist is not a substitute, in the one configuration tested.** Row C differs from +row A only in `enable_bypass_allowlist`, and merged just the same — so enabling it did not substitute +for `block_admin_merge_override`. A NON-empty username list was not tested, and this cannot distinguish +"the allowlist does not govern admins" from "an empty list is treated as unconfigured". Enough to +refuse the swap; not enough to describe the field's semantics. The claim that it postdates 1.25.4 comes +from #747's issue body, not from a probe here. + +**Trap: `mergeable` does not answer the gate question.** On PR #863 — row 1, the ordinary-merge arm — +the API reported `mergeable: true` at the moment that merge was being refused 405, so there it did not +track required-context state. Observed once, on one scratch PR, with the other protection inputs held +fixed. Never read `mergeable` to decide whether the gate would admit a merge; attempt the merge, or read the required contexts. **What this actually closes, and what it does not.** It closes the *write-only* credential routes, -- 2.47.3 From d8c20e70c97007d15488ffa78e942256adc03abc Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 28 Aug 2026 18:10:47 +0200 Subject: [PATCH 5/6] docs(747): reconcile the arm table's prose with the four bases it actually describes Round 4 verified all five table cells against the raw measurements and found the primary data correct - the structural fix held. Its six findings were prose only, and the trend across rounds is 21 -> 12 -> 9 -> 6. The one that mattered: the table's preamble said every row was a SEPARATE scratch base with its own rule, head and PR. That is false for row 1c, which the table itself labels "same base/PR" - five rows, four bases - and it contradicted this record's own mechanics field ("Four SEPARATE"). Worse, taken literally it destroyed the control the next paragraph rests on: if 1c had its own base and PR, "the single variable between them is the context" would be false and row 1's 405 would not be attributable. Rows 1 and 1c are now stated as the same base and PR, re-merged after posting the context, which is exactly what makes 1c a control. Also fixed: * release.main-direct-push-disabled was listed among the 1.25.4-pinned claims #747 "re-probed". Nothing in that record was 1.25.4-dated - its mechanics opens "Gitea 1.27.1" and the override claim was source-attested against 1.27, never measured at any version. #747 measured it for the FIRST time, which the same paragraph already says correctly further down; it was listed twice, once in the wrong bucket * residual single-mutation shorthand: it is the RULES of A and B that differ only in that field, not the arms, which also differ in base, PR and head * mechanics lacked the body's "except the control step" carve-out, so it read as claiming the context was never posted on any base * "(both measured, #698)" dangled after round 2 split that sentence in two * two orphan half-lines left by earlier surgical edits Refs: #747 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) --- .../records/ci/actions-credential-scoping.md | 10 +++++----- .../release/main-direct-push-disabled.md | 18 ++++++++++-------- scripts/pr-changed-files.sh | 8 ++++---- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index 0bd621969..080bcebb1 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -140,17 +140,17 @@ therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their date and are the best evidence available, but do not cite them as current behaviour without re-probing. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — -`release.main-direct-push-disabled`, `ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the -REST-route half of `ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` -and the `branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the +`ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the REST-route half of +`ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` and the +`branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10 allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403 all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see "Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did -neither. What #747 did -settle elsewhere is the merge-path half — see `release.main-direct-push-disabled` for the measured +neither. What #747 did settle elsewhere is the merge-path half — see +`release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute. **Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index 78217f615..6b5a76c52 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' -mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' +mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- **Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct @@ -34,9 +34,11 @@ cite `enable_push: false` alone as the reason the gate holds. **The admin-override half is measurable without touching `main` (#747).** It was set on source attestation because probing it appears to require merging an unreviewed PR into `main`. It does not: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Measured -2026-08-28. Each row below is a SEPARATE scratch base with its own rule, head branch and PR — they -differ by construction, not by mutating one rule in place (arm A merged, so its PR could not be -re-used). Every row had one probe-named required context that was never posted, except the control. +2026-08-28. Rows 1 and 1c are the SAME base and the SAME PR — 1c is row 1 re-merged after posting its +context, which is what makes it a control. The other three rows are SEPARATE scratch bases, each with +its own rule, head branch and PR: they differ by construction, not by mutating one rule in place (arm A +merged, so its PR could not be re-used). Every row had one probe-named required context that was never +posted, except 1c. | # | base (PR) | `block_admin_merge_override` | `enable_bypass_allowlist` | merge | result | |---|---|---|---|---|---| @@ -52,13 +54,13 @@ was never PATCHed. **What the rows license, and nothing more.** Row 1 vs 1c is the only pair with an attribution control: the single variable between them is the context, so row 1's 405 is attributable to its ABSENCE. So the gate does not fail open merely because `review-verdict.yml` did not run — and on the ordinary path it -does not need `block_admin_merge_override`, which was at its `false` default throughout row 1. A and B -differ only in that field, so the pair is the evidence that it governs the FORCE path; neither arm +does not need `block_admin_merge_override`, which was at its `false` default throughout row 1. A and +B's RULES differ only in that field, so the pair is the evidence that it governs the FORCE path; neither arm alone is, and B's 405 has no control of its own. Absence therefore blocks an ordinary merge but not a forced one. `main` carries the field `true`, which is what closes the force path there. -**The 1.27 bypass allowlist is not a substitute, in the one configuration tested.** Row C differs from -row A only in `enable_bypass_allowlist`, and merged just the same — so enabling it did not substitute +**The 1.27 bypass allowlist is not a substitute, in the one configuration tested.** Row C's rule +differs from row A's only in `enable_bypass_allowlist`, and its PR merged just the same — so enabling it did not substitute for `block_admin_merge_override`. A NON-empty username list was not tested, and this cannot distinguish "the allowlist does not govern admins" from "an empty list is treated as unconfigured". Enough to refuse the swap; not enough to describe the field's semantics. The claim that it postdates 1.25.4 comes diff --git a/scripts/pr-changed-files.sh b/scripts/pr-changed-files.sh index 791403a7d..4a89a4ef0 100755 --- a/scripts/pr-changed-files.sh +++ b/scripts/pr-changed-files.sh @@ -45,7 +45,7 @@ # {head}` returns `total_commits`/`commits` and NO `files` (measured on 1.25.4, re-confirmed on # 1.27.1 2026-08-28 ersatztv#747). Separately, and NOT re-probed since 1.25.4: a `--depth=1` fetch of # the two shas has no merge base, so a three-dot diff is impossible while a two-dot one over-reports -# every commit `main` gained since the branch point (both measured, #698). The remainder is covered one level up +# every commit `main` gained since the branch point (measured, #698). The remainder is covered one level up # instead, by the workflow reclassifying on `edited` rather than trusting a machine-written success. # # AUTH/TRANSPORT is caller-supplied via env, because the two callers authenticate differently: @@ -195,9 +195,9 @@ while [ "$page" -le "$MAX_PAGES" ]; do # on 1.27.1 (2026-08-28, ersatztv#747) over the file rows of the 200 most recently updated PRs saw # `changed`, `added`, `renamed` and `deleted` — all four already in the list below. `copied` and # `modified` did not appear in that corpus, which does not show they are never emitted. `modified` - # is retained because a - # closed allow-list built from the wrong vocabulary is a worse failure than the hole it closes — it - # would gate every genuine docs-only PR on any version that spells it differently. The property is + # is retained because a closed allow-list built from the wrong vocabulary is a worse failure than + # the hole it closes — it would gate every genuine docs-only PR on any version that spells it + # differently. The property is # "reject values we do not recognise", not "enumerate one version exactly". if ! printf '%s' "$raw" \ | jq -e 'def ok: type == "string" and length > 0 -- 2.47.3 From ab00c6bfb87887d72797199572f3af5f713cac6a Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 28 Aug 2026 18:19:23 +0200 Subject: [PATCH 6/6] docs(747): row 1's merge was refused, so 1c re-attempts it rather than re-merging Round 5 returned two Low findings and declared the change mergeable. Both fixed: "1c is row 1 re-merged" was literally false - row 1's ordinary merge was REFUSED with 405, which the table two lines below states, so nothing was merged there to re-merge. 1c re-attempts the same ordinary merge after posting the context. The mechanics field already worded this precisely; the body was the looser of the two. Also joined the 52-col orphan this session's own edit left in ci.actions-credential-scoping, and rewrapped the paragraph the fix lengthened. Refs: #747 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) --- .../decisions/records/ci/actions-credential-scoping.md | 4 ++-- .../records/release/main-direct-push-disabled.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index 080bcebb1..74802e2ff 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -149,8 +149,8 @@ remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and t all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see "Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did -neither. What #747 did settle elsewhere is the merge-path half — see -`release.main-direct-push-disabled` for the measured +neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled` +for the measured `block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute. **Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index 6b5a76c52..fbd95c85a 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -34,11 +34,11 @@ cite `enable_push: false` alone as the reason the gate holds. **The admin-override half is measurable without touching `main` (#747).** It was set on source attestation because probing it appears to require merging an unreviewed PR into `main`. It does not: a scratch BASE branch carries its own protection rule, so the whole experiment is disposable. Measured -2026-08-28. Rows 1 and 1c are the SAME base and the SAME PR — 1c is row 1 re-merged after posting its -context, which is what makes it a control. The other three rows are SEPARATE scratch bases, each with -its own rule, head branch and PR: they differ by construction, not by mutating one rule in place (arm A -merged, so its PR could not be re-used). Every row had one probe-named required context that was never -posted, except 1c. +2026-08-28. Rows 1 and 1c are the SAME base and the SAME PR — 1c is the same ordinary merge +re-attempted after posting its context, which is what makes it a control. The other three rows are +SEPARATE scratch bases, each with its own rule, head branch and PR: they differ by construction, +not by mutating one rule in place (arm A merged, so its PR could not be re-used). Every row had +one probe-named required context that was never posted, except 1c. | # | base (PR) | `block_admin_merge_override` | `enable_bypass_allowlist` | merge | result | |---|---|---|---|---|---| -- 2.47.3