Files
home-network-k3s-impl/infra/gitea/allow-admin-self-merge.sh
T
jakman85andClaude Opus 5 8ccfdab180 fix(ci): let an administrator merge their own green PR
`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]>
2026-10-02 00:44:31 -05:00

106 lines
6.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Let the sole administrator merge their own green PR: required_approvals 1 -> 0 on `main`.
#
# WHY. `required_approvals: 1` plus Gitea's refusal to let an author approve their own pull
# request plus exactly ONE collaborator (`jakman85`, permission `owner`) is a lock-out, not a
# policy. Every PR the operator opened was permanently unmergeable, including the PR that would
# fix it. Measured 2026-10-01: PR #35 — `gate / gate (pull_request)` success in 1m41s, zero
# reviews, `required_approvals: 1`, no account in existence able to satisfy it.
#
# The approvals field was also never what the design said it was. `ci/approval-policy.sh`'s
# header and `ci/gate.sh:194` both already described the model as "required_approvals: 0 and the
# GATE decides", and `require-gate.sh`'s own closing note named this exact failure and this exact
# remedy. decisions/2026-09-30-flux-deploy-notification-inhibited.md flagged the 1-vs-0
# disagreement as unresolved. This resolves it at 0.
#
# WHAT THIS DOES *NOT* DO, and the distinction is the whole point.
#
# * It does NOT touch `block_admin_merge_override`, which stays TRUE. The required status check
# still binds ABSOLUTELY — a red gate is unmergeable by everyone, administrators included.
# Setting `block_admin_merge_override: false` would have been the other way to unblock an
# owner, and it would have re-opened precisely what block-admin-override.sh was written to
# close: PRs #5-#9 merged with zero approvals while the settings read as enforced.
# * It does NOT stop reviewing security-relevant changes. Those are gated by
# ci/approval-policy.sh against infra/gitea/approval-policy.json `sensitive_paths`, which an
# administrator can waive only with an explicit, attributed `/approve-override <reason>`
# comment on the PR.
#
# ORDER MATTERS — READ THIS BEFORE RUNNING IT.
#
# ci/gitea-settings.sh fails the merge gate on ANY divergence between the live rule and
# infra/gitea/expected-branch-protection.json, and `gate.yml` sets REQUIRE_GITEA_SETTINGS=true.
# The declaration in git now says 0. So between running this and merging that declaration, the
# live value and the declared value must be compared from a branch that CARRIES the new
# declaration — otherwise the stage goes red, correctly, for drift.
#
# 1. MERGE any other open PRs FIRST, while approvals still work for them (their branches
# declare 1, and a PR you did not author can still be approved normally). Skipping this
# only costs a rebase later; it does not break anything.
# 2. Run this script.
# 3. Re-run the gate on the branch that carries the new declaration. `gitea-settings` goes
# green because live 0 == declared 0.
# 4. Merge that branch.
# 5. Any PR still open from before step 2 now declares 1 against a live 0 and will be RED
# until it is rebased onto the new main. That is the detector doing its job, not a fault.
#
# Recoverable: `required_approvals` can be PATCHed back to 1 by an owner at any time. If you do,
# change expected-branch-protection.json in the same breath or every PR goes red on drift.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
DECL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/expected-branch-protection.json"
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
[ -f "$DECL" ] || { echo "declaration missing: ${DECL}" >&2; exit 1; }
# Take the target from the DECLARATION, not from a literal here. If the two could disagree,
# this script would be one more thing re-asserting a value nothing checks — which is the
# failure mode the whole of infra/gitea/ exists to stop.
WANT="$(jq -r '.protection.required_approvals' "$DECL")"
[[ "$WANT" =~ ^[0-9]+$ ]] || { echo "declaration has no numeric required_approvals" >&2; exit 1; }
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")"
[ -n "$U" ] && [ -n "$P" ] || { echo "no stored credential for $HOST" >&2; exit 1; }
echo "authenticating as '${U}' (secret not shown)"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
B="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main"
# Read the RULE OBJECT, never /branches/main: the branch view does not expose
# block_admin_merge_override and showed all-green for hours while the rule was overridable.
echo "==> before"
api "$B" | jq -r '{required_approvals, block_admin_merge_override, enable_status_check, status_check_contexts}'
echo "==> setting required_approvals = ${WANT} (declared in expected-branch-protection.json)"
api -X PATCH -H 'Content-Type: application/json' \
-d "{\"required_approvals\": ${WANT}}" "$B" >/dev/null
echo "==> after"
AFTER_JSON="$(api "$B")"
jq -r '{required_approvals, block_admin_merge_override, enable_status_check, status_check_contexts}' <<<"$AFTER_JSON"
GOT="$(jq -r '.required_approvals' <<<"$AFTER_JSON")"
ADMIN_BLOCK="$(jq -r '.block_admin_merge_override' <<<"$AFTER_JSON")"
STATUS="$(jq -r '.enable_status_check' <<<"$AFTER_JSON")"
fail=0
[ "$GOT" = "$WANT" ] || { echo "PATCH did not take: required_approvals is ${GOT}, wanted ${WANT}" >&2; fail=1; }
# These two are what keeps this change narrow. If the PATCH disturbed either, the gate has
# stopped binding and that must be loud, not discovered later.
[ "$ADMIN_BLOCK" = true ] || { echo "block_admin_merge_override is ${ADMIN_BLOCK}, MUST be true — the gate is now bypassable" >&2; fail=1; }
[ "$STATUS" = true ] || { echo "enable_status_check is ${STATUS}, MUST be true — the gate is no longer required" >&2; fail=1; }
[ "$fail" -eq 0 ] || { echo "do NOT record this as applied." >&2; exit 1; }
cat <<'NOTE'
NOW PROVE IT -- the setting is not the evidence. Two things to see, not assume:
1. An APPROVAL IS NO LONGER NEEDED. On your own PR with a green gate, the merge button is
live with zero reviews on it.
2. THE GATE STILL BINDS. On a PR whose gate is RED or still pending, Gitea must REFUSE the
merge -- for you, as owner. If it lets you through, block_admin_merge_override is not
doing its job and this change has traded a lock-out for a bypass.
Then confirm the detector agrees: run ci/gate.sh from a branch carrying the new
expected-branch-protection.json and check that `gitea-settings` is green rather than DRIFT.
NOTE