#!/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 SETTINGS_API_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. # # NOT `GITEA_SETTINGS_TOKEN`. This comment named that until 2026-09-29 and it instructed the # reader to do something IMPOSSIBLE: Gitea reserves the `GITEA_` prefix for secret names and # rejects the PUT with HTTP 400 "invalid secret name". That is not a style rule — it is why the # detector sat inert for hours while a token existed and no secret did. The env var below still # accepts GITEA_SETTINGS_TOKEN, but only for a LOCAL run: it can never be the CI wiring, because # the secret that would feed it cannot be created at all. 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}" # SETTINGS_API_TOKEN, not GITEA_SETTINGS_TOKEN. Gitea RESERVES the `GITEA_` prefix for # secret names and rejects a PUT of one with HTTP 400 "invalid secret name" — the same way # GitHub reserves `GITHUB_`. The original name could therefore never be stored, and because # infra/gitea/set-ci-token.sh sent that PUT to /dev/null and printed "stored" unconditionally, # it looked like it had worked. A token was minted, no secret existed, and this stage would # have skipped forever. GITEA_SETTINGS_TOKEN is still honoured for a local run. TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}" [ -f "$DECL" ] || die "declaration missing: ${DECL}" require_tool jq require_tool curl if [ -z "$TOKEN" ]; then skip "SETTINGS_API_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 "<>" 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"