`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]>
249 lines
14 KiB
Bash
Executable File
249 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ci/approval-policy.sh — the CI/CD pipeline is the approver, EXCEPT for changes that alter
|
|
# what the pipeline verifies or the security posture it rests on.
|
|
#
|
|
# THE POLICY, and why it is shaped this way.
|
|
#
|
|
# Operator intent (2026-09-29): "CI/CD pipeline is the end-all-be-all for approval. If the CI
|
|
# pipeline is not good enough to be trusted for this, then it is not good enough to do its
|
|
# intended job and needs to be improved. Manual approvers should only be for architecturally or
|
|
# security relevant changes -- which is essentially the CI pipeline itself and kyverno policies."
|
|
#
|
|
# So `required_approvals` on the branch drops to 0 and the GATE decides. That is a real transfer
|
|
# of authority, and it has exactly one hole: **a change to the gate cannot be gated by the gate.**
|
|
# A PR that weakens a check would be approved by the weakened check. The same applies to the
|
|
# admission policies, the Flux wiring that decides what reconciles, the SOPS recipients, and the
|
|
# RBAC. Those are the "architecturally or security relevant" set, and this stage is what still
|
|
# requires a human for them.
|
|
#
|
|
# The list is data, not code: infra/gitea/approval-policy.json, with a stated reason per entry so
|
|
# it stays reviewable. Changing that list is itself a sensitive change (infra/gitea/**), so the
|
|
# policy cannot be widened without a human.
|
|
#
|
|
# FAIL-CLOSED, DELIBERATELY DIFFERENT FROM gitea-settings.sh. That stage skips when its token is
|
|
# missing, because an unwired credential blocking every PR is a lock-out. Here the reverse is
|
|
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
|
|
# BYPASS, not a gap. So: cannot determine -> BLOCK.
|
|
#
|
|
# THE ADMIN OVERRIDE (added 2026-10-01), and the lock-out it clears.
|
|
#
|
|
# The rule above was UNSATISFIABLE for the one human who runs this repo. Gitea refuses to let a
|
|
# pull request's author approve their own PR, and `jakman85` is the only collaborator — so "an
|
|
# APPROVED review from someone who is not its author" is a condition the sole administrator can
|
|
# never meet. Every sensitive-path PR they opened was permanently unmergeable, INCLUDING the PR
|
|
# that would fix it. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, blocked.
|
|
#
|
|
# So an administrator may waive the review by COMMENTING on the PR:
|
|
#
|
|
# /approve-override <reason>
|
|
#
|
|
# Authority comes from the LIVE Gitea permission API, never from the comment — the commenter must
|
|
# hold a permission listed in `.override.authorized_permissions` (admin/owner) at the moment the
|
|
# gate runs, so an old comment from a since-demoted account is worth nothing. The directive must
|
|
# start a line, a reason is mandatory, and the comment must POST-DATE the head commit: an override
|
|
# earned at commit A does not cover commit B. Choosing the candidate comment is the job of the
|
|
# pure, offline, unit-tested ci/approval-override.py; AUTHORISING it is the job of this stage,
|
|
# which fails closed.
|
|
#
|
|
# WHAT THIS COSTS, STATED PLAINLY. For an administrator, "a change to the gate cannot be gated by
|
|
# the gate" stops being structural and becomes advisory. That is the unavoidable consequence of
|
|
# there being one human; the alternative is a repository its owner cannot change. What is bought
|
|
# back is a RECORD — the waiver is an explicit act, attributed to an account, carrying a stated
|
|
# reason, in the PR history and in this stage's output. A silent bypass keyed on "is the author an
|
|
# admin?" would have been less code and is exactly the fail-open shape this repo keeps finding.
|
|
#
|
|
# AND THE PART THAT IS NOT COMFORTABLE. Authorisation is by permission ALONE, with no allowlist of
|
|
# human accounts (operator decision, 2026-10-01, taken with this consequence on the table). The
|
|
# AGENT account `gitea-admin` also reports permission `owner` here, so an agent can clear the
|
|
# sensitive-path review on its own PR by posting the directive — and agents author most PRs in
|
|
# this repo, so that is the common case, not an edge case. To narrow it without touching this
|
|
# logic, add an `override_admins` list to infra/gitea/approval-policy.json: the check below
|
|
# already requires membership whenever that key is present.
|
|
#
|
|
# EXIT CODES
|
|
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and
|
|
# either a non-author APPROVED review or a valid admin override is present
|
|
# 1 sensitive paths touched with neither, OR the policy could not be evaluated
|
|
set -euo pipefail
|
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
|
# shellcheck disable=SC1091
|
|
source "${CI_DIR}/lib.sh"
|
|
|
|
log "Stage: approval policy (CI approves; humans approve changes to CI)"
|
|
|
|
DECL="${REPO_ROOT}/infra/gitea/approval-policy.json"
|
|
[ -f "$DECL" ] || die "approval policy declaration missing: ${DECL}"
|
|
require_tool jq
|
|
require_tool python3
|
|
|
|
# Not a PR -> nothing to gate. A push to main is already merged, and direct pushes are refused
|
|
# by branch protection, so this is genuinely not-applicable rather than a skipped check.
|
|
EVENT="${GITHUB_EVENT_NAME:-${EVENT_NAME:-}}"
|
|
PR="${PR_NUMBER:-}"
|
|
if [ "$EVENT" != "pull_request" ] || [ -z "$PR" ] || [ "$PR" = "null" ]; then
|
|
ok "not a pull_request event (event='${EVENT:-none}') — approval policy N/A"
|
|
exit 0
|
|
fi
|
|
|
|
GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}"
|
|
GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}"
|
|
TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
|
|
if [ -z "$TOKEN" ]; then
|
|
err "no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews."
|
|
err " BLOCKING. A skip here would let an unreviewed change to the gate itself merge."
|
|
summary "- approval-policy: **BLOCKED — cannot evaluate** (no token; a skip here is a bypass)"
|
|
exit 1
|
|
fi
|
|
api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
|
|
|
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
|
|
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
|
|
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
|
|
# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review
|
|
# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it.
|
|
# Verified against this Gitea (1.24.3) rather than assumed.
|
|
COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)"
|
|
for v in FILES_JSON PR_JSON REVIEWS_JSON COMMENTS_JSON; do
|
|
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
|
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
|
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
|
exit 1; }
|
|
done
|
|
|
|
AUTHOR="$(jq -r '.user.login // empty' <<<"$PR_JSON")"
|
|
[ -n "$AUTHOR" ] || { err "could not determine PR author — BLOCKING"; exit 1; }
|
|
|
|
# NOTE: the PR file list goes in via an ENV VAR, not a second stdin redirect. `python3 - <<EOF`
|
|
# already consumes stdin for the script; adding `<<<"$json"` silently wins and the script body is
|
|
# never read. Caught before shipping, by running it.
|
|
SENSITIVE="$(FILES_JSON="$FILES_JSON" DECL="$DECL" python3 -c '
|
|
import json, os, fnmatch
|
|
decl = json.load(open(os.environ["DECL"]))
|
|
files = [f.get("filename", "") for f in json.loads(os.environ["FILES_JSON"])]
|
|
pats = decl["sensitive_paths"]
|
|
hits = sorted({f for f in files for p in pats
|
|
if fnmatch.fnmatch(f, p) or (p.endswith("/**") and f.startswith(p[:-2]))})
|
|
print("\n".join(hits))
|
|
')"
|
|
|
|
if [ -z "$SENSITIVE" ]; then
|
|
ok "no architecturally/security-relevant paths touched — the GATE is the approver."
|
|
summary "- approval-policy: **green** (CI approves; no sensitive paths touched)"
|
|
exit 0
|
|
fi
|
|
|
|
echo " sensitive paths touched:" >&2
|
|
printf ' %s\n' $SENSITIVE >&2
|
|
|
|
APPROVER="$(jq -r --arg a "$AUTHOR" \
|
|
'[.[] | select(.state == "APPROVED") | select(.user.login != $a) | .user.login] | first // empty' \
|
|
<<<"$REVIEWS_JSON")"
|
|
|
|
if [ -n "$APPROVER" ]; then
|
|
ok "approved by '${APPROVER}' (author '${AUTHOR}') — sensitive change is human-reviewed."
|
|
summary "- approval-policy: **green** (sensitive paths approved by \`${APPROVER}\`)"
|
|
exit 0
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────────────────
|
|
# No non-author approval exists. Before refusing, look for an administrator's explicit
|
|
# override. See the header for why this hatch exists and what it costs.
|
|
# ─────────────────────────────────────────────────────────────────────────────────────────
|
|
OVERRIDE_ENABLED="$(jq -r '.override.enabled // false' "$DECL")"
|
|
DIRECTIVE="$(jq -r '.override.comment_directive // "/approve-override"' "$DECL")"
|
|
HEAD_SHA="$(jq -r '.head.sha // empty' <<<"$PR_JSON")"
|
|
|
|
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
|
# The head commit's timestamp anchors staleness. Read it from GITEA, not from the local
|
|
# tree: CI checks out a merge commit, whose date is not the head commit's date.
|
|
HEAD_TIME=""
|
|
if [ -n "$HEAD_SHA" ]; then
|
|
HEAD_COMMIT_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/git/commits/${HEAD_SHA}" || true)"
|
|
HEAD_TIME="$(jq -r '.commit.committer.date // .created // empty' <<<"${HEAD_COMMIT_JSON:-null}" 2>/dev/null || true)"
|
|
fi
|
|
if [ -z "$HEAD_TIME" ]; then
|
|
err "could not read the head commit's timestamp for PR #${PR} — BLOCKING."
|
|
err " Without it an override cannot be aged, and an unaged override is one that silently"
|
|
err " covers commits nobody waived review on."
|
|
summary "- approval-policy: **BLOCKED — head commit timestamp unreadable** (cannot age an override)"
|
|
exit 1
|
|
fi
|
|
|
|
# Pure, offline, unit-tested selection: which comment, if any, is a live override?
|
|
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \
|
|
python3 "${CI_DIR}/approval-override.py")" || {
|
|
err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override."
|
|
summary "- approval-policy: **BLOCKED — override parser failed**"
|
|
exit 1; }
|
|
|
|
if [ -n "$OVERRIDE_TSV" ]; then
|
|
OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")"
|
|
OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")"
|
|
OV_WHY="$(cut -f3- <<<"$OVERRIDE_TSV")"
|
|
|
|
# AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git.
|
|
PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)"
|
|
OV_PERM="$(jq -r '.permission // empty' <<<"${PERM_JSON:-null}" 2>/dev/null || true)"
|
|
if [ -z "$OV_PERM" ]; then
|
|
err "'${OV_USER}' posted ${DIRECTIVE}, but their permission on ${GITEA_REPO} could not be"
|
|
err " read — BLOCKING. An override whose authority is unverified is not an override."
|
|
summary "- approval-policy: **BLOCKED — cannot verify \`${OV_USER}\`'s permission**"
|
|
exit 1
|
|
fi
|
|
|
|
# Authorised when the LIVE permission is one of the declared ones and — only if an
|
|
# `override_admins` allowlist exists at all — the account is on it. No allowlist means
|
|
# permission alone, which is the operator's standing decision; adding the key narrows
|
|
# this with no change to any code.
|
|
AUTHORISED="$(DECL="$DECL" OV_PERM="$OV_PERM" OV_USER="$OV_USER" python3 -c '
|
|
import json, os
|
|
d = json.load(open(os.environ["DECL"])).get("override", {})
|
|
perms = d.get("authorized_permissions") or ["admin", "owner"]
|
|
allow = d.get("override_admins")
|
|
ok_perm = os.environ["OV_PERM"] in perms
|
|
ok_list = True if allow is None else os.environ["OV_USER"] in allow
|
|
print("yes" if (ok_perm and ok_list) else "no")
|
|
')"
|
|
|
|
if [ "$AUTHORISED" = "yes" ]; then
|
|
if [ "$OV_USER" = "$AUTHOR" ]; then
|
|
warn "ADMIN OVERRIDE — SELF-WAIVED. '${OV_USER}' authored this PR and waived the"
|
|
warn " non-author review requirement on their own change. Permitted (permission"
|
|
warn " '${OV_PERM}'), deliberate, and shouted about here precisely because it is not free."
|
|
else
|
|
warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'."
|
|
fi
|
|
warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})"
|
|
warn " reason: ${OV_WHY}"
|
|
warn " review waived on: $(printf '%s ' $SENSITIVE)"
|
|
ok "sensitive paths cleared by administrator override — this stage does not block."
|
|
SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**"
|
|
summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`${SELFNOTE})"
|
|
summary " - reason: ${OV_WHY}"
|
|
summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)"
|
|
exit 0
|
|
fi
|
|
|
|
err "'${OV_USER}' posted ${DIRECTIVE} but is NOT authorised to override: live permission is"
|
|
err " '${OV_PERM}'. The authorised set is declared in infra/gitea/approval-policy.json."
|
|
summary "- approval-policy: **BLOCKED — \`${OV_USER}\` may not override** (permission \`${OV_PERM}\`)"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
|
|
err " review from anyone other than its author ('${AUTHOR}')."
|
|
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
|
|
err " honestly approve for itself. Get a human review, or move the change out of:"
|
|
printf ' %s\n' $SENSITIVE >&2
|
|
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
|
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
|
err " Or, if you hold admin/owner on this repository, comment on the PR:"
|
|
err " ${DIRECTIVE} <reason>"
|
|
err " The reason is mandatory, the directive must START a line, and the comment must be"
|
|
err " NEWER than head commit ${HEAD_SHA:0:8} — push again and you must override again."
|
|
fi
|
|
summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**"
|
|
exit 1
|