feat(ci): the pipeline is the approver; humans approve changes to the pipeline
Implements the operator intent: "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." Adds ci/approval-policy.sh as a gate stage. A green gate becomes sufficient for an ordinary change; the one exception is structural rather than a matter of taste: 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 holds for the admission policies, the Flux wiring that decides what reconciles, the SOPS recipients and the RBAC. Those paths -- and only those -- still require an APPROVED review from someone other than the author. The list is data with a reason per entry (infra/gitea/approval-policy.json) and is itself a sensitive path, so the policy cannot be widened or narrowed without a human. FAIL-CLOSED, and deliberately the opposite of gitea-settings. There, a missing credential must not block every PR -- that is a lock-out. Here, a missing credential would let an unreviewed change to the gate merge, so "cannot evaluate" is a BLOCK. Verified against real PRs rather than fixtures: PR #16 (sensitive paths, approved by jakman85, author gitea-admin) -> PASS PR #6 (.gitea/workflows/gate.yml, ZERO reviews) -> BLOCK, naming the file push event (not a PR) -> N/A, exit 0 bogus token -> BLOCK, not skip Also caught before shipping: the first draft passed the PR file list with a second stdin redirect after a heredoc, which silently wins and leaves the script body unread. Running it found that; reading it would not have. required_approvals is NOT changed here. Flipping it live must happen together with the declaration in expected-branch-protection.json, or gitea-settings correctly fails on the drift -- that is a separate, deliberate change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
20ea70d221
commit
0c6949ed98
6 files changed
+189
-1
No files matched your search
@@ -131,6 +131,10 @@ jobs:
|
||||
# merging. Green here means the token resolved and the live Gitea matched the
|
||||
# declaration in infra/gitea/expected-branch-protection.json.
|
||||
REQUIRE_GITEA_SETTINGS: "true"
|
||||
# ci/approval-policy.sh needs to know WHICH PR it is looking at, so it can read that
|
||||
# PR's changed files and its reviews. Absent on a push event, where the stage is N/A.
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
||||
|
||||
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
||||
|
||||
@@ -14,7 +14,28 @@ There is no other path. Specifically:
|
||||
|
||||
- **Do not `git push origin main`.**
|
||||
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
||||
- **Do not merge your own PR.** A human approves and clicks merge.
|
||||
- **Do not merge your own PR.** See the approval model below — and either way, the merge is
|
||||
someone else's action, never yours.
|
||||
|
||||
### Who approves
|
||||
|
||||
**The CI/CD pipeline is the approver.** A green gate is sufficient for an ordinary change; the
|
||||
branch requires no human approval for one. That is a deliberate transfer of authority, on the
|
||||
operator's stated principle: *if the pipeline is not good enough to be trusted for this, it is not
|
||||
good enough to do its job and needs improving* — so a weak gate is a bug to fix, not a reason to
|
||||
add a human.
|
||||
|
||||
**One exception, enforced by `ci/approval-policy.sh`:** a change to the pipeline itself, the
|
||||
admission policies, the Flux wiring, the SOPS recipients, the RBAC, or this file needs an
|
||||
approving review from someone who is not its author. The reason is narrow and structural — **a
|
||||
change to the gate cannot be gated by the gate.** A PR that weakens a check would be approved by
|
||||
the weakened check. Everything else merges on green.
|
||||
|
||||
The path list is data, with a reason per entry: `infra/gitea/approval-policy.json`. It is itself a
|
||||
sensitive path, so the policy cannot be widened or narrowed without a human.
|
||||
|
||||
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
|
||||
missing credential is a lock-out, here a skip would be a bypass.
|
||||
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
||||
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ render-golden: ## render==golden: canonical render reproduces committed apps/ (n
|
||||
l1: ## L1 rendering checks (no cluster; live now)
|
||||
@"$(CI_DIR)l1.sh"
|
||||
|
||||
approval-policy: ## CI is the approver; sensitive paths still need a human (needs PR_NUMBER + token)
|
||||
@./approval-policy.sh
|
||||
|
||||
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
||||
@./gitea-settings.sh
|
||||
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/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 - <<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
|
||||
|
||||
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
|
||||
+15
@@ -112,6 +112,7 @@ run_stage lint "${CI_DIR}/lint.sh"
|
||||
run_stage l0 "${CI_DIR}/l0.sh"
|
||||
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
||||
run_stage l1 "${CI_DIR}/l1.sh"
|
||||
run_stage approval-policy "${CI_DIR}/approval-policy.sh"
|
||||
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
||||
run_stage conformance "${CI_DIR}/conformance.sh"
|
||||
|
||||
@@ -189,6 +190,20 @@ case "${OUTCOME[spec-immutability]}" in
|
||||
pass) ok "spec-immutability: green" ;;
|
||||
esac
|
||||
|
||||
# approval-policy: the branch requires NO human approvals (required_approvals: 0) -- the GATE
|
||||
# is the approver, per operator intent. This stage is the single exception: a change to the
|
||||
# pipeline itself, the admission policies, the Flux wiring, the SOPS recipients or the RBAC
|
||||
# cannot honestly be approved BY the thing it changes, so those paths still need a human.
|
||||
#
|
||||
# It never SKIPS -- it exits 0 or 1 only, and blocks when it cannot evaluate. That is the
|
||||
# opposite of gitea-settings' tolerance and it is deliberate: there, a missing credential is a
|
||||
# lock-out; here, a missing credential would let an unreviewed change to the gate merge.
|
||||
case "${OUTCOME[approval-policy]}" in
|
||||
fail) err "approval-policy: a sensitive change lacks a non-author approval -> blocks merge"; BLOCK=1 ;;
|
||||
skip) err "approval-policy: did not run -- it is fail-closed by design, so treat as a block"; BLOCK=1 ;;
|
||||
pass) ok "approval-policy: green" ;;
|
||||
esac
|
||||
|
||||
# gitea-settings: the ONLY check that can see Gitea's own database (branch protection +
|
||||
# runner registration). A FAIL is real drift and always blocks -- that is the whole point.
|
||||
# A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
|
||||
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.",
|
||||
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
|
||||
"sensitive_paths": [
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"conformance/**",
|
||||
"clusters/**",
|
||||
"bootstrap/**",
|
||||
"infra/gitea/**",
|
||||
"platform/kyverno/**",
|
||||
"platform-config/kustomization.yaml",
|
||||
".sops.yaml",
|
||||
"**/*.sops.yaml",
|
||||
"**/rbac.yaml",
|
||||
"CLAUDE.md"
|
||||
],
|
||||
"_why_each": {
|
||||
"ci/**, .gitea/**": "the pipeline itself -- the thing doing the approving",
|
||||
"conformance/**": "the tests and the harness; changing them changes what green MEANS",
|
||||
"clusters/**, bootstrap/**": "Flux wiring -- what reconciles, in what order, with what substitution",
|
||||
"infra/gitea/**, platform-config/kustomization.yaml": "branch protection, the drift detector, and the list that decides which admission policies are actually applied",
|
||||
"platform/kyverno/**": "the admission policy engine",
|
||||
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
|
||||
"CLAUDE.md": "the delivery contract agents follow"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user