Every check in this repo reads the repository, the rendered manifests, or the cluster.
NONE could see Gitea's own database, and that is where branch protection and runner
registration live. Three failures came out of that blind spot on 2026-09-29, all the same
shape -- a setting that READ as enforced while not being, with nothing able to notice:
* main had NO branch protection for ~a month while DECISIONS-LOG recorded it as a
required status check;
* ZERO Actions runners were registered, so PR #1 -- the first PR this repo ever had --
sat pending for 30.6 minutes with nothing to execute it;
* after enable_status_check and required_approvals were turned on they STILL did not
bite, because block_admin_merge_override was false: PRs #5-#9 merged with zero
approvals recorded against required_approvals: 1.
infra/gitea/*.sh ASSERT the desired state. Nothing DETECTED drift from it. This is the
detector.
ci/gitea-settings.sh runs as a merge-gate stage: it diffs the live rule at
/branch_protections/main against infra/gitea/expected-branch-protection.json and asserts at
least one runner is online. It reads the RULE object, not /branches/main -- the branch view
does not expose block_admin_merge_override and showed all-green while the rule was
overridable by the only account that merges.
Verified by test, not assumed:
no token -> exit 3, skips loudly, does NOT block
live == declaration -> exit 0
injected drift -> exit 1, blocks, naming both the drifted key and the runner count
The skip is DELIBERATELY not in gate.sh's strict-skips loop. Making an absent credential
block every PR is precisely the lock-yourself-out failure this repo keeps walking into, so
a skip denies a PASS (exit 3) and says loudly what is unverified. REQUIRE_GITEA_SETTINGS=true
upgrades it to a block once the token is wired.
gate.yml's `|| [ "$?" -eq 3 ]` tolerance now covers a second stage. That widening is
declared in the file rather than left to be discovered, and it narrows back the moment
REQUIRE_GITEA_SETTINGS is set.
infra/gitea/set-ci-token.sh mints the token and stores it as the repo Actions secret without
ever printing the value.
UNTIL THAT TOKEN EXISTS THIS STAGE SKIPS, AND A SKIPPED DETECTOR DETECTS NOTHING.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
121 lines
5.7 KiB
Bash
Executable File
121 lines
5.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ci/gitea-settings.sh — Stage: the Gitea platform settings that gate this repo still
|
|
# match what git declares.
|
|
#
|
|
# WHY THIS STAGE EXISTS AT ALL
|
|
# ----------------------------
|
|
# Every other check in this repo reads the repository, the rendered manifests, or the
|
|
# cluster. NONE of them can see Gitea's own database — and that is where branch
|
|
# protection and runner registration live. On 2026-09-29 that blind spot was measured:
|
|
#
|
|
# * `main` had NO branch protection, and
|
|
# * ZERO Actions runners were registered,
|
|
#
|
|
# for roughly a month, while DECISIONS-LOG.md recorded both as working and every agent
|
|
# and human in the loop repeated that claim because the repository said so. PR #1 — the
|
|
# first pull request this repo ever had — sat `pending` for 30.6 minutes with nothing to
|
|
# execute it. Later the same day `enable_status_check` and `required_approvals` were turned
|
|
# on and STILL did not bite, because `block_admin_merge_override` was false; PRs #5-#9
|
|
# merged with zero approvals recorded against `required_approvals: 1`.
|
|
#
|
|
# Three separate failures, one shape: a setting that READ as enforced while not being, with
|
|
# no mechanism anywhere that could notice. Scripts that ASSERT the desired state
|
|
# (infra/gitea/*.sh) do not detect drift from it. This stage is the detector.
|
|
#
|
|
# WHAT IT ASSERTS
|
|
# 1. every key in infra/gitea/expected-branch-protection.json `.protection` matches the
|
|
# LIVE rule at /repos/{repo}/branch_protections/{branch}, exactly;
|
|
# 2. at least `.runners_online_min` Actions runners report status "online".
|
|
#
|
|
# (2) is not decoration. A required status check with no runner is the deadlock this repo
|
|
# nearly walked into: every PR pends forever, including the one that would fix it.
|
|
#
|
|
# READ THE RULE OBJECT, NOT THE BRANCH VIEW. /repos/{repo}/branches/{branch} reports
|
|
# `protected`, `enable_status_check` and `required_approvals` but NOT
|
|
# `block_admin_merge_override` — it showed all-green while the rule was overridable by the
|
|
# only account that merges. This stage queries /branch_protections/ deliberately.
|
|
#
|
|
# EXIT CODES (the gate contract)
|
|
# 0 live Gitea matches the declaration
|
|
# 1 DIVERGENCE — something drifted, or was never applied. Blocks the merge.
|
|
# 3 SKIP — no token, or Gitea unreachable. Never a pass; see gate.sh, which treats this
|
|
# skip like spec-immutability's (UNDETERMINED, exit 3) rather than a hard block, so
|
|
# that an unwired credential cannot lock the repository.
|
|
#
|
|
# TO WIRE IT: create a Gitea repo secret GITEA_SETTINGS_TOKEN (a token that can read repo
|
|
# settings) — infra/gitea/set-ci-token.sh does it. Until then this stage skips LOUDLY and
|
|
# the drift it exists to catch is still undetected.
|
|
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: Gitea platform settings match the declaration"
|
|
|
|
DECL="${REPO_ROOT}/infra/gitea/expected-branch-protection.json"
|
|
GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}"
|
|
GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}"
|
|
TOKEN="${GITEA_SETTINGS_TOKEN:-}"
|
|
|
|
[ -f "$DECL" ] || die "declaration missing: ${DECL}"
|
|
require_tool jq
|
|
require_tool curl
|
|
|
|
if [ -z "$TOKEN" ]; then
|
|
skip "GITEA_SETTINGS_TOKEN is not set — cannot read the live Gitea rule."
|
|
skip " Branch protection and runner registration are therefore UNVERIFIED. They were"
|
|
skip " wrong, unnoticed, for a month (2026-09-29). Wire it: infra/gitea/set-ci-token.sh"
|
|
summary "- gitea-settings: **SKIPPED — no token** (protection + runners UNVERIFIED; not a pass)"
|
|
exit 3
|
|
fi
|
|
|
|
api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
|
|
|
BRANCH="$(jq -r '.branch' "$DECL")"
|
|
LIVE="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/branch_protections/${BRANCH}" || true)"
|
|
if [ -z "$LIVE" ] || ! jq -e 'type == "object" and has("branch_name")' >/dev/null 2>&1 <<<"$LIVE"; then
|
|
skip "could not read the branch-protection rule from ${GITEA_URL} (unreachable, or the token"
|
|
skip " lacks permission). Not treating this as a pass."
|
|
summary "- gitea-settings: **SKIPPED — Gitea unreadable** (not a pass)"
|
|
exit 3
|
|
fi
|
|
|
|
RC=0
|
|
|
|
# --- 1. every declared protection key must match the live rule, exactly ---------------
|
|
while IFS= read -r key; do
|
|
want="$(jq -Sc --arg k "$key" '.protection[$k]' "$DECL")"
|
|
got="$(jq -Sc --arg k "$key" 'if has($k) then .[$k] else "<<ABSENT>>" end' <<<"$LIVE")"
|
|
if [ "$want" = "$got" ]; then
|
|
ok " ${key} = ${got}"
|
|
else
|
|
err " ${key}: DECLARED ${want} but LIVE is ${got}"
|
|
RC=1
|
|
fi
|
|
done < <(jq -r '.protection | keys[]' "$DECL")
|
|
|
|
# --- 2. a runner must actually be online ----------------------------------------------
|
|
MIN="$(jq -r '.runners_online_min // 1' "$DECL")"
|
|
RUNNERS="$(api "${GITEA_URL}/api/v1/admin/actions/runners" || true)"
|
|
N="$(jq -r '[(.runners//[])[]|select(.status=="online")]|length' <<<"${RUNNERS:-{\}}" 2>/dev/null || echo 0)"
|
|
if [ "${N:-0}" -ge "$MIN" ]; then
|
|
ok " runners online: ${N} (>= ${MIN})"
|
|
else
|
|
err " runners online: ${N}, DECLARED minimum ${MIN}."
|
|
err " A required status check with no runner leaves every PR pending forever —"
|
|
err " including the PR that would fix it. This is the 2026-09-29 outage condition."
|
|
RC=1
|
|
fi
|
|
|
|
if [ "$RC" -eq 0 ]; then
|
|
ok "Gitea settings match the declaration."
|
|
summary "- gitea-settings: **green** (branch protection + runners match \`infra/gitea/expected-branch-protection.json\`)"
|
|
else
|
|
err "Gitea has DRIFTED from infra/gitea/expected-branch-protection.json."
|
|
err " Re-assert it (infra/gitea/require-gate.sh, block-admin-override.sh), or if the live"
|
|
err " state is what you now want, change the DECLARATION in the same commit — deliberately."
|
|
summary "- gitea-settings: **DRIFT** — live Gitea does not match the declaration; blocks merge"
|
|
fi
|
|
exit "$RC"
|