The drift detector merged in #11 was inert and would have stayed inert silently. WHAT HAPPENED. infra/gitea/set-ci-token.sh minted a token and PUT it to the repo Actions secret GITEA_SETTINGS_TOKEN. Gitea RESERVES the `GITEA_` prefix for secret names -- exactly as GitHub reserves `GITHUB_` -- and rejects that PUT with: HTTP 400 {"message":"invalid secret name"} The script sent the PUT to /dev/null and printed "==> stored." unconditionally, and `curl -sS` exits 0 on an HTTP error, so it reported success for a store that never happened. Measured afterwards: the token existed in the user's token list, and the repo's secret list was EMPTY. ci/gitea-settings.sh would have skipped on every CI run forever, and with REQUIRE_GITEA_SETTINGS=true it would instead have BLOCKED EVERY PR -- including the one fixing it. Three changes: * the secret is SETTINGS_API_TOKEN; ci/gitea-settings.sh reads it, keeping GITEA_SETTINGS_TOKEN as a local-run fallback; * set-ci-token.sh checks the PUT's HTTP status AND reads the secret back, refusing to claim success otherwise. That missing verification is the actual defect -- the wrong name was just what it hid; * gate.yml maps the secret and ENABLES REQUIRE_GITEA_SETTINGS. WHY ENABLING IT HERE IS SAFE: this PR's own CI run is the proof. If the stage were still skipping, REQUIRE turns that into a failure and this PR goes RED rather than merging. Green means the token resolved and live Gitea matched infra/gitea/expected-branch-protection.json. Verified locally before pushing: REQUIRE=true, token present -> MERGE GATE: PASS REQUIRE=true, no token -> gitea-settings SKIPPED -> BLOCKED (not a silent skip) The secret is already stored and read back as present, so CI has what it needs. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
127 lines
6.3 KiB
Bash
Executable File
127 lines
6.3 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}"
|
|
# 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 "<<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"
|