diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 7e2d2971f..f11141acf 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -38,7 +38,9 @@ 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)` 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 f2f0fb235..35f55ded2 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,19 @@ 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. 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. **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 +1577,19 @@ 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 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 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 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..74802e2ff 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -139,7 +139,19 @@ 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 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — +`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. **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..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,19 +1,26 @@ --- 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; REST route re-probed on 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**): +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). **Only the operator can cancel, in the browser** — so if you must supersede a live +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/docs-only-skip-steps.md b/docs/decisions/records/ci/docs-only-skip-steps.md index bde330652..6b3151a3a 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` — 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/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md index fd0c4fc24..c63afbc18 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: `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 8cdb12dc6..51ad7baa1 100644 --- a/docs/decisions/records/ci/gate-trigger-base-resolved.md +++ b/docs/decisions/records/ci/gate-trigger-base-resolved.md @@ -28,6 +28,11 @@ and the base definition posted `h10=pending` on `opened` and `synchronize` alike 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 (PR #863): a PR from a scratch head into a scratch base carried thirteen `pull_request` +contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the +observation is also consistent with a run that started and posted nothing. + **Why `branches: [main]` is load-bearing rather than tidy.** The *base branch* supplies the definition, and anyone who can push a branch can make it a base — so dropping the filter trades a head-supplied gate for a base-supplied one and closes nothing. Worse than lateral: a commit status is diff --git a/docs/decisions/records/ci/shared-pr-file-enumeration.md b/docs/decisions/records/ci/shared-pr-file-enumeration.md index 696e3f4f1..b94297b4a 100644 --- a/docs/decisions/records/ci/shared-pr-file-enumeration.md +++ b/docs/decisions/records/ci/shared-pr-file-enumeration.md @@ -55,7 +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. 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) 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 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 71f67e12b..9124320b5 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -16,6 +16,16 @@ 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 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 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 818998d89..fbd95c85a 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. 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 @@ -31,6 +31,47 @@ 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. +**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 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 | +|---|---|---|---|---|---| +| 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** | + +405 is `Not all required status checks successful`. `main` itself was never merge-probed and its rule +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'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'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 +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, 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..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 (#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/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..4a89a4ef0 100755 --- a/scripts/pr-changed-files.sh +++ b/scripts/pr-changed-files.sh @@ -41,10 +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 1.25.4 cannot serve that: `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 (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: @@ -190,9 +191,13 @@ 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 - # 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 + # `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". if ! printf '%s' "$raw" \ | jq -e 'def ok: type == "string" and length > 0 diff --git a/scripts/tests/test_merge_consent_exemption.py b/scripts/tests/test_merge_consent_exemption.py index fdf209ee8..b4370d331 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; 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"},