Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b31f97486 | ||
|
|
911ead5fbd | ||
|
|
4634b44cc7 | ||
|
|
e6f6e4febb |
No files matched your search
@@ -110,9 +110,22 @@ jobs:
|
|||||||
# UNVERIFIED and NAMED in the PASS headline, never faked green. GATE_STRICT_SKIPS + the
|
# UNVERIFIED and NAMED in the PASS headline, never faked green. GATE_STRICT_SKIPS + the
|
||||||
# tolerated exit 3 are explained in the file header; the tolerance covers exactly one
|
# tolerated exit 3 are explained in the file header; the tolerance covers exactly one
|
||||||
# thing, the unverifiable spec repo, and nothing else can produce a 3 here.
|
# thing, the unverifiable spec repo, and nothing else can produce a 3 here.
|
||||||
|
# GITEA_SETTINGS_TOKEN feeds ci/gitea-settings.sh, the only check that can see Gitea's
|
||||||
|
# OWN database (branch protection + runner registration) — the blind spot that let `main`
|
||||||
|
# sit unprotected with zero runners for a month while this repo recorded both as working.
|
||||||
|
# Referenced BY NAME only; create it with infra/gitea/set-ci-token.sh.
|
||||||
|
#
|
||||||
|
# WIDENED TOLERANCE, DECLARED: `|| [ "$?" -eq 3 ]` used to cover exactly one skip
|
||||||
|
# (spec-immutability). It now also covers gitea-settings skipping when that secret is
|
||||||
|
# absent. That is a deliberate, temporary weakening: making an unwired credential BLOCK
|
||||||
|
# every PR is the lock-yourself-out failure this repo keeps walking into. Once the secret
|
||||||
|
# exists and the stage has been SEEN green, set REQUIRE_GITEA_SETTINGS=true below and the
|
||||||
|
# tolerance narrows back to spec-immutability alone.
|
||||||
- name: Merge gate — ci/gate.sh (what `make -C ci gate` runs)
|
- name: Merge gate — ci/gate.sh (what `make -C ci gate` runs)
|
||||||
env:
|
env:
|
||||||
GATE_STRICT_SKIPS: "true"
|
GATE_STRICT_SKIPS: "true"
|
||||||
|
GITEA_SETTINGS_TOKEN: ${{ secrets.GITEA_SETTINGS_TOKEN }}
|
||||||
|
# REQUIRE_GITEA_SETTINGS: "true" # <- uncomment AFTER seeing gitea-settings green
|
||||||
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
||||||
|
|
||||||
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
||||||
|
|||||||
@@ -4964,3 +4964,148 @@ account rubber-stamping the human. That is ceremony, not review. Options: keep i
|
|||||||
the ceremony; drop `required_approvals` to 0 and rely on the status check, which is the gate
|
the ceremony; drop `required_approvals` to 0 and rely on the status check, which is the gate
|
||||||
that actually verifies something; or add a second human account. **This is a decision for the
|
that actually verifies something; or add a second human account. **This is a decision for the
|
||||||
operator, not a default to be chosen silently.**
|
operator, not a default to be chosen silently.**
|
||||||
|
|
||||||
|
## 2026-09-29 (end of session) — **PM HANDOFF.** Six PRs open; read this before touching anything.
|
||||||
|
|
||||||
|
The session that began "set a SOPS secret" ended having rebuilt the delivery pipeline. Plex is
|
||||||
|
claimed and scanning. `main` is genuinely protected and the gate genuinely enforces. What is
|
||||||
|
open is listed below with the reason each is not merged.
|
||||||
|
|
||||||
|
### MERGE THESE — both gated green
|
||||||
|
|
||||||
|
| PR | What | Risk |
|
||||||
|
|---|---|---|
|
||||||
|
| #12 | `CLUSTER_AVAILABLE=true` would have FAKED A GREEN over 54 skipped tests — now blocks | low; makes a wrong state loud |
|
||||||
|
| #13 | Admission boundary constraining the conformance CI trigger (R-SEC-02) + a fail-open fix in `ci/l0.sh` | medium; touches Kyverno + adds a Flux Kustomization |
|
||||||
|
|
||||||
|
(#11, the Gitea drift detector, was merged during this session — hence the action below.)
|
||||||
|
|
||||||
|
Order does not matter for correctness; if a later one conflicts, rebase and the gate re-runs
|
||||||
|
(~1m35s in CI, 76s local). **Each was gated in the pinned toolbox, not on a host.**
|
||||||
|
|
||||||
|
### DO THIS FIRST — #11 is merged and its detector is currently INERT
|
||||||
|
|
||||||
|
`ci/gitea-settings.sh` SKIPS until its token exists, and **a skipped detector detects nothing**:
|
||||||
|
|
||||||
|
./infra/gitea/set-ci-token.sh # mints + stores GITEA_SETTINGS_TOKEN, never prints it
|
||||||
|
|
||||||
|
Then open any PR and confirm the gate prints `gitea-settings: green`. ONLY THEN uncomment
|
||||||
|
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml`, so a future missing token blocks
|
||||||
|
instead of skipping. Doing that before seeing it green locks the repo.
|
||||||
|
|
||||||
|
Until then every gate run ends `MERGE GATE: UNDETERMINED` (exit 3) naming `gitea-settings` as
|
||||||
|
unrun. That is BY DESIGN and `gate.yml` tolerates exit 3, so PRs still pass — but it is not a
|
||||||
|
green run, and it is saying so honestly. The token is what turns it green.
|
||||||
|
|
||||||
|
### A DIFFERENT COMPUTER NEEDS THREE THINGS THAT ARE NOT IN GIT
|
||||||
|
|
||||||
|
This is the part that will silently waste an hour otherwise. None of these are in the repo, by
|
||||||
|
design, and without them the toolbox and the cluster are unreachable:
|
||||||
|
|
||||||
|
1. **`~/.config/sops/age/keys.txt`** — the age PRIVATE key. Without it NO `*.sops.yaml` can be
|
||||||
|
read or edited, and `sops` will fail in a way that looks like a broken file. Copy it from this
|
||||||
|
workstation; the public recipient in `.sops.yaml` is only half the pair.
|
||||||
|
2. **`infra/ansible/.cache/prod.kubeconfig`** — untracked, so it exists only in the main checkout
|
||||||
|
on this machine. Without it `dev.sh` refuses to start. In a git WORKTREE it is absent even
|
||||||
|
here: use `DEV_KUBECONFIG=/dev/null` for anything that needs no cluster (see `CLAUDE.md` §3).
|
||||||
|
3. **A Gitea credential** for `gitea.home.keithjackson.dev` (this session used the stored
|
||||||
|
`~/.git-credentials`). `git push` and every `infra/gitea/*.sh` script needs it.
|
||||||
|
|
||||||
|
### STATE, MEASURED — do not re-derive it from this log, re-measure it
|
||||||
|
|
||||||
|
`CLAUDE.md` §2b exists because this log was confidently wrong for a month. Verify before relying:
|
||||||
|
|
||||||
|
protected:true user_can_push:false enable_status_check:true
|
||||||
|
status_check_contexts:["gate / gate (pull_request)"] required_approvals:1
|
||||||
|
block_admin_merge_override:true runner incluster-dind: online gate ~1m35s
|
||||||
|
|
||||||
|
### NEXT WORK, in priority order
|
||||||
|
|
||||||
|
1. **Finish the L2/L3 wiring** now that the credential model is decided and #13 lands the
|
||||||
|
boundary. Still missing (`docs/plans/l2-l3-conformance-ci-wiring.md` §3): a runner image with
|
||||||
|
bash/python3/kubectl/git/chainsaw, an init-container cloning the repo at the PR SHA, results
|
||||||
|
collection via a base64 tar on stdout, and the `ci/conformance.sh` rewrite to drive the Job.
|
||||||
|
**Tighten the admission policy's image pattern to the literal digest when that image exists.**
|
||||||
|
2. **Do NOT set `vars.CLUSTER_AVAILABLE=true`** until step 1 is done. It does not enable L2/L3; it
|
||||||
|
only changes which way the stage fails (see #12).
|
||||||
|
3. The `gitea-settings` detector covers branch protection + runners. It does NOT yet cover Gitea
|
||||||
|
Actions **variables and secrets** — the next-nearest invisible surface.
|
||||||
|
4. `required_approvals: 1` with two accounts means a PR authored by `jakman85` needs `gitea-admin`
|
||||||
|
to approve. The operator holds ultimate authority and does not require that review; revisit if
|
||||||
|
it becomes friction.
|
||||||
|
|
||||||
|
### THE LESSON, because it caused every finding here
|
||||||
|
|
||||||
|
Four times this session the repository was treated as evidence about the live platform, and four
|
||||||
|
times it was wrong: the push that would "be rejected" (it would not have been), the token that
|
||||||
|
"could not survive the pipeline" (~2m45s, it fits), the required check that would "deadlock" (a
|
||||||
|
skipped Gitea job reports SUCCESS), and enforcement that was "on" (admins overrode it). Two
|
||||||
|
fail-opens in the gate itself were found the same way — `kyverno test` certifying policies that do
|
||||||
|
not load, and `CLUSTER_AVAILABLE` certifying 54 skipped tests as passed.
|
||||||
|
|
||||||
|
**Measure, then assert. `CLAUDE.md` §2b is the rule; this entry is the evidence for it.**
|
||||||
|
|
||||||
|
## 2026-09-29 (addendum, unattended) — the detector is armed; **I CANNOT MERGE, and that is correct**
|
||||||
|
|
||||||
|
Operator ran `set-ci-token.sh`, uncommented `REQUIRE_GITEA_SETTINGS` locally on `main`, and left
|
||||||
|
instructions to continue unattended and merge on green CI. Two things came out of that.
|
||||||
|
|
||||||
|
### 1. The detector was INERT, and would have stayed inert silently
|
||||||
|
|
||||||
|
`set-ci-token.sh` PUT the secret as `GITEA_SETTINGS_TOKEN`. **Gitea reserves the `GITEA_`
|
||||||
|
prefix** for secret names (as GitHub reserves `GITHUB_`) and rejected it:
|
||||||
|
|
||||||
|
HTTP 400 {"message":"invalid secret name"}
|
||||||
|
|
||||||
|
The script sent that PUT to `/dev/null` and printed `==> stored.` unconditionally; `curl -sS`
|
||||||
|
exits 0 on an HTTP error. **A token existed; no secret did.** So `ci/gitea-settings.sh` would
|
||||||
|
have skipped on every run forever — and enabling `REQUIRE_GITEA_SETTINGS` on top of that would
|
||||||
|
have BLOCKED EVERY PR, including the one fixing it. That is the exact lock-out this repo has now
|
||||||
|
nearly walked into three times.
|
||||||
|
|
||||||
|
Fixed in **PR #15**: the secret is `SETTINGS_API_TOKEN`, and `set-ci-token.sh` now checks the HTTP
|
||||||
|
status AND reads the secret back before claiming success. The missing verification was the real
|
||||||
|
defect; the illegal name is only what it hid. The secret is stored and verified present, and
|
||||||
|
**PR #15's CI is GREEN with `REQUIRE_GITEA_SETTINGS=true`** — which is the only proof that
|
||||||
|
matters, because a skip would have made it red.
|
||||||
|
|
||||||
|
The operator's local edit could not be pushed to `main` (protected). It is carried in PR #15.
|
||||||
|
|
||||||
|
### 2. No agent-authored PR can be merged without the operator — by design
|
||||||
|
|
||||||
|
Merging PR #12 as `gitea-admin` returned:
|
||||||
|
|
||||||
|
HTTP 405 {"message":"Does not have enough approvals"}
|
||||||
|
|
||||||
|
`required_approvals: 1` + `block_admin_merge_override: true` + two accounts, one of which
|
||||||
|
(`gitea-admin`) authors every agent PR and cannot self-approve. **So "merge on green CI" is not
|
||||||
|
something an agent can execute here.** That is the control working, not a fault — and it was
|
||||||
|
deliberately not circumvented. Self-approving would hollow out the boundary that was just built
|
||||||
|
and verified.
|
||||||
|
|
||||||
|
**This is an operator decision, not a bug to fix:** either accept that every agent PR waits for a
|
||||||
|
human approval (the honest reading of the control), or add a second human reviewer account, or
|
||||||
|
lower `required_approvals` to 0 and rely on the status check — which is the gate that actually
|
||||||
|
verifies something. Whichever, change the DECLARATION in
|
||||||
|
`infra/gitea/expected-branch-protection.json` in the same commit, or `gitea-settings` will
|
||||||
|
correctly fail the gate on the drift.
|
||||||
|
|
||||||
|
### Open PRs, all gated green, none mergeable by an agent
|
||||||
|
|
||||||
|
| PR | What |
|
||||||
|
|---|---|
|
||||||
|
| #12 | `CLUSTER_AVAILABLE=true` would have faked a green over 54 skipped tests — now blocks |
|
||||||
|
| #13 | Conformance-trigger admission boundary (R-SEC-02) + `kyverno test` fail-open fix |
|
||||||
|
| #14 | This handoff |
|
||||||
|
| #15 | Secret-name fix + `REQUIRE_GITEA_SETTINGS` enabled — **CI green with REQUIRE active** |
|
||||||
|
|
||||||
|
Suggested order: #15 first (it arms the detector and its CI already proved it), then #12, #13,
|
||||||
|
#14. If a later one conflicts, rebase; the gate re-runs in ~1m36s.
|
||||||
|
|
||||||
|
### Deliberately NOT done while unattended
|
||||||
|
|
||||||
|
No new feature work was started. Five unmerged PRs already stack review burden, and building the
|
||||||
|
next L2/L3 piece on top of unmerged branches would create a rebase tangle for no gain — the work
|
||||||
|
cannot land any faster. The next piece is specified and ready to start once these are merged:
|
||||||
|
`docs/plans/l2-l3-conformance-ci-wiring.md` §3, with §4's credential model now decided and its
|
||||||
|
boundary landed in #13.
|
||||||
@@ -32,6 +32,9 @@ render-golden: ## render==golden: canonical render reproduces committed apps/ (n
|
|||||||
l1: ## L1 rendering checks (no cluster; live now)
|
l1: ## L1 rendering checks (no cluster; live now)
|
||||||
@"$(CI_DIR)l1.sh"
|
@"$(CI_DIR)l1.sh"
|
||||||
|
|
||||||
|
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
||||||
|
@./gitea-settings.sh
|
||||||
|
|
||||||
conformance: ## L2/L3 in-cluster Chainsaw Job (SKIPS w/o CLUSTER_AVAILABLE=true)
|
conformance: ## L2/L3 in-cluster Chainsaw Job (SKIPS w/o CLUSTER_AVAILABLE=true)
|
||||||
@"$(CI_DIR)conformance.sh"
|
@"$(CI_DIR)conformance.sh"
|
||||||
|
|
||||||
|
|||||||
+26
@@ -112,6 +112,7 @@ run_stage lint "${CI_DIR}/lint.sh"
|
|||||||
run_stage l0 "${CI_DIR}/l0.sh"
|
run_stage l0 "${CI_DIR}/l0.sh"
|
||||||
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
||||||
run_stage l1 "${CI_DIR}/l1.sh"
|
run_stage l1 "${CI_DIR}/l1.sh"
|
||||||
|
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
||||||
run_stage conformance "${CI_DIR}/conformance.sh"
|
run_stage conformance "${CI_DIR}/conformance.sh"
|
||||||
|
|
||||||
echo >&2
|
echo >&2
|
||||||
@@ -188,6 +189,31 @@ case "${OUTCOME[spec-immutability]}" in
|
|||||||
pass) ok "spec-immutability: green" ;;
|
pass) ok "spec-immutability: green" ;;
|
||||||
esac
|
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
|
||||||
|
# in the change under test. It is deliberately NOT in the strict-skips loop below: making an
|
||||||
|
# absent secret block every PR is precisely the lock-yourself-out failure this repo keeps
|
||||||
|
# walking into (a required check with nothing able to satisfy it). So a skip denies a PASS
|
||||||
|
# (exit 3) and says loudly what is unverified. Set REQUIRE_GITEA_SETTINGS=true once the
|
||||||
|
# token is wired, and the skip becomes a block as it should.
|
||||||
|
case "${OUTCOME[gitea-settings]}" in
|
||||||
|
fail) err "gitea-settings: Gitea DRIFTED from infra/gitea/expected-branch-protection.json -> blocks merge"; BLOCK=1 ;;
|
||||||
|
skip)
|
||||||
|
if truthy "${REQUIRE_GITEA_SETTINGS:-false}"; then
|
||||||
|
err "gitea-settings: skipped and REQUIRE_GITEA_SETTINGS=true -> blocks merge"
|
||||||
|
summary "- gitea-settings: **SKIPPED** (no token) — blocks merge (REQUIRE_GITEA_SETTINGS=true)"
|
||||||
|
BLOCK=1
|
||||||
|
else
|
||||||
|
warn "gitea-settings: skipped (no GITEA_SETTINGS_TOKEN) — branch protection and runner"
|
||||||
|
warn " registration are UNVERIFIED. Both were silently wrong for a month. Wire the token"
|
||||||
|
warn " (infra/gitea/set-ci-token.sh), then set REQUIRE_GITEA_SETTINGS=true."
|
||||||
|
summary "- gitea-settings: **SKIPPED** (no token — protection/runners unverified; not a pass)"
|
||||||
|
NOT_ESTABLISHED+=("gitea-settings")
|
||||||
|
fi ;;
|
||||||
|
pass) ok "gitea-settings: green" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# L0/L1 stages: any failure blocks. A SKIP here means a pinned tool is missing or a seam is
|
# L0/L1 stages: any failure blocks. A SKIP here means a pinned tool is missing or a seam is
|
||||||
# unwired — NEVER green. In CI (strict) that is a defect and blocks; on a workstation it
|
# unwired — NEVER green. In CI (strict) that is a defect and blocks; on a workstation it
|
||||||
# degrades to UNDETERMINED, which is still non-zero and still not a pass. render-golden is a
|
# degrades to UNDETERMINED, which is still non-zero and still not a pass. render-golden is a
|
||||||
|
|||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/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"
|
||||||
+32
-4
@@ -86,6 +86,32 @@ enabling enforcement and probing the push. `require-gate.sh` is the enforcement
|
|||||||
|
|
||||||
Re-run `verify-then-enforce.sh` after any future change to the act-runner Deployment.
|
Re-run `verify-then-enforce.sh` after any future change to the act-runner Deployment.
|
||||||
|
|
||||||
|
### `expected-branch-protection.json` + `ci/gitea-settings.sh` — the detector
|
||||||
|
|
||||||
|
`expected-branch-protection.json` **declares** the desired rule. `ci/gitea-settings.sh` runs as
|
||||||
|
a merge-gate stage, reads the LIVE rule, and **fails the gate on any divergence** — plus asserts
|
||||||
|
at least one runner is online. It is the only thing in this repository that can see Gitea's own
|
||||||
|
database.
|
||||||
|
|
||||||
|
It reads `/branch_protections/main`, not `/branches/main`: the branch view does **not** expose
|
||||||
|
`block_admin_merge_override`, and it showed all-green for hours while the rule was overridable.
|
||||||
|
|
||||||
|
Behaviour, proven by test rather than assumed:
|
||||||
|
|
||||||
|
| Situation | Exit | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| live matches the declaration | 0 | green |
|
||||||
|
| any key drifted, or no runner online | 1 | **blocks the merge** |
|
||||||
|
| no `GITEA_SETTINGS_TOKEN`, or Gitea unreachable | 3 | skips **loudly**; never a pass, never a block |
|
||||||
|
|
||||||
|
The skip is deliberately *not* a block: an unwired credential blocking every PR is the
|
||||||
|
lock-yourself-out failure this repo keeps hitting. Wire the token with `set-ci-token.sh`, confirm
|
||||||
|
the stage goes **green on a real PR**, and only then set `REQUIRE_GITEA_SETTINGS=true` in
|
||||||
|
`gate.yml` so a future missing token blocks instead of skipping.
|
||||||
|
|
||||||
|
**Changing the declaration is how you change policy.** If the live state is what you now want,
|
||||||
|
edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
|
||||||
|
|
||||||
### Why these scripts exist at all, and what is still missing
|
### Why these scripts exist at all, and what is still missing
|
||||||
|
|
||||||
Gitea's repo settings live in **its own database**, not in anything Flux reconciles or the
|
Gitea's repo settings live in **its own database**, not in anything Flux reconciles or the
|
||||||
@@ -94,10 +120,12 @@ the root cause of the entire 2026-09-29 session: branch protection was off and t
|
|||||||
gone for a month while the log recorded both as working, and **no check existed that could
|
gone for a month while the log recorded both as working, and **no check existed that could
|
||||||
have caught it.**
|
have caught it.**
|
||||||
|
|
||||||
These scripts encode the desired state and can re-assert it, but they are not a detector. The
|
These scripts encode the desired state and can re-assert it; `ci/gitea-settings.sh` above is the
|
||||||
durable fix is a conformance check that reads the live Gitea API and fails when it diverges
|
**detector** that turns divergence from a silent, invisible failure into a loud one. It closes the
|
||||||
from what is declared here — turning a class of silent, invisible failure into a loud one.
|
root cause of the 2026-09-29 session.
|
||||||
**That check does not exist yet and is the highest-value remaining work in this directory.**
|
|
||||||
|
It is only as good as its wiring: **until `GITEA_SETTINGS_TOKEN` exists it skips**, and a skipped
|
||||||
|
detector detects nothing. That is the remaining step.
|
||||||
|
|
||||||
## Historical sequence (kept for context)
|
## Historical sequence (kept for context)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"_comment": "DECLARED desired state of Gitea branch protection on main. ci/gitea-settings.sh fails the merge gate when the LIVE Gitea diverges from this. Gitea settings live in Gitea database - invisible to Flux, unreadable by every other check - so this file plus that check are the only drift detection that exists for them.",
|
||||||
|
"_generated": "2026-09-29 from the live rule, after the session that found protection OFF and zero runners for a month",
|
||||||
|
"branch": "main",
|
||||||
|
"runners_online_min": 1,
|
||||||
|
"protection": {
|
||||||
|
"enable_push": false,
|
||||||
|
"enable_push_whitelist": false,
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"enable_status_check": true,
|
||||||
|
"status_check_contexts": [
|
||||||
|
"gate / gate (pull_request)"
|
||||||
|
],
|
||||||
|
"required_approvals": 1,
|
||||||
|
"enable_approvals_whitelist": false,
|
||||||
|
"block_admin_merge_override": true,
|
||||||
|
"block_on_rejected_reviews": true,
|
||||||
|
"dismiss_stale_approvals": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Mint a Gitea API token and store it as the repo Actions secret GITEA_SETTINGS_TOKEN,
|
||||||
|
# so ci/gitea-settings.sh can read the live branch-protection rule during the gate.
|
||||||
|
#
|
||||||
|
# The token value is NEVER printed. It goes from the token API into the secrets API and
|
||||||
|
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
|
||||||
|
#
|
||||||
|
# Scope: `read:repository` + `read:admin` are what the check needs (the rule object, and the
|
||||||
|
# runner list). Gitea token scopes are coarse; grant the least that works on your version and
|
||||||
|
# confirm the check goes green rather than assuming the scope name.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
|
NAME="ci-gitea-settings-$(date +%Y%m%d%H%M%S)"
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
|
||||||
|
echo "==> minting token '${NAME}' as ${U}"
|
||||||
|
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\"]}" \
|
||||||
|
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
|
||||||
|
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
|
||||||
|
echo "==> got a ${#TOKEN}-character token (value not shown)"
|
||||||
|
|
||||||
|
echo "==> storing it as repo Actions secret GITEA_SETTINGS_TOKEN"
|
||||||
|
basic -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg d "$TOKEN" '{data:$d}')" \
|
||||||
|
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/actions/secrets/GITEA_SETTINGS_TOKEN" >/dev/null
|
||||||
|
unset TOKEN
|
||||||
|
echo "==> stored."
|
||||||
|
|
||||||
|
cat <<'NOTE'
|
||||||
|
|
||||||
|
Next:
|
||||||
|
1. .gitea/workflows/gate.yml already passes this secret to the gate step.
|
||||||
|
2. Open any PR and confirm the gate prints "gitea-settings: green" -- it SKIPS silently
|
||||||
|
without the token, so a green run is the only proof the wiring works.
|
||||||
|
3. THEN set REQUIRE_GITEA_SETTINGS=true in gate.yml, so a future missing token BLOCKS
|
||||||
|
instead of skipping. Do that only after you have seen it green, or you lock the repo.
|
||||||
|
NOTE
|
||||||
Reference in new issue
Block a user