Files
home-network-k3s-impl/ci/approval-override.py
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

140 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""ci/approval-override.py — select the admin-override comment on a PR, if there is a valid one.
This script decides NOTHING about authority. It parses a PR's comment list and returns the
single override *candidate* that ci/approval-policy.sh should then try to authorise against
the live Gitea permission API. Keeping the two apart is the point:
* parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh);
* authorisation needs the API and must stay in the stage that fails closed.
A candidate is a comment whose body contains, AT THE START OF A LINE, the directive followed
by a non-empty reason:
/approve-override the gate cannot gate itself and I am the only human reviewer
WHY EACH RULE IS HERE
* START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override
("you could just /approve-override this") re-arms it. Markdown quotes are stripped of a
leading '>' before matching for the same reason -- a quoted directive must NOT count, so
lines beginning with '>' are DROPPED, not unquoted.
* A REASON IS MANDATORY. A bare directive records that a review was waived but not why,
which is the difference between an audit trail and a rubber stamp.
* STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover
commit B. Any candidate created at or before the head commit's timestamp is dropped --
the same semantics Gitea's own `dismiss_stale_approvals` applies to reviews. Push new
commits and you must re-override.
* THE LATEST CANDIDATE WINS, so a second override comment supersedes the first rather than
both being live.
Output: one TSV line (login, created_at, reason) on stdout, or nothing at all when there is no
valid candidate. Never exits non-zero for "no override" -- absence is a normal answer, and the
caller must not be able to confuse it with a parse crash.
Input, all via env so nothing lands in `ps`:
COMMENTS_JSON the Gitea /issues/{n}/comments array
HEAD_TIME ISO-8601 timestamp of the PR head commit
DIRECTIVE the override directive, e.g. /approve-override
"""
import json
import os
import re
import sys
from datetime import datetime, timezone
def parse_ts(value):
"""Gitea emits RFC3339. Return an aware datetime, or None if it is unparseable.
Unparseable is NOT 'treat as fresh': the caller drops such a candidate, because a
timestamp we cannot read is a staleness check we cannot perform.
"""
if not value:
return None
text = str(value).strip()
# Python < 3.11 cannot read a trailing 'Z' in fromisoformat.
if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def extract_reason(body, directive):
"""Return the reason from the first line that STARTS with the directive, else None."""
pattern = re.compile(
r"^[ \t]*" + re.escape(directive) + r"(?=[ \t:]|$)[ \t:]*(.*)$"
)
for raw in (body or "").splitlines():
# A quoted line is somebody repeating an override, not casting one.
if raw.lstrip().startswith(">"):
continue
match = pattern.match(raw)
if match:
reason = match.group(1).strip()
return reason or None
return None
def main():
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
try:
comments = json.loads(os.environ.get("COMMENTS_JSON") or "[]")
except (ValueError, TypeError):
# The caller validated this JSON already; if it is broken here, say so loudly on
# stderr and emit no candidate. Silence on stdout means "no override".
print("approval-override: COMMENTS_JSON is not valid JSON", file=sys.stderr)
return 0
if not isinstance(comments, list):
print("approval-override: COMMENTS_JSON is not a list", file=sys.stderr)
return 0
candidates = []
for comment in comments:
if not isinstance(comment, dict):
continue
login = ((comment.get("user") or {}).get("login") or "").strip()
reason = extract_reason(comment.get("body"), directive)
if not login or not reason:
continue
created_raw = comment.get("created_at") or comment.get("updated_at") or ""
created = parse_ts(created_raw)
if created is None:
print(
"approval-override: dropping override by '%s' — unreadable timestamp %r"
% (login, created_raw),
file=sys.stderr,
)
continue
# An override must post-date the commit it covers.
if head_time is not None and created <= head_time:
print(
"approval-override: dropping STALE override by '%s' (%s) — not newer than "
"head commit (%s). Re-comment to override the current commit."
% (login, created.isoformat(), head_time.isoformat()),
file=sys.stderr,
)
continue
candidates.append((created, login, reason, created_raw))
if not candidates:
return 0
# Latest wins: a later override supersedes an earlier one.
created, login, reason, created_raw = max(candidates, key=lambda c: c[0])
# Keep the reason on one line — this becomes a TSV field.
reason = " ".join(reason.split())
print("%s\t%s\t%s" % (login, created_raw, reason))
return 0
if __name__ == "__main__":
sys.exit(main())