Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/. Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named. Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution. #893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away. Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance. Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN. fixes #869 fixes #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
15 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| release.main-direct-push-disabled | 2026-08-05 — `main` refuses direct pushes (`enable_push: false`), because a push whitelist would have been a no-op here (#743) | active | 2026-08-05 | none | none | 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. | 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 | 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
push entirely)" as if the two were interchangeable. They are not, and which one is right depends on a
fact about this instance: the only accounts with repository write are timothy (a site admin) and
renovate. Every credential in the threat model — an agent session, a collaborator PAT, the
GITEA_TOKEN Gitea injects into every Actions job — authenticates as one of those two, and
overwhelmingly as timothy. A whitelist admitting timothy therefore admits precisely the identity
the control is supposed to constrain. It would have ticked the issue's box while changing nothing.
This was measured, not reasoned: the same push was refused under enable_push: false and accepted
under a whitelist naming timothy.
Disabling push alone was NOT enough, and the reason is the same argument twice. The first draft of
this record disabled direct push and concluded that review-verdict/h10 was therefore load-bearing.
An independent review caught that this repeated on the merge path exactly the mistake it had just
diagnosed on the push path. The push argument was: a whitelist naming timothy fails because
timothy is the identity every credential already holds. The merge path had the identical shape —
block_admin_merge_override defaulted to false, so CanBypassBranchProtection returned true for a
repo admin and POST /pulls/{n}/merge with force_merge: true merged straight past a missing or red
h10. One API call, cheaper than the push route it replaced. An admin-shaped control that exempts
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.
That enable_bypass_allowlist postdates 1.25.4 was an INHERITED claim and is now a checked one
(#869, 2026-09-02). It came from #747's issue body rather than from any probe, which is the shape
this repo keeps getting burned by — a claim whose only provenance is another artifact's prose. Read
off modules/structs/repo_branch.go at both tags: EnableBypassAllowlist does not occur anywhere in
the file at v1.25.4 and is present at v1.27.1, where this instance also returns
enable_bypass_allowlist in a live branch-protection read. So the claim holds, and the reason it is
believed is now the source rather than a sentence someone typed into an issue.
Note the neighbouring field does NOT share that history, which is worth stating because the paragraph
above could invite the inference: BlockAdminMergeOverride / block_admin_merge_override is present
at v1.25.4 too — in all three structs (BranchProtection, CreateBranchProtectionOption,
EditBranchProtectionOption). It is not a 1.27 addition, and nothing here should be read as saying it
is. What was new in this repo was our USE of it, not the field.
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),
RENOVATE_TOKEN, and any non-admin collaborator PAT. Those can no longer reach main at all, by any
path that skips the gate.
It does not close the admin route. timothy is a site admin, so a credential holding that
identity can PATCH either field off, act, and restore it — the exact sequence used to prove the
push semantics above. Closing that requires agent sessions to run as a scoped non-admin credential,
which is a different change with its own cost (packages live in a user namespace; see the "Admin
ownership is a real residual" section of ci.actions-credential-scoping). Recorded as an accepted
residual rather than fixed here, so it is not mistaken for covered. The severity bound from #697 and
#743 is unchanged throughout: push access is required, so this is a compromised contributor or a
subverted automated session, never an anonymous attacker.
Which write surfaces were enumerated. git push (measured, refused), the contents API and by
extension the web editor / upload path (measured on a probe branch, refused HTTP 403 — they share the
CanUserPush predicate, which has no admin special-case and no unprotected_file_patterns carve-out
since that field is empty), apply-patch / revert / cherry-pick (source-attested, same predicate),
force push (enable_force_push: false), default-branch deletion (separately refused), and fork-sync /
mirror (not applicable: fork: false, mirror: false). Merge remains the one intended path.
Why the release cut does not deadlock. #743 flagged that the tag path had to keep working, and
#719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to
refs/heads/main; tags are governed by an entirely separate mechanism, and tag_protections on this
repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated
rather than assumed: from one worktree, the branch push to main was refused and a tag push succeeded.
Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future
tag-protection rule would not inherit from this one.
The docker-build.yml persist-credentials question (#743's fourth box), since resolved in #746.
Head-resolved jobs kept a write-capable credential in .git/config because their actions/checkout
steps omitted persist-credentials: false. Setting it was right but not safe to do blind, and the
ordering is the part worth keeping, as a rule for the next time: unmask the dependent fetches first,
then set the flag. Under || true a credential regression does not fail the job — it yields an empty
changed-file set that the skip logic reads as "nothing changed". The population is also wider than the
six checkouts #743 recorded: eight in docker-build.yml, five in pr-checks.yml, one in
dependency-scan.yml, plus review-verdict.yml's pre-existing one — 15 of the repo's 16; the sixteenth, ci-image.yml's, followed in #744. Details in
ci.actions-credential-scoping.
Why this is not redundant with the Husky pre-push hooks. .husky/pre-push guards (H6 done-when,
H11 rebase, H13 clean worktree) are client-side and deliberately fail-open — a git hook cannot prompt.
They are not installed in CI, not present in a fresh clone until husky runs, and --no-verify
bypasses them, which the worktree workflow uses routinely. They are good friction against mistakes and
were never a control against a credential. This record is the server-side half; the hooks remain useful
and unchanged.