`required_approvals: 1` on `main` was a lock-out, not a protection. Gitea
refuses to let a PR's author approve their own PR, and `jakman85` is the only
collaborator — so no account in existence could approve a PR the operator
opened, including the PR that would fix that. Measured 2026-10-01 on PR #35:
gate green in 1m41s, reviews `[]`, `required_approvals: 1`, unmergeable.
It was never the documented design either: `ci/approval-policy.sh`'s header and
`ci/gate.sh:194` both already describe the model as "required_approvals drops
to 0 and the GATE decides", and `require-gate.sh`'s own closing note named this
failure and prescribed this remedy. 2026-09-30's entry flagged the 1-vs-0
disagreement as unresolved; this settles it at 0.
Layer 1 — branch protection. required_approvals 1 -> 0 in the declaration, plus
infra/gitea/allow-admin-self-merge.sh to apply it live (target read FROM the
declaration, so the script cannot drift from what the gate checks).
require-gate.sh moves to 0 too; left at 1 it would re-impose the lock-out on its
next run. The narrow choice is deliberate: the other way to unblock an owner is
block_admin_merge_override: false, which would re-open what
block-admin-override.sh was written to close (PRs #5-#9, zero approvals). This
drops the approval COUNT only — block_admin_merge_override and
enable_status_check stay true and are asserted after the PATCH, so a red gate
stays unmergeable by everyone, administrators included.
Layer 2 — ci/approval-policy.sh. Sensitive paths still need a non-author
approval the sole admin cannot get, so an administrator may waive it with an
`/approve-override <reason>` comment: directive must start a line, reason
mandatory, permission re-checked LIVE, invalidated by a new commit, and the
overrider + reason printed in the gate log and job summary. Candidate selection
is the pure, offline ci/approval-override.py (23 unit assertions, all passing);
authorisation stays in the fail-closed stage. Every new failure mode blocks.
The cost, stated not buried: for an administrator, "a change to the gate cannot
be gated by the gate" is now advisory rather than structural. Unavoidable with
one human; what is bought back is an attributed, commit-scoped record instead of
a silent bypass. And the uncomfortable part, accepted as an operator decision:
authorisation is by live permission alone, and the AGENT account `gitea-admin`
also holds `owner` — so an agent can clear its own PR this way. Documented in
approval-policy.json, the stage header, and CLAUDE.md (which tells agents not
to). Narrowing needs no code change: add `override_admins` and the stage already
requires membership.
Adjacent fix, in scope because `make -C ci test` is the only runner for the
suite added here: ci/tests/gate-decision.test.sh hand-maintained a stub list
that "must name EVERY stage gate.sh runs" and omitted `approval-policy` (added
to gate.sh by PR #17), so run_stage hit exit 127 and 15 of 50 assertions failed.
Verified pre-existing on pristine origin/main @ 0c7e8dd. The list is now derived
from gate.sh's own run_stage lines, so the omission cannot recur; the suite goes
15-failed -> 50 passing and `make -C ci test` goes FAILED -> all suites passed.
Toolbox gate: rc 3 UNDETERMINED — every stage green except `gitea-settings`
(no SETTINGS_API_TOKEN locally) and `conformance` (no cluster). NOT a green run.
The live rule is UNCHANGED and still reads 1 — mutating it is an operator
action. Until allow-admin-self-merge.sh is run, ci/gitea-settings.sh will
correctly report DRIFT and this PR will be red.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
71 lines
3.8 KiB
Bash
Executable File
71 lines
3.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Items 1 + 2: make the gate a REQUIRED status check and require one approval.
|
|
#
|
|
# RUN THIS LAST. Prerequisites, in order:
|
|
# a) PR #7 (dind native sidecar) is MERGED, and
|
|
# b) you have SEEN a real workflow run go green on the converted pod.
|
|
# The runner Deployment is the merge gate for the whole repo. Turning on a required check
|
|
# while the runner is broken means no PR can merge -- including the revert. (Recoverable:
|
|
# an owner can set enable_status_check back to false. But do not rely on that.)
|
|
#
|
|
# Requires exactly ONE context: `gate / gate (pull_request)`.
|
|
# * NOT `gate / conformance (*)` -- that job is `if: false`, and a skipped Gitea job reports
|
|
# SUCCESS ("Has been skipped"). Requiring it would pass VACUOUSLY forever while nothing
|
|
# ran, which under HARD RULE 4 is worse than a deadlock: a deadlock is loud.
|
|
# block_admin_merge_override: true is LOAD-BEARING and was missing on the first pass. Gitea
|
|
# lets repo admins override merge requirements without it, so required_approvals and the
|
|
# required status check are both bypassable by exactly the account that does the merging.
|
|
# Measured: PRs #5-#9 merged with ZERO approvals recorded against required_approvals: 1.
|
|
#
|
|
# FIELD NAMES: this Gitea (1.24.3) has NO `apply_to_admins`. Asking jq for it returns null,
|
|
# which reads like "false" but means "key absent" -- enumerate real keys with `to_entries`
|
|
# instead of probing names you assume exist. That mistake cost a wasted PATCH.
|
|
#
|
|
# * NOT the `(push)` variant -- after PR #6 there are no per-branch push runs at all, so a
|
|
# PR would never produce that context.
|
|
set -euo pipefail
|
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
|
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
|
|
|
echo "==> updating branch protection on main"
|
|
jq -n '{
|
|
enable_push: false,
|
|
enable_status_check: true,
|
|
status_check_contexts: ["gate / gate (pull_request)"],
|
|
required_approvals: 0,
|
|
block_on_rejected_reviews: true,
|
|
dismiss_stale_approvals: true,
|
|
block_on_outdated_branch: false,
|
|
block_admin_merge_override: true
|
|
}' | api -X PATCH -H 'Content-Type: application/json' --data @- \
|
|
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main" >/dev/null
|
|
|
|
echo "==> verifying"
|
|
api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branches/main" \
|
|
| jq -r '{protected,user_can_push,enable_status_check,status_check_contexts,required_approvals}'
|
|
|
|
cat <<'NOTE'
|
|
|
|
NOTE ON required_approvals: 0 -- CHANGED FROM 1 ON 2026-10-01, and this script is the reason
|
|
the change had to land here too: re-running it with 1 would silently re-impose the lock-out.
|
|
|
|
The note that used to sit here called the problem and its own fix: "Gitea will not let an
|
|
author approve their own PR ... if that is awkward ... set required_approvals back to 0 and
|
|
rely on the status check alone." It was awkward. With jakman85 the ONLY collaborator, every PR
|
|
the operator opened was unmergeable -- measured on PR #35, gate green in 1m41s, zero reviews,
|
|
nothing that could ever satisfy the rule. So: 0, and the status check alone, as written.
|
|
|
|
What did NOT change, deliberately:
|
|
* block_admin_merge_override stays TRUE. This drops the approval COUNT; it does not hand
|
|
anyone a bypass over the gate. A red gate is still unmergeable by everybody.
|
|
* Architecturally/security-relevant paths are still human-reviewed -- by ci/approval-policy.sh
|
|
(sensitive_paths in infra/gitea/approval-policy.json), which an administrator can waive
|
|
only by posting an explicit, attributed `/approve-override <reason>` comment.
|
|
|
|
Then prove it, rather than trusting the API:
|
|
git commit --allow-empty -m probe && git push origin main # EXPECT: rejected
|
|
git reset --hard HEAD~1
|
|
NOTE
|