Files
ersatztv/docs/decisions/records/release/main-direct-push-disabled.md
T
fb55929711
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
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>
2026-09-02 20:20:43 +00:00

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.