#!/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. # # EXIT CODES # 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a # non-author APPROVED review is present # 1 sensitive paths touched without such a review, 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)" for v in FILES_JSON PR_JSON REVIEWS_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 - <&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 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." summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**" exit 1