Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5 ad5de2dba9 docs(decisions): move PR #19's handoff out of the frozen DECISIONS-LOG.md
gate / gate (pull_request) Successful in 1m35s
gate / conformance (pull_request) Has been skipped
This branch originally appended ~99 lines to the tail of DECISIONS-LOG.md. That
tail is the hunk every concurrent branch collides on: measured against the live
Gitea on 2026-09-29, four of the five open PRs were mergeable:false and
DECISIONS-LOG.md was the ONLY conflicting path in all four. PR #19 was one of them.
Nothing was wrong with the change; the log's storage format serialised work that
was otherwise parallel.

So the handoff now lives in its own file, decisions/2026-09-29-conformance-trigger-kubeconfig.md,
in the one-entry-per-file layout PR #24 introduces (frontmatter date/branch/title,
plus "What changed" / "What I verified" / "What I did NOT verify"). Concurrent
branches then touch disjoint paths and there is nothing to merge.

The branch was rebuilt on today's main with the DECISIONS-LOG.md change dropped
entirely; infra/gitea/README.md and infra/gitea/conformance-trigger-kubeconfig.sh
are byte-identical to the original commits. The original commit that touched ONLY
DECISIONS-LOG.md -- the measurement that PR #19's CI has no approval-policy check
-- was dropped as a commit and its substance folded into the new entry verbatim in
meaning, including every UNVERIFIED it recorded.

No content was upgraded. The script is still UNEXECUTED: nothing minted, no Gitea
object written, no kubectl mutation, and the Gitea variables API shape remains
documentation rather than measurement.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 22:51:14 -05:00
jakman85andClaude Opus 5 ae5c7385a5 docs(gitea): the README still named the ILLEGAL secret GITEA_SETTINGS_TOKEN
PR #15 renamed the Actions secret to SETTINGS_API_TOKEN because Gitea RESERVES the
GITEA_ prefix and rejects such a name with HTTP 400 "invalid secret name" -- the
defect that left the branch-protection detector inert and unnoticed. The README in
this very directory, which documents that story, went on naming the illegal name in
its behaviour table and in its closing "remaining step" line.

Both now say SETTINGS_API_TOKEN, plus a note recording what ci/gitea-settings.sh
ACTUALLY does, quoted from the file rather than assumed:

    TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"

GITEA_SETTINGS_TOKEN is still honoured, but ONLY as an environment variable for a
local run -- it can never be the CI wiring, because the secret that would feed it
cannot be created at all. The closing line also predated PR #15 storing the secret;
it now says so.

Documentation only. No behaviour change.

NOT fixed here, because it is outside this PR's diff: ci/gitea-settings.sh:45 still
instructs the reader to "create a Gitea repo secret GITEA_SETTINGS_TOKEN", which is
impossible. Same defect, different file -- worth its own one-line change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 22:47:55 -05:00
jakman85andClaude Opus 5 19f9a8ae0c feat(sec): provision the narrow conformance CI trigger credential -- and refuse to arm a fake green
docs/plans/l2-l3-conformance-ci-wiring.md §3 item 6, as an operator script:
infra/gitea/conformance-trigger-kubeconfig.sh. UNEXECUTED here -- minting a
cluster credential and changing Gitea variables are operator actions.

It mints a token for conformance/conformance-trigger (TokenRequest), wraps ONLY
that token with the cluster CA and server into a kubeconfig, and stores it as the
Actions secret CONFORMANCE_TRIGGER_KUBECONFIG. Never cluster-admin, never a copy
of the operator's own kubeconfig. The token is never printed and never reaches
argv: it goes into a 0600 file and into the PUT body FILE (--data @file), and the
script refuses to run under `set -x` because xtrace would print it.

Two traps this repo already paid for are designed around:

  * GITEA_ is a RESERVED secret-name prefix (HTTP 400 "invalid secret name"), so
    the name is CONFORMANCE_TRIGGER_KUBECONFIG -- which is also what gate.yml and
    merge-gate.yaml already reference.
  * `curl -sS` exits 0 on an HTTP error. Every write checks %{http_code} AND reads
    the object back. The secret read-back proves presence by NAME only (Gitea
    never returns secret values) and the script says so rather than implying its
    content was checked; the variable read-back asserts the value is exactly
    "true", which is what gate.yml compares against.

PRECONDITIONS ARE SPLIT IN TWO, and the split is the security argument:

  mint-blocking (cluster) -- SA/Role/RoleBinding present, and ClusterPolicy
  conformance-trigger-job-shape present + Enforce + failurePolicy Fail + Ready.
  Any failure and NOTHING IS MINTED. Kubernetes places no `use` check on a Job's
  serviceAccountName, so a `create jobs` token in that namespace IS the privileged
  conformance-runner identity unless admission bounds its shape. RBAC cannot close
  that (plan §4); minting before the boundary is live hands CI the escalation the
  boundary exists to close.

  arm-blocking (repo) -- digest-pinned CONFORMANCE_RUNNER_IMAGE, an initContainer
  plus the admitted image in chainsaw-job.yaml, and CONFORMANCE_TRIGGER_KUBECONFIG
  referenced in ci/conformance.sh CODE rather than a comment. These block only
  vars.CLUSTER_AVAILABLE, because storing the secret early is harmless (the job is
  gated off) and is the plan's own order.

All three arm-blocking checks FAIL in the tree today, so a live run would store
the secret and then REFUSE to arm CLUSTER_AVAILABLE, naming each missing item.
That is the intended behaviour: measured 2026-09-29, CLUSTER_AVAILABLE=true with
no trigger path scaffold-skipped all 54 tests at rc 0 and the stage printed "L2/L3
conformance passed". ci/conformance.sh now blocks that case, so arming early does
not enable L2/L3 -- it only breaks every PR. Arming needs the explicit flag, every
precondition green, and a typed confirmation on a TTY.

Before storing anything, the credential is PROVEN narrow: auth whoami must be
system:serviceaccount:conformance:conformance-trigger, a real `get jobs` round-trip
must succeed, and an auth can-i matrix asserts both halves -- granted (create/get
jobs, get pods, get pods/log) and DENIED (delete jobs, create pods/exec, get
secrets, create jobs in default, get nodes, '*' '*' --all-namespaces). A mismatch
aborts before any store, and says explicitly not to widen
conformance/incluster/rbac.yaml to make it pass.

Verified only by `bash -n` (clean) and reading. The Gitea variables API shape
(POST creates, PUT updates, body field `value` not `data`) is from documentation,
not measurement: the script tries POST then PUT, prints the response body on
failure, and treats the read-back as the authority. Kyverno readiness is accepted
from either .status.ready or a Ready condition, and "neither present" blocks
rather than assuming ready.

Gate (pinned toolbox): MERGE GATE: UNDETERMINED (rc 3) -- spec-immutability and
gitea-settings unrun, the expected local result.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 22:47:55 -05:00
jakman85 c59fe9f923 Merge pull request 'chore(log): the handoff log was serialising every parallel branch' (#24) from chore/decisions-log-conflict-free into main
gate / gate (push) Successful in 1m38s
gate / conformance (push) Has been skipped
Reviewed-on: #24
Reviewed-by: Keith Jackson <[email protected]>
2026-09-30 03:40:18 +00:00
jakman85andClaude Opus 5 72a46ad82d chore(log): the handoff log was serialising every parallel branch
gate / gate (pull_request) Successful in 1m35s
gate / conformance (pull_request) Has been skipped
DECISIONS-LOG.md was append-only in ONE file and every agent appended to its
tail, so any two concurrent branches collided on the same last hunk. Measured
against the live Gitea today: four of the five open PRs were mergeable=false,
and `git merge-tree` named DECISIONS-LOG.md as the ONLY conflicting path in
all four.

    #22 fix/crafty-oauth2-proxy-signin       CONFLICT: DECISIONS-LOG.md
    #20 feat/conformance-job-template        CONFLICT: DECISIONS-LOG.md
    #19 feat/conformance-trigger-kubeconfig  CONFLICT: DECISIONS-LOG.md
    #18 feat/conformance-runner-image        CONFLICT: DECISIONS-LOG.md
    #17 feat/ci-is-the-approver              clean — it wrote no handoff

Nothing was wrong with any of those changes. The log's storage format
serialised work that was otherwise parallel, and the cost grew with every
concurrent branch (N branches -> N-1 rebases, each invalidating the gate run
before it). The agents work in isolated worktrees precisely so they do not
block each other; this one file undid that.

So: stop sharing a file. Handoffs are now one-per-file under decisions/,
named YYYY-MM-DD-<slug>.md. Disjoint paths have nothing to merge. There is
deliberately no index file (a shared mutable file is the thing that just
failed) and no counter (two agents would race for the same number) — the
directory listing is the index.

DECISIONS-LOG.md is frozen as the pre-2026-09-29 archive, its SHA-256 pinned
in decisions/ARCHIVE.sha256, and a new gate stage rejects any change to it.
That is a guard and not a paragraph because the old habit is written into the
runbooks and into every agent that has read the log, and getting it wrong does
not fail: it produces a clean-looking commit that re-creates the conflict on
OTHER branches, later, misattributed.

  ci/decisions-log.sh        new stage: archive pinned; every entry named with
                             a real date, carrying date/branch/title frontmatter
                             that agrees with its filename, and all three of
                             CLAUDE.md §7's sections with a real body under each.
                             Exit 0 or 1 only — no skip path by construction, so
                             no fail-open surface on a guard that closes one.
  ci/gate.sh                 runs it; fail and (impossible) skip both BLOCK.
                             No REQUIRE_DECISIONS_LOG knob: a guard with an off
                             switch is not a guard.
  ci/tests/decisions-log.test.sh   22 assertions, incl. the one that matters —
                             an append to the archive is rejected, and the
                             message names the file to write instead.
  ci/tests/gate-decision.test.sh   +3 assertions for the new wiring, AND a
                             pre-existing bug: the stub list never included
                             gitea-settings, so run_stage saw exit 127 and
                             recorded a FAILED stage in every case. 15 of 47
                             assertions failed on main for that reason alone —
                             the suite was measuring its own missing stub.
                             Now 50/50.
  CLAUDE.md                  §7 rewritten; §2b widened to the new path.
  ci/Makefile, ci/README.md, .gitea/workflows/README.md — stage named where
                             the other stages are named.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 22:35:55 -05:00
jakman85 cadf64a514 Merge pull request 'docs(ci): the wiring comment named a secret Gitea cannot accept' (#21) from fix/gitea-settings-comment-names-impossible-secret into main
gate / gate (push) Successful in 1m36s
gate / conformance (push) Has been skipped
Reviewed-on: #21
Reviewed-by: Keith Jackson <[email protected]>
2026-09-30 03:22:06 +00:00
jakman85 cfbc596b8b Merge pull request 'docs(crafty): Bedrock on 19132 RESOLVED — a player has joined; the wall was an empty allowlist' (#23) from fix/crafty-bedrock-19132 into main
gate / gate (push) Successful in 1m34s
gate / conformance (push) Has been skipped
Reviewed-on: #23
Reviewed-by: Keith Jackson <[email protected]>
2026-09-30 03:19:04 +00:00
jakman85andClaude Opus 5 e993d0f7d1 docs(ci): the wiring comment named a secret Gitea cannot accept
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
ci/gitea-settings.sh told the reader to "create a Gitea repo secret
GITEA_SETTINGS_TOKEN". That instruction is IMPOSSIBLE to follow: Gitea reserves the
GITEA_ prefix for secret names and rejects the PUT with HTTP 400 "invalid secret name".

Same defect as the one fixed in PR #15 and in the README in PR #19 -- this was the third
file carrying it, found by an agent reading the file rather than trusting my description
of it. A comment that instructs an impossible action is not cosmetic: this exact wrong
name is why the drift detector sat inert while a token existed and no secret did.

Now names SETTINGS_API_TOKEN, and states explicitly that GITEA_SETTINGS_TOKEN remains
honoured ONLY as a local-run env var -- it can never be the CI wiring, because the secret
that would feed it cannot exist.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:30:35 -05:00
16 changed files with 1377 additions and 16 deletions

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ it touches is green** (R-TEST-03).
## The pipeline
```
lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
decisions-log ─▶ lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
└──────────────── run in CI, NO cluster ────────────────┘ └── in-cluster Chainsaw Job ──┘
live now cluster-gated (SKIPS in Phase 0)
```
+33 -9
View File
@@ -1,7 +1,7 @@
# CLAUDE.md — how work lands in this repo
Read this before doing anything durable. It is the delivery contract for AI coding
sessions and agents. `DECISIONS-LOG.md`'s last entry is the current PM handoff and
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
takes precedence on *what* to work on; this file governs *how* it lands.
## 1. Never push to `main`. Ever.
@@ -48,9 +48,10 @@ reports as `success`, so a green tick is not by itself evidence that anything ra
## 2b. Never infer platform state from this repository
**This repo is not evidence about the live platform.** Neither is `DECISIONS-LOG.md`.
Both describe what someone believed at the time of writing, and both have been
confidently wrong for a month at a stretch.
**This repo is not evidence about the live platform.** Neither is the handoff log
(`decisions/`, and the frozen `DECISIONS-LOG.md` archive). Both describe what someone
believed at the time of writing, and both have been confidently wrong for a month at a
stretch.
On 2026-09-29 the log stated the merge gate was a required status check with a runner
online. The API said `branch_protections: []` and **zero** runners. Everything downstream
@@ -139,9 +140,32 @@ invocations concurrently.
flow. Do not design around committing one — read the expiry from the issuing page and
treat a burnt token as free to retry.
## 7. Before you finish
## 7. Before you finish — write a handoff entry, in its OWN file
Append a handoff entry to `DECISIONS-LOG.md` describing what you changed, what you
verified, and what you did **not** verify. State gaps plainly — an honest
"UNVERIFIED" is worth more than an optimistic "green", and this repo has already been
burned by the difference.
Add **one new file**:
decisions/YYYY-MM-DD-<slug>.md <slug> = your branch's last segment
with frontmatter (`date`, `branch`, `title`) and exactly these three sections:
## What changed
## What I verified
## What I did NOT verify
State gaps plainly — an honest "UNVERIFIED" is worth more than an optimistic "green",
and this repo has already been burned by the difference. All three headings are
**enforced** by `ci/decisions-log.sh`, because the third is the one that gets dropped.
Template and full rationale: `decisions/README.md`.
**Do NOT append to `DECISIONS-LOG.md`.** It is a frozen archive (pre-2026-09-29), its
SHA-256 is pinned in `decisions/ARCHIVE.sha256`, and the merge gate rejects any change
to it. Appending to its tail is what every agent used to do, and because every branch
appended to the *same last hunk*, no two concurrent branches could auto-merge. Measured
on 2026-09-29: **four of the five open PRs were `mergeable: false`, with
`DECISIONS-LOG.md` the only conflicting path in all four** — none of those changes had
anything to do with each other. Separate files have nothing to merge, which is the
entire fix. Note that the damage from getting this wrong lands on *other people's*
branches, later, which is why it is a gate stage and not a request.
Many older handoff entries and runbooks say "append to `DECISIONS-LOG.md`". That
wording is **stale**, in the same way §1's `git push origin main` wording is stale.
+13
View File
@@ -1,3 +1,16 @@
> **FROZEN ARCHIVE — 2026-08-16 … 2026-09-29. Do not append to this file.**
>
> New handoff entries go in **`decisions/`**, one file per entry
> (`decisions/YYYY-MM-DD-<slug>.md`). See [`decisions/README.md`](decisions/README.md).
>
> Why: every agent appended to this file's tail, so any two concurrent branches
> conflicted on the same last hunk. On 2026-09-29 that made four of the five open PRs
> unmergeable, with this file as the *only* conflicting path in all four. Separate files
> have nothing to merge.
>
> This file's SHA-256 is pinned in `decisions/ARCHIVE.sha256` and the merge gate
> (`ci/decisions-log.sh`) rejects any change to it.
# Autonomous Session Decision Log (PM)
Program Manager working independently while the operator is AFK (started 2026-08-16). Every
+4 -1
View File
@@ -12,7 +12,7 @@ CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
.DEFAULT_GOAL := help
.PHONY: help spec-immutability lint schema l0 render-golden l1 conformance gate all touched versions tools test
.PHONY: help spec-immutability decisions-log lint schema l0 render-golden l1 conformance gate all touched versions tools test
help: ## Show this help
@awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST)
@@ -29,6 +29,9 @@ schema l0: ## ServiceDefinition schema validation + Kyverno L0 policy gate (no c
render-golden: ## render==golden: canonical render reproduces committed apps/ (no cluster; live now)
@"$(CI_DIR)render-golden.sh"
decisions-log: ## handoff log: DECISIONS-LOG.md frozen, decisions/ entries well-formed (no cluster)
@"$(CI_DIR)decisions-log.sh"
l1: ## L1 rendering checks (no cluster; live now)
@"$(CI_DIR)l1.sh"
+1
View File
@@ -101,6 +101,7 @@ caller that must distinguish them invokes `./ci/gate.sh` directly.
|---|---|---|
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is installed for us by `.gitea/workflows/scripts/install-tools.sh`, so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
| `spec-immutability` (R-TEST-06) | the sibling spec repo is not checked out, so the spec **could not be verified** | **UNDETERMINED** — unverified is unverified. Deliberately *not* upgraded to BLOCKED by strict skips: the live runner does not check the spec repo out at all, so blocking would leave the only enforced status check permanently red for a reason no PR could clear. It is therefore the **one** skip whose non-answer a caller may knowingly tolerate, and `.gitea/workflows/gate.yml` does so in one visible line. `REQUIRE_SPEC_VERIFICATION=true` makes it a hard block once that checkout is wired. Locally: check the spec repo out beside this one, or set `SPEC_REPO` (`dev.sh` mounts a sibling automatically). |
| `decisions-log` | **nothing** — this stage has no skip path. It reads only files in the tree under test and needs no tool and no cluster, so there is no environment in which it "could not run" | a reported skip is a **defect in the stage** and BLOCKS. The stage fails when the frozen `DECISIONS-LOG.md` is modified (which silently re-creates, for every *other* open branch, the tail-hunk conflict that made four of five PRs unmergeable on 2026-09-29) or when a `decisions/` entry is malformed. There is deliberately **no** `REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard. See `decisions/README.md`. |
| `conformance` (L2/L3) | `CLUSTER_AVAILABLE!=true` — the environment **declared** the capability absent (documented Phase-0 behaviour) | **does not deny a PASS**: a declared-inapplicable stage was never *supposed* to run. It is NAMED in the PASS headline and step summary, the touched requirements it would have covered are reported UNVERIFIED, and `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) turns that into a block. A skip **despite** `CLUSTER_AVAILABLE=true` is a defect and BLOCKS. |
That asymmetry is the whole rule: **declared-not-applicable may pass; could-not-run may not.**
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# ci/decisions-log.sh — the handoff log is a DIRECTORY, and the old single file is frozen.
#
# WHY THIS STAGE EXISTS
# ---------------------
# CLAUDE.md §7 tells every agent to record a handoff before finishing. Until 2026-09-29 that
# meant appending to the tail of one 5,228-line file, DECISIONS-LOG.md. Every branch appended
# to the SAME last hunk, so git could not auto-merge any two of them. Measured that day against
# the live Gitea: four of the five open PRs were `mergeable: false`, and `git merge-tree`
# against `main` named DECISIONS-LOG.md as the ONLY conflicting path in all four:
#
# #22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md
# #20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md
# #19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
# #18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md
# #17 feat/ci-is-the-approver clean — it wrote no handoff entry
#
# Nothing was wrong with any of those changes. The log's storage format serialised work that
# was otherwise parallel, and the cost grew with every concurrent branch (N branches -> N-1
# rebases, each invalidating the gate run before it).
#
# The fix is that handoffs now live one-per-file under decisions/, so concurrent branches touch
# disjoint paths and there is nothing to merge. See decisions/README.md.
#
# WHY IT NEEDS A CHECK AND NOT JUST A DOCUMENT
# --------------------------------------------
# The old habit is written into this repo in many places — the runbooks, the muscle memory of
# every agent that has read the log, and (until this change) CLAUDE.md itself. An agent that
# appends to DECISIONS-LOG.md anyway does not fail: it produces a perfectly good-looking commit
# that quietly re-creates the conflict for every other open branch, and the damage is only
# visible later, to somebody else, in a PR that is not theirs. That is the exact shape this
# repo keeps getting burned by — a silent regression with a delayed, misattributed symptom — so
# it gets a guard rather than a paragraph.
#
# WHAT IS ASSERTED
# (1) DECISIONS-LOG.md is byte-identical to the SHA-256 pinned in decisions/ARCHIVE.sha256.
# Re-pinning is a deliberate, reviewable line in a diff; appending is not.
# (2) Every decisions/ entry is named YYYY-MM-DD-<slug>.md with a real calendar date, so the
# directory sorts chronologically and two agents cannot collide on a filename.
# (3) Every entry carries frontmatter (date, branch, title) whose `date` agrees with its
# filename — a disagreement means a copied template nobody edited.
# (4) Every entry has all three of CLAUDE.md §7's sections with a real body under each:
# "What changed", "What I verified", "What I did NOT verify". This is the quality floor
# that stops an entry degrading into "done ✅". The third heading is the load-bearing one:
# this repo's recurring failure is a confident report of something nobody measured, and an
# entry with nowhere to write "UNVERIFIED" does not get one written.
#
# EXIT CODES (ci/README.md contract): 0 pass, 1 BLOCKS the merge. This stage has NO skip path —
# it reads only files that are in the tree it is run against, needs no tool beyond coreutils and
# no cluster, so there is no environment in which it "could not run". Adding an rc 3 here would
# add a fail-open surface to a guard whose whole job is to close one.
set -uo pipefail
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "${CI_DIR}/lib.sh"
set +e # aggregate every problem in one run; do not stop at the first.
cd "$REPO_ROOT"
ARCHIVE="DECISIONS-LOG.md"
PIN="decisions/ARCHIVE.sha256"
rc=0
# ---- (1) the archive is frozen ----------------------------------------------
log "Stage: decisions log (archive frozen + entries well-formed)"
if [ ! -f "$ARCHIVE" ]; then
err "${ARCHIVE} is missing. It is the frozen pre-2026-09-29 history and must stay in the tree."
summary "- decisions-log: **FAILED** (\`${ARCHIVE}\` missing)"
rc=1
elif [ ! -f "$PIN" ]; then
err "${PIN} is missing, so the archive's integrity cannot be established."
err " If you are re-pinning deliberately: sha256sum ${ARCHIVE} > ${PIN}"
summary "- decisions-log: **FAILED** (\`${PIN}\` missing — archive unverifiable)"
rc=1
else
want="$(awk 'NR==1{print $1}' "$PIN")"
got="$(sha256sum "$ARCHIVE" | awk '{print $1}')"
if [ "$want" != "$got" ]; then
err "${ARCHIVE} HAS CHANGED. It is a FROZEN ARCHIVE — do not append to it."
err " pinned: ${want}"
err " on disk: ${got}"
err ""
err " Every branch that appends to this file's tail conflicts with every other branch that"
err " does, on the same hunk. That is what made four of five open PRs unmergeable on"
err " 2026-09-29, and your commit would re-create it for branches that are not yours."
err ""
err " Write your handoff as its OWN FILE instead:"
err " decisions/\$(date +%F)-<your-branch-slug>.md (see decisions/README.md)"
err " and restore the archive: git checkout -- ${ARCHIVE}"
err ""
err " If the archive genuinely must change (redacting a leaked credential, say), that is a"
err " reviewed act: re-pin it with 'sha256sum ${ARCHIVE} > ${PIN}' and say why in your entry."
summary "- decisions-log: **FAILED — the frozen archive \`${ARCHIVE}\` was modified** (append to \`decisions/\` instead)"
rc=1
else
ok "${ARCHIVE}: unchanged (sha256 matches ${PIN})"
fi
fi
# ---- (2)(3)(4) every entry is well-formed ------------------------------------
shopt -s nullglob
entries=( decisions/[0-9]*.md )
shopt -u nullglob
# Zero entries is legitimate only until the first handoff lands; after that the glob always
# matches. It is never an error here — a branch that changes no behaviour owes no handoff, and
# CLAUDE.md, not this stage, is what asks for one.
if [ "${#entries[@]}" -eq 0 ]; then
log "no decisions/ entries yet"
fi
REQUIRED_SECTIONS=("What changed" "What I verified" "What I did NOT verify")
for f in "${entries[@]}"; do
base="${f#decisions/}"
# (2) filename
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([a-z0-9][a-z0-9-]*)\.md$ ]]; then
err "${f}: name must be decisions/YYYY-MM-DD-<slug>.md with a lowercase [a-z0-9-] slug."
err " Use your branch's last segment as the slug — it is already unique per unit of work."
summary "- decisions-log: **FAILED** (\`${f}\` misnamed)"
rc=1
continue
fi
fname_date="${BASH_REMATCH[1]}"
if ! date -d "$fname_date" +%F >/dev/null 2>&1 || [ "$(date -d "$fname_date" +%F)" != "$fname_date" ]; then
err "${f}: '${fname_date}' is not a real calendar date."
summary "- decisions-log: **FAILED** (\`${f}\` — impossible date)"
rc=1
continue
fi
# (3) frontmatter
if [ "$(head -1 "$f")" != "---" ]; then
err "${f}: must open with a '---' YAML frontmatter block (date, branch, title)."
err " See decisions/README.md for the template."
summary "- decisions-log: **FAILED** (\`${f}\` — no frontmatter)"
rc=1
continue
fi
fm="$(awk 'NR==1&&$0=="---"{inb=1;next} inb&&$0=="---"{exit} inb{print}' "$f")"
missing_keys=()
for k in date branch title; do
grep -qE "^${k}:[[:space:]]*\S" <<<"$fm" || missing_keys+=("$k")
done
if [ "${#missing_keys[@]}" -gt 0 ]; then
err "${f}: frontmatter is missing a non-empty ${missing_keys[*]}."
summary "- decisions-log: **FAILED** (\`${f}\` — frontmatter missing \`${missing_keys[*]}\`)"
rc=1
continue
fi
fm_date="$(grep -m1 -E '^date:' <<<"$fm" | sed -E 's/^date:[[:space:]]*//; s/[[:space:]"'\'']//g')"
if [ "$fm_date" != "$fname_date" ]; then
err "${f}: frontmatter date '${fm_date}' disagrees with the filename's '${fname_date}'."
err " One of them was copied from a template and not edited; the log's ordering depends on both."
summary "- decisions-log: **FAILED** (\`${f}\` — date disagrees with filename)"
rc=1
continue
fi
# (4) the three required sections, each with a real body
body_start="$(awk 'NR>1 && $0=="---" {print NR; exit}' "$f")"
[ -n "$body_start" ] || body_start=1
for want_sec in "${REQUIRED_SECTIONS[@]}"; do
hdr_line="$(awk -v s="$body_start" -v w="## ${want_sec}" 'NR>s && $0==w {print NR; exit}' "$f")"
if [ -z "$hdr_line" ]; then
err "${f}: missing the required section '## ${want_sec}'."
err " CLAUDE.md §7 asks for what you changed, what you verified, and what you did NOT."
err " All three are structure here, because the third is the one that gets dropped."
summary "- decisions-log: **FAILED** (\`${f}\` — missing \`## ${want_sec}\`)"
rc=1
continue
fi
# A real body: at least 20 non-blank, non-heading characters before the next '## '.
nchars="$(awk -v s="$hdr_line" 'NR>s { if ($0 ~ /^## /) exit; print }' "$f" \
| tr -d '[:space:]' | wc -c)"
if [ "$nchars" -lt 20 ]; then
err "${f}: section '## ${want_sec}' is empty or a stub (${nchars} chars of content)."
err " If nothing was verified, WRITE THAT — 'nothing was verified; the gate was not run'"
err " is a valid and useful entry. A blank section is not."
summary "- decisions-log: **FAILED** (\`${f}\` — \`## ${want_sec}\` is a stub)"
rc=1
fi
done
done
if [ "$rc" -eq 0 ]; then
ok "decisions log: archive frozen, ${#entries[@]} entr$([ "${#entries[@]}" -eq 1 ] && echo y || echo ies) well-formed"
summary "- decisions-log: **passed** (archive frozen; ${#entries[@]} entries well-formed)"
else
err "decisions log stage FAILED"
fi
exit "$rc"
+17
View File
@@ -108,6 +108,7 @@ summary "## Merge gate (R-TEST-03)"
summary ""
run_stage spec-immutability "${CI_DIR}/spec-immutability.sh"
run_stage decisions-log "${CI_DIR}/decisions-log.sh"
run_stage lint "${CI_DIR}/lint.sh"
run_stage l0 "${CI_DIR}/l0.sh"
run_stage render-golden "${CI_DIR}/render-golden.sh"
@@ -214,6 +215,22 @@ case "${OUTCOME[gitea-settings]}" in
pass) ok "gitea-settings: green" ;;
esac
# decisions-log: the handoff log's storage format. A FAIL means either the frozen archive
# DECISIONS-LOG.md was modified — which re-creates, for every OTHER open branch, the tail-hunk
# conflict that made four of five PRs unmergeable on 2026-09-29 — or a decisions/ entry is
# malformed. Always blocks, and has no skip path by construction: the stage reads only files in
# the tree under test and needs no tool or cluster, so there is no environment in which it
# "could not run". A guard against a silent, delayed, misattributed regression must not itself
# have a way to not-run.
case "${OUTCOME[decisions-log]}" in
fail) err "decisions-log: FAILED -> blocks merge (see decisions/README.md)"; BLOCK=1 ;;
skip) err "decisions-log: reported SKIPPED, which this stage has no path to produce -> treat"
err " as a defect in the stage itself and block."
summary "- decisions-log: **SKIPPED** (impossible by construction) — blocks merge"
BLOCK=1 ;;
pass) ok "decisions-log: green" ;;
esac
# 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
# degrades to UNDETERMINED, which is still non-zero and still not a pass. render-golden is a
+8 -1
View File
@@ -42,9 +42,16 @@
# 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
# 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)"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# ci/tests/decisions-log.test.sh — proves ci/decisions-log.sh actually fails closed.
#
# The stage exists because appending to DECISIONS-LOG.md silently re-creates a merge conflict
# for every other open branch. A guard against a silent regression is itself worth nothing
# unless somebody has watched it reject the thing it claims to reject, so each case below
# CONSTRUCTS the violation in a throwaway tree and asserts a non-zero exit — the same reasoning
# as ci/tests/gate-decision.test.sh, one layer down.
set -uo pipefail
REAL_CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0 FAIL=0
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# A minimal well-formed tree: the frozen archive, its pin, and one valid entry.
mk_tree() {
local d="$1"; rm -rf "$d"; mkdir -p "$d/ci" "$d/decisions"
cp "${REAL_CI_DIR}/decisions-log.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$d/ci/"
printf 'frozen history\n' > "$d/DECISIONS-LOG.md"
( cd "$d" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 )
mk_entry "$d/decisions/2026-09-29-a-good-entry.md" 2026-09-29
# REPO_ROOT in ci/lib.sh is derived from git; give the tree a repo so it resolves here.
( cd "$d" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t commit -qm init )
}
mk_entry() {
cat > "$1" <<ENTRY
---
date: $2
branch: chore/example
title: An example entry
---
## What changed
Enough prose here to clear the twenty-character body floor comfortably.
## What I verified
Enough prose here to clear the twenty-character body floor comfortably.
## What I did NOT verify
Enough prose here to clear the twenty-character body floor comfortably.
ENTRY
}
# run <dir> -> sets RC and OUT
run() { OUT="$( cd "$1" && bash ci/decisions-log.sh 2>&1 )"; RC=$?; }
D="${TMP}/t"
echo "== 1. a well-formed tree passes"
mk_tree "$D"; run "$D"
[ "$RC" -eq 0 ] && ok "clean tree -> exit 0" || bad "clean tree -> exit ${RC}: ${OUT}"
echo
echo "== 2. THE REGRESSION THIS STAGE EXISTS FOR: appending to the frozen archive"
mk_tree "$D"; printf '\n## my new handoff\n' >> "$D/DECISIONS-LOG.md"; run "$D"
[ "$RC" -ne 0 ] && ok "append to DECISIONS-LOG.md -> non-zero (${RC})" \
|| bad "append to DECISIONS-LOG.md was ACCEPTED — the guard is inert"
grep -qi 'frozen archive' <<<"$OUT" && ok "the message names the archive as frozen" \
|| bad "the message does not say the archive is frozen"
grep -q 'decisions/\$(date +%F)' <<<"$OUT" && ok "the message names the file to write instead" \
|| bad "the message does not name the remedy"
echo
echo "== 3. a re-pin is a deliberate, accepted act"
mk_tree "$D"; printf '\nredacted\n' >> "$D/DECISIONS-LOG.md"
( cd "$D" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 ); run "$D"
[ "$RC" -eq 0 ] && ok "archive changed AND re-pinned -> exit 0" || bad "re-pin rejected: ${OUT}"
echo
echo "== 4. a missing pin is unverifiable, not green"
mk_tree "$D"; rm "$D/decisions/ARCHIVE.sha256"; run "$D"
[ "$RC" -ne 0 ] && ok "no ARCHIVE.sha256 -> non-zero (${RC})" || bad "missing pin was accepted"
echo
echo "== 5. entry naming"
for badname in 2026-9-29-x.md 26-09-29-x.md 2026-09-29-Upper.md 2026-09-29-.md 2026-02-30-x.md; do
mk_tree "$D"; mk_entry "$D/decisions/${badname}" 2026-09-29; run "$D"
[ "$RC" -ne 0 ] && ok "rejects decisions/${badname}" || bad "ACCEPTED decisions/${badname}"
done
mk_tree "$D"; mk_entry "$D/decisions/2026-10-01-fix-thing-2.md" 2026-10-01; run "$D"
[ "$RC" -eq 0 ] && ok "accepts a digits-and-dashes slug" || bad "rejected a valid slug: ${OUT}"
echo
echo "== 6. frontmatter"
mk_tree "$D"; sed -i '1d' "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
[ "$RC" -ne 0 ] && ok "rejects an entry with no frontmatter" || bad "accepted a bare entry"
for key in date branch title; do
mk_tree "$D"; sed -i "/^${key}:/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
[ "$RC" -ne 0 ] && ok "rejects frontmatter missing '${key}'" || bad "accepted missing '${key}'"
done
# The copied-template failure: the body was rewritten, the date never was.
mk_tree "$D"; mk_entry "$D/decisions/2026-09-30-copied.md" 2026-09-29; run "$D"
[ "$RC" -ne 0 ] && ok "rejects a frontmatter date that disagrees with the filename" \
|| bad "accepted a date/filename disagreement"
echo
echo "== 7. the three required sections, and their bodies"
for sec in 'What changed' 'What I verified' 'What I did NOT verify'; do
mk_tree "$D"; sed -i "/^## ${sec}\$/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
[ "$RC" -ne 0 ] && ok "rejects an entry missing '## ${sec}'" || bad "accepted missing '## ${sec}'"
done
# A heading with nothing under it is the way this floor gets defeated in practice.
mk_tree "$D"
python3 - "$D/decisions/2026-09-29-a-good-entry.md" <<'PY'
import sys,re
p=sys.argv[1]; s=open(p).read()
s=re.sub(r'## What I did NOT verify\n\n.*', '## What I did NOT verify\n\nn/a\n', s, flags=re.S)
open(p,'w').write(s)
PY
run "$D"
[ "$RC" -ne 0 ] && ok "rejects a stub body under a required section" || bad "accepted an 'n/a' section"
echo
echo "== 8. README.md in decisions/ is not an entry and is not policed as one"
mk_tree "$D"; printf 'not an entry\n' > "$D/decisions/README.md"; run "$D"
[ "$RC" -eq 0 ] && ok "decisions/README.md ignored by the entry rules" || bad "README policed: ${OUT}"
echo
if [ "$FAIL" -eq 0 ]; then echo "decisions-log tests: PASS ($PASS assertions)"; exit 0
else echo "decisions-log tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
+22 -1
View File
@@ -36,7 +36,13 @@ REPO="${TMP}/repo"; mkdir -p "$REPO"
cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$CI/"
# One stub per stage. It exits with STAGE_RC_<stage-with-dashes-as-underscores>, default 0.
for stage in spec-immutability lint l0 render-golden l1 conformance; do
# NOTE: this list must name EVERY stage gate.sh runs. It did not: `gitea-settings` was added to
# gate.sh and never added here, so its script was absent from the stub CI dir, `run_stage` got
# exit 127 from the missing file, and the gate recorded a FAILED stage in every single case —
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do
cat > "${CI}/${stage}.sh" <<'STUB'
#!/usr/bin/env bash
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
@@ -184,6 +190,21 @@ assert_verdict BLOCKED 1 "l0 FAILED + l1 SKIPPED"
run_gate STAGE_RC_spec_immutability=3 STAGE_RC_conformance=3
assert_verdict UNDETERMINED 3 "spec-immutability SKIPPED + conformance declared-absent"
echo
echo "== 10. decisions-log: the handoff log's storage format has no non-blocking outcome"
# A FAIL here means the frozen archive was appended to (re-creating, for every OTHER open
# branch, the tail-hunk conflict that made 4 of 5 PRs unmergeable on 2026-09-29) or an entry is
# malformed. A SKIP is impossible by construction — the stage needs no tool and no cluster — so
# if one is ever reported it is a defect in the stage, and the gate must not soften it into
# UNDETERMINED. Both outcomes block, and neither is upgradeable by an env knob: there is
# deliberately no REQUIRE_DECISIONS_LOG, because a guard with an off switch is not a guard.
run_gate STAGE_RC_decisions_log=1
assert_verdict BLOCKED 1 "decisions-log FAILED (archive modified / entry malformed)"
run_gate STAGE_RC_decisions_log=3
assert_verdict BLOCKED 1 "decisions-log SKIPPED (impossible by construction -> defect, blocks)"
run_gate STAGE_RC_decisions_log=1 STAGE_RC_l1=3
assert_verdict BLOCKED 1 "decisions-log FAILED outranks an l1 non-run"
echo
if [ "$FAIL" -eq 0 ]; then echo "gate-decision tests: PASS ($PASS assertions)"; exit 0
else echo "gate-decision tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
@@ -0,0 +1,125 @@
---
date: 2026-09-29
branch: feat/conformance-trigger-kubeconfig
title: The trigger credential script — it REFUSES, on purpose, and it is UNEXECUTED
---
## What changed
Delivered `infra/gitea/conformance-trigger-kubeconfig.sh` —
`docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 — plus its section in
`infra/gitea/README.md`. **The script is UNEXECUTED.** Minting a cluster credential and
changing Gitea variables are operator actions; nothing was minted, no Gitea object was
written, no `kubectl` mutation was made. Everything below is a claim about the *script*,
not about the platform.
*What it does.* Mints a token for `conformance/conformance-trigger` (TokenRequest), wraps
**only that token** with the cluster CA and server into a kubeconfig, stores it as the
Actions secret `CONFORMANCE_TRIGGER_KUBECONFIG` (legal name — **not** the reserved `GITEA_`
prefix), and sets `vars.CLUSTER_AVAILABLE` only as a separately-requested,
precondition-gated, TTY-confirmed final step. The token is never printed and never reaches
argv: it goes into a 0600 file and into the PUT **body file**. The script refuses to run
under `set -x`.
*The design decision worth reviewing: TWO precondition classes.*
- **mint-blocking (cluster-side):** SA/Role/RoleBinding present, and ClusterPolicy
`conformance-trigger-job-shape` present + `Enforce` + `failurePolicy: Fail` + Ready. If any
fails, **nothing is minted**. Rationale: Kubernetes puts no `use` check on a Job's
`serviceAccountName`, so a `create jobs` token in that namespace IS the privileged
`conformance-runner` identity unless the admission policy bounds it. RBAC cannot fix that
(plan §4); minting before the boundary is live would hand CI the escalation the boundary
exists to close.
- **arm-blocking (repo-side):** digest-pinned `CONFORMANCE_RUNNER_IMAGE`, an init-container
plus the admitted image in `chainsaw-job.yaml`, and `CONFORMANCE_TRIGGER_KUBECONFIG`
referenced in `ci/conformance.sh` **code rather than a comment**. These block only the
variable, because storing the secret early is harmless and is the plan's own order.
**Measured in the tree at the time of writing: all three arm-blocking checks FAIL** (no
`CONFORMANCE_RUNNER_IMAGE` in `conformance/versions.env`; `chainsaw-job.yaml` is still the
skeleton with an empty `workspace` emptyDir and the stock chainsaw image; `ci/conformance.sh`
names the trigger kubeconfig only in its TODO comment). So on a live run today the script
would provision the secret and then **refuse to arm `CLUSTER_AVAILABLE`**, naming each
missing item — which is the intended behaviour, not a defect. §3 items 1, 2 and 4 are the
work that unblocks it.
*Both writes are verified the way `set-ci-token.sh` learned to.* `curl -sS` exits 0 on an
HTTP error, so status alone proves nothing: each write checks `%{http_code}` **and** reads
the object back. For the secret that is presence-by-name only (Gitea never returns secret
values) and the script says so rather than implying the content was checked; for the variable
it asserts the value reads back as exactly `true`.
*Noted, not fixed at the time (since fixed on this branch).* `infra/gitea/README.md`'s
detector table still named `GITEA_SETTINGS_TOKEN` — the illegal name PR #15 replaced with
`SETTINGS_API_TOKEN`. The second commit on this branch corrects the README's table and its
closing "remaining step" line, and records what `ci/gitea-settings.sh` actually does
(`TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"` — the old name survives only as a
local-run environment variable, never as CI wiring, because the secret that would feed it
cannot be created at all). Documentation only, no behaviour change. Still NOT fixed, because
it is outside this PR's diff: `ci/gitea-settings.sh:45` instructs the reader to create a repo
secret `GITEA_SETTINGS_TOKEN`, which is impossible.
*Rework, 2026-09-29.* This branch originally appended ~99 lines to the tail of
`DECISIONS-LOG.md`. That is the hunk every concurrent branch collided on and the sole reason
PR #19 was `mergeable: false`. The branch was rebuilt on today's `main` with the
`DECISIONS-LOG.md` change dropped entirely, and this file is that handoff moved into
`decisions/`. The changes to `infra/gitea/**` are byte-identical to the original three
commits; the commit that contained only the log append was dropped and its substance folded
in below.
## What I verified
*Of the script, before it stores anything:* it PROVES the credential narrow — `auth whoami`
must return `system:serviceaccount:conformance:conformance-trigger`; a real `get jobs`
round-trip must succeed; then an `auth can-i` matrix asserts BOTH halves — granted
(`create/get jobs`, `get pods`, `get pods/log`) and **denied** (`delete jobs`, `create
pods/exec`, `get secrets`, `create jobs` in `default`, `get nodes`,
`'*' '*' --all-namespaces`). A mismatch aborts before anything is stored, and the message
says explicitly **not** to widen `conformance/incluster/rbac.yaml` to make it pass. That is a
property of the code as read, not of an execution.
*Of PR #19's CI, measured rather than assumed — and it contradicts the brief.* The brief for
this work stated this PR's CI would FAIL `approval-policy` because `infra/gitea/**` is
security-relevant. Measured on the PR head commit's `/statuses`:
gate / gate (pull_request) success Successful in 1m34s
gate / conformance (pull_request) pending Blocked by required conditions
`.gitea/workflows/` on `main` contains only `gate.yml` and the dormant `merge-gate.yaml`.
**No path-scoped approval policy exists**, so nothing machine-enforces "security-relevant
paths need a human"; that rule rests entirely on `required_approvals: 1` plus a reviewer
actually reading the diff. Another instance of the CLAUDE.md §2b failure mode — a control
believed to exist because a brief said so. If the policy is intended, it is missing and needs
its own change. And per this repo's own rule, that green `gate` tick is **not** evidence this
script works: the gate never executes `infra/gitea/**`, and the L2/L3 stage skipped.
*Gate on the reworked branch (pinned toolbox, from the agent worktree, `DEV_KUBECONFIG=/dev/null`
plus the `ENGINE` bind-mount shim per CLAUDE.md §3):* `MERGE GATE: UNDETERMINED`, **rc 3**.
Diff base `origin/main @ c59fe9f923c4` ... this branch's head; touched requirement resolved as
`R-ENV-01`. `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden` and `l1` all
green; the gate decision names exactly one stage that did not run — `gitea-settings`
(`SETTINGS_API_TOKEN` is a credential this agent does not hold, unwired repo-wide). The
`conformance` stage also reported SKIPPED (`CLUSTER_AVAILABLE!=true`, Phase 0). The new
`decisions-log` stage — merged to `main` as PR #24 after this rework began — validated this very
file: "archive frozen, 2 entries well-formed", with `DECISIONS-LOG.md` unchanged and matching
`decisions/ARCHIVE.sha256`. rc 3 is UNDETERMINED, not green, and is not reported as green here.
## What I did NOT verify
**The script has never executed.** Not one line of it has run against Gitea or against the
cluster. Only `bash -n` was run (clean); `shellcheck` is not on the host and there is no
shellcheck stage in the gate.
- The Gitea variables API shape (POST-creates / PUT-updates, body field `value` not `data`)
is from **documentation, not measurement**. The script tries POST then falls back to PUT,
prints the response body on failure, and treats the read-back as the authority.
- The Kyverno readiness field (`.status.ready` vs. a Ready condition) is handled both ways
and treats "neither present" as a block rather than assuming ready — but which form this
cluster actually reports is **unmeasured**.
- Whether the kubeconfig's embedded server URL is reachable from the runner pod **cannot be
known from here** — hence `--server`, and a hard refusal on a loopback address.
- No credential was minted, no Actions secret was created, `vars.CLUSTER_AVAILABLE` was not
set, and no `can-i` matrix was ever run against a real token. The narrowness argument above
is read from the RBAC and the script, not observed.
- The gate proves only its L0/L1 offline subset. L2/L3 conformance did not run, so nothing
here is evidence about runtime behaviour.
@@ -0,0 +1,82 @@
---
date: 2026-09-29
branch: chore/decisions-log-conflict-free
title: The handoff log was serialising every parallel branch — it is a directory now
---
## What changed
`DECISIONS-LOG.md` is frozen and the handoff log is now a directory, `decisions/`, with one
file per entry (`YYYY-MM-DD-<slug>.md`). Concurrent branches write disjoint paths, so there
is nothing for git to merge.
The problem was not any particular change. CLAUDE.md §7 told every agent to append a handoff,
and appending always landed on the *same last hunk* of one 5,228-line file. Two branches that
both did it could not auto-merge; N branches cost N-1 rebases, and each rebase invalidated the
gate run before it. The agents work in isolated worktrees precisely so they don't block each
other, and this one file undid that.
- `decisions/README.md` — the convention, the template, and how to read the log. No index file
and no sequence number, deliberately: an index is a shared mutable file (the thing that just
failed) and a counter has two agents racing for the same number. The directory listing is the
index.
- `DECISIONS-LOG.md` — a "FROZEN ARCHIVE" header prepended; content otherwise untouched. Its
SHA-256 is pinned in `decisions/ARCHIVE.sha256`.
- `ci/decisions-log.sh` — a new gate stage. Asserts the archive matches its pin, and that every
entry is named `YYYY-MM-DD-<slug>.md` with a real date, carries `date`/`branch`/`title`
frontmatter agreeing with its filename, and has all three of §7's sections with a real body
under each. Exit 0 or 1 only; it has no skip path by construction, because it reads only files
in the tree under test and needs no tool and no cluster.
- `ci/gate.sh` — runs the stage; both `fail` and (impossible) `skip` set `BLOCK`. There is no
`REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard.
- `ci/tests/decisions-log.test.sh` — 22 assertions, new.
- `ci/tests/gate-decision.test.sh` — 3 new assertions for the stage's wiring, **plus a
pre-existing bug fixed**: its stub list never included `gitea-settings`, so that stage's
script was absent from the stub CI dir, `run_stage` recorded exit 127 as a FAILED stage in
every case, and 15 of 47 assertions failed on `main`. The suite was measuring its own missing
stub rather than the decision logic.
- `CLAUDE.md` §7 rewritten; §1's "stale runbook wording" caveat extended to the log.
- `ci/Makefile`, `ci/README.md`, `.gitea/workflows/README.md` — the stage named where stages are
named.
A guard rather than a paragraph, because the old habit is written into the runbooks and into
every agent that has read the log, and getting it wrong does not fail: it produces a clean-looking
commit that re-creates the conflict on *other people's* branches, later, misattributed.
## What I verified
Measured against the live Gitea (`GET /api/v1/repos/Home-Network/home-network-k3s-impl/pulls`,
2026-09-29), not inferred from the tree:
#22 fix/crafty-oauth2-proxy-signin mergeable=False
#20 feat/conformance-job-template mergeable=False
#19 feat/conformance-trigger-kubeconfig mergeable=False
#18 feat/conformance-runner-image mergeable=False
#17 feat/ci-is-the-approver mergeable=True
`git merge-tree --write-tree origin/main origin/<branch>` for each named
`CONFLICT (content): DECISIONS-LOG.md` and **no other conflicting path** in all four. #17 is
clean and touched no log entry. So the log's storage format was the whole of the blockage.
Tests: `ci/tests/decisions-log.test.sh` **PASS (22 assertions)**, including the one that
matters — an append to `DECISIONS-LOG.md` exits non-zero and the message names both the frozen
archive and the file to write instead. `ci/tests/gate-decision.test.sh` **PASS (50 assertions)**,
up from **FAIL (15 failed, 32 passed)** on `main` before the stub fix.
Gate: see the run quoted in the PR description.
## What I did NOT verify
- **Nothing was applied and nothing was merged.** No `kubectl`, no `flux reconcile`. This change
touches only CI and documentation; it has no cluster-visible effect at all.
- **L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), as always. Irrelevant to this
change's content but stated so the gate result is not over-read.
- **I have not watched a Gitea PR check run this stage.** The stage is proven by its own unit
test and by the gate locally; that the `incluster-dind` runner executes it is unobserved here.
A skipped Gitea job still reports `success`, so read the job log, not the tick.
- **The four hung PRs are not yet fixed by this commit.** They still carry their
`DECISIONS-LOG.md` appends and will still conflict until each is rebased onto this change with
its append converted to a `decisions/` entry. That is follow-up work, in flight separately.
- **`runbooks/` and older entries still say "append to DECISIONS-LOG.md"** in places. I did not
sweep them. The gate now rejects the action, so the stale wording misleads but cannot cause
the damage; it joins the existing tracked work on stale `git push origin main` wording.
+1
View File
@@ -0,0 +1 @@
b16e8cb7ea12720bc7c3a77a496211e3204534c5e6600ba748fb9ab14fa21c26 DECISIONS-LOG.md
+101
View File
@@ -0,0 +1,101 @@
# `decisions/` — the handoff log, one file per entry
This directory **is** the decision log. `../DECISIONS-LOG.md` is the frozen archive of
everything written before 2026-09-29 and is never edited again (see "The archive is
frozen" below).
## Why this exists
`DECISIONS-LOG.md` was append-only in a single file, and every agent appended to its
**tail**. Two branches that both append hit the same last hunk, so git cannot
auto-merge them: the second one to be reviewed conflicts, and so does the third, and
the fourth. Measured on 2026-09-29: **four of the five open PRs were unmergeable, and
`DECISIONS-LOG.md` was the only conflicting file in all four**:
#22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md
#20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md
#19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
#18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md
#17 feat/ci-is-the-approver clean (it touched no log entry)
Nothing was wrong with any of those changes. The log's *storage format* serialised work
that was otherwise parallel, and it got worse with every concurrent branch: N branches
produce N-1 rebases, each of which invalidates any gate run that preceded it.
The fix is the obvious one for concurrent appenders: **stop sharing a file**. Each
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
nothing for git to merge and nothing to conflict. This scales to any number of parallel
branches with zero coordination — which is the point, since the agents are deliberately
isolated from each other in separate worktrees and cannot coordinate.
There is deliberately **no index file** and no counter. An index would be a shared
mutable file — exactly the thing that just failed — and a sequence number would have
two agents racing for `0043`. The directory listing is the index.
## Writing an entry
One file per handoff, named:
decisions/YYYY-MM-DD-<slug>.md
`<slug>` is lowercase `[a-z0-9-]`. **Use your branch name's last segment** — it is
already unique per unit of work, which is precisely the property the filename needs.
A branch `fix/crafty-oauth2-proxy-signin` handed off on 2026-09-29 writes:
decisions/2026-09-29-crafty-oauth2-proxy-signin.md
The file is YAML frontmatter followed by three required sections:
```markdown
---
date: 2026-09-29
branch: fix/crafty-oauth2-proxy-signin
title: The Keycloak gate works — Crafty's own login was the wall
---
## What changed
Prose. What you actually did, and why.
## What I verified
The measurements, with their numbers. Quote the field you read, not the prose you
believed. Name the gate result and its exit code.
## What I did NOT verify
Everything else. An honest "UNVERIFIED" is worth more than an optimistic "green" —
this repo has been burned by the difference more than once.
```
All three headings are **required and enforced** (`ci/decisions-log.sh`). They are
CLAUDE.md §7's contract; making them a checked structure is what stops an entry
degrading into "done ✅".
## Reading the log
The whole log, oldest first:
ls decisions/[0-9]*.md | sort
The current PM handoff — the newest entry. Ties within a day are broken by what landed
on `main` last, which is the honest answer, so ask git rather than the filename:
git log --diff-filter=A --format='%H' --name-only -- 'decisions/[0-9]*.md' \
| grep '^decisions/' | head -1
For a quick skim of who said what:
grep -H '^title:' decisions/[0-9]*.md | sort
## The archive is frozen
`DECISIONS-LOG.md` is **read-only history**. Its SHA-256 is pinned in
`decisions/ARCHIVE.sha256` and `ci/decisions-log.sh` fails the merge gate if the file
changes. That is not ceremony: without it, the next agent to read `runbooks/` or an old
CLAUDE.md habit appends to the tail again and re-creates the conflict for everybody
else, silently, one PR at a time.
If the archive genuinely must change (a leaked credential to redact, say), that is a
deliberate, reviewed act: change it, run `sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256`,
and say in your handoff entry why. Re-pinning is visible in the diff. Appending is not.
+69 -3
View File
@@ -102,7 +102,19 @@ Behaviour, proven by test rather than assumed:
|---|---|---|
| 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 |
| no `SETTINGS_API_TOKEN`, or Gitea unreachable | 3 | skips **loudly**; never a pass, never a block |
> **The secret is `SETTINGS_API_TOKEN`.** An earlier draft of this table named
> `GITEA_SETTINGS_TOKEN`, which **cannot exist as a Gitea secret at all** — Gitea reserves the
> `GITEA_` prefix and returns HTTP 400 `invalid secret name`. That is the whole 2026-09-29
> inert-detector story, so the wrong name surviving here is worth correcting rather than
> reading past. Measured in `ci/gitea-settings.sh`:
>
> TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
>
> so `GITEA_SETTINGS_TOKEN` is still honoured — but **only as an environment variable for a
> LOCAL run**. It can never be the CI wiring, because the secret that would feed it cannot be
> created. In CI, only `SETTINGS_API_TOKEN` exists.
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
@@ -112,6 +124,59 @@ the stage goes **green on a real PR**, and only then set `REQUIRE_GITEA_SETTINGS
**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.
## `conformance-trigger-kubeconfig.sh` — the narrow L2/L3 trigger credential
Implements `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 (= §5 step 7): mint a
ServiceAccount token for `conformance/conformance-trigger`, wrap **only that token** in a
kubeconfig, store it as the Actions secret `CONFORMANCE_TRIGGER_KUBECONFIG`, and — separately,
and only on request — set `vars.CLUSTER_AVAILABLE=true`.
./infra/gitea/conformance-trigger-kubeconfig.sh --dry-run # measure only; writes nothing
./infra/gitea/conformance-trigger-kubeconfig.sh # mint + store the secret
./infra/gitea/conformance-trigger-kubeconfig.sh --arm-cluster-available # + the variable
Never a cluster-admin kubeconfig, never a copy of the operator's own. The token is never
printed: it goes from the TokenRequest API into a 0600 file, into the request **body file**
(`--data @file`, so not even into `ps`), and nowhere else. The script refuses to run under
`set -x`.
**Its preconditions are split in two, and the split is the point.**
| Class | Checks | Blocks |
|---|---|---|
| **mint-blocking** (cluster) | SA/Role/RoleBinding exist; ClusterPolicy `conformance-trigger-job-shape` is present, `Enforce`, `failurePolicy: Fail`, Ready | the **mint** — nothing is created |
| **arm-blocking** (repo) | digest-pinned `CONFORMANCE_RUNNER_IMAGE`; `chainsaw-job.yaml` has an init-container and the admitted image; `ci/conformance.sh` names the trigger kubeconfig in **code, not a comment** | only `CLUSTER_AVAILABLE` |
Why mint-blocking: Kubernetes puts **no `use` check on a Job's `serviceAccountName`**, so a
token that can `create jobs` in `conformance` can run arbitrary code as the privileged
`conformance-runner` SA. RBAC cannot close that; the Kyverno policy is the boundary. Minting
while it is absent or in `Audit` hands CI the privileged identity, so the script will not do it.
Why arm-blocking is separate: storing the secret before the runner image / Job template / CI
trigger flow exist is harmless (the job is gated off), and is the plan's intended order. So
running this today provisions the secret and then **refuses to arm the variable**, naming the
missing item.
**`CLUSTER_AVAILABLE` is not a "turn conformance on" switch.** Measured 2026-09-29: set with no
trigger path, `run.sh` scaffold-skipped all 54 tests at rc 0 and `ci/conformance.sh` printed
"L2/L3 conformance passed" — a fake green on an enforcing required check. `ci/conformance.sh`
now blocks that case, so arming early does not enable L2/L3; it only breaks every PR. Hence:
`--arm-cluster-available` plus all preconditions green plus a typed confirmation on a TTY, or
nothing happens.
Both writes are verified the way `set-ci-token.sh` learned to: check `%{http_code}` **and**
read the object back. For the secret that proves presence by name only (Gitea never returns
secret values); for the variable it proves the value is exactly `true`, which is what
`gate.yml` compares against.
The token **expires** — it is a TokenRequest token, and the script decodes the JWT `exp` claim
to report the real expiry rather than trusting `--duration` (the API server caps it). Re-run to
rotate. After expiry the trigger gets 401 and the stage fails loudly rather than skipping.
`--server` exists because the embedded API-server URL must be reachable **from the CI runner
pod** — the VIP — which is not necessarily the address your workstation uses. The script
refuses a loopback server outright.
### 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
@@ -124,8 +189,9 @@ These scripts encode the desired state and can re-assert it; `ci/gitea-settings.
**detector** that turns divergence from a silent, invisible failure into a loud one. It closes the
root cause of the 2026-09-29 session.
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.
It is only as good as its wiring: **until `SETTINGS_API_TOKEN` exists it skips**, and a skipped
detector detects nothing. (That secret was stored and verified in PR #15; this sentence predates
it. The name matters — see the note under the behaviour table above.)
## Historical sequence (kept for context)
+575
View File
@@ -0,0 +1,575 @@
#!/usr/bin/env bash
# infra/gitea/conformance-trigger-kubeconfig.sh
#
# OPERATOR script for docs/plans/l2-l3-conformance-ci-wiring.md §3 item 6 (= §5 step 7):
# provision the NARROW CI trigger credential for L2/L3 conformance, and — only when the
# rest of the trigger path actually exists — arm `vars.CLUSTER_AVAILABLE`.
#
# What it does, in order:
# 1. measures the preconditions (cluster-side AND repo-side) instead of assuming them
# 2. mints a bounded-lifetime ServiceAccount token for `conformance-trigger` in ns
# `conformance` (kubectl create token -> TokenRequest API)
# 3. assembles a kubeconfig containing ONLY that token + the cluster CA + the API server
# 4. PROVES the kubeconfig is the narrow identity (auth whoami + an allow/deny matrix)
# 5. stores it as the Gitea Actions repo secret CONFORMANCE_TRIGGER_KUBECONFIG, and
# VERIFIES the store by reading the secret list back
# 6. sets vars.CLUSTER_AVAILABLE=true ONLY under --arm-cluster-available, only with every
# precondition green, and only after a typed confirmation. Otherwise it REFUSES.
#
# NOTHING SECRET IS EVER PRINTED. The token exists in a shell variable and in a 0600 file
# under a 0700 temp dir that is removed on exit; it reaches the Gitea API in a request-body
# FILE (`--data @file`), never in argv, never on stdout. This script prints names, byte
# lengths, HTTP status codes, the token's expiry timestamp, and yes/no authorization answers.
#
# ----------------------------------------------------------------------------------------
# THREE HARD-WON LESSONS THIS SCRIPT IS BUILT AROUND
# ----------------------------------------------------------------------------------------
# 1. GITEA RESERVES THE `GITEA_` PREFIX FOR SECRET NAMES.
# `PUT .../actions/secrets/GITEA_ANYTHING` returns HTTP 400 {"message":"invalid secret
# name"}. That is why set-ci-token.sh stores SETTINGS_API_TOKEN and not
# GITEA_SETTINGS_TOKEN. `CONFORMANCE_TRIGGER_KUBECONFIG` is legal, and is the exact name
# .gitea/workflows/{gate.yml,merge-gate.yaml} already reference — do not "tidy" it.
#
# 2. `curl -sS` EXITS 0 ON AN HTTP ERROR. The first version of set-ci-token.sh sent its PUT
# to /dev/null and printed "stored" unconditionally; the secret never existed and nobody
# knew for hours. So every write here captures `-w '%{http_code}'`, checks it, AND reads
# the object back afterwards. A store is not claimed unless the read-back shows it.
#
# 3. `CLUSTER_AVAILABLE` IS A LOADED GUN — SEE THE 2026-09-29 CORRECTION IN THE PLAN.
# `conformance/run.sh` exits 0 in scaffold mode with all 54 tests `<skipped>` when it
# cannot reach a cluster. Measured on 2026-09-29: with CLUSTER_AVAILABLE=true and no
# trigger path, ci/conformance.sh printed "[ ok ] L2/L3 conformance passed" at rc 0 — a
# FAKE GREEN on an enforcing required status check. ci/conformance.sh now BLOCKS that
# case (exit 1), so today the variable does not enable L2/L3: it only changes which way
# the stage fails. Arming it before §3 items 1-4 land therefore buys nothing and breaks
# every PR. This script refuses to do it.
#
# ----------------------------------------------------------------------------------------
# WHY THE PRECONDITIONS ARE SPLIT INTO TWO CLASSES (deliberate, and load-bearing)
# ----------------------------------------------------------------------------------------
# MINT-BLOCKING (cluster-side). Kubernetes places NO `use` check on a Job's
# `serviceAccountName`, so a token with plain `create jobs` in ns `conformance` can create a
# Job running as the PRIVILEGED `conformance-runner` SA with an arbitrary command. The
# trigger Role is only the necessary half; the sufficient half is the Kyverno ClusterPolicy
# `conformance-trigger-job-shape` (platform/kyverno/admission/), which must be present AND
# Enforce AND Ready. Minting this token while that policy is absent or in Audit hands CI a
# privileged identity. So those checks block the MINT itself — nothing is created if they
# fail. (See conformance/incluster/rbac.yaml's trigger header and plan §4.)
#
# ARM-BLOCKING (repo-side). The runner image, the Job template's init-container, and
# ci/conformance.sh's in-cluster trigger flow (plan §3 items 1, 2, 4) are what make a real
# L2/L3 run possible. Storing the kubeconfig secret without them is harmless — the
# conformance job is gated on the variable and never starts — and the plan explicitly lands
# the secret before the switch. So these checks block ONLY the variable.
#
# Net effect: you can safely run this script today to provision the secret. It will then
# REFUSE to arm the variable, and tell you exactly which repo-side item is missing.
#
# ----------------------------------------------------------------------------------------
# USAGE
# ----------------------------------------------------------------------------------------
# ./infra/gitea/conformance-trigger-kubeconfig.sh --dry-run
# Read-only. Measures every precondition, mints nothing, writes nothing.
# ./infra/gitea/conformance-trigger-kubeconfig.sh [--server URL]
# Mint + store the secret. Does NOT touch CLUSTER_AVAILABLE.
# ./infra/gitea/conformance-trigger-kubeconfig.sh --arm-cluster-available
# The above, then set vars.CLUSTER_AVAILABLE=true after a typed confirmation —
# refused unless every precondition is green.
#
# Options:
# --dry-run measure only; no TokenRequest, no PUT/POST
# --arm-cluster-available additionally set vars.CLUSTER_AVAILABLE=true (guarded)
# --server URL API server URL to embed (default: the operator context's own)
# --context NAME kube context to read the CA/server from (default: k3s-prod)
# --duration D requested token lifetime (default: 720h = 30d)
# -h | --help this text
#
# ON --server: the embedded server URL must be reachable FROM THE CI RUNNER POD, which is
# not necessarily the address your workstation uses. The runner has broad egress and reaches
# the kube-API VIP (per the plan: 192.168.1.49:6443). If your operator kubeconfig points at
# a workstation-only name or a localhost tunnel, pass --server explicitly; the script warns
# on a loopback server but cannot know your topology for you.
#
# ON TOKEN LIFETIME: this is a TokenRequest token and it EXPIRES. The API server may cap the
# requested duration, so the script decodes the JWT `exp` claim and reports the ACTUAL
# expiry rather than trusting --duration. Re-run this script to rotate. After expiry the
# trigger gets 401 and the conformance stage FAILS (loudly) rather than skipping — which is
# the correct direction, but put the expiry date in your calendar.
#
# DELIBERATELY NOT DONE: SOPS-sealing the token into secrets/conformance-trigger.sops.yaml
# (plan §3 item 6's "for reproducibility/rotation"). A bearer token with a 30-day life
# cannot usefully survive the branch -> PR -> review -> merge flow (CLAUDE.md §6), and
# committing one buys re-issuance we get for free by re-running this script against the
# live SA. If the operator still wants it sealed, that is a separate reviewed change.
#
# FORBIDDEN HERE, ON PURPOSE: no cluster-admin kubeconfig, no copy of the operator's own
# kubeconfig, no widening of conformance/incluster/rbac.yaml, no mutating kubectl beyond the
# TokenRequest itself, no `kubectl apply`.
set -euo pipefail
# xtrace would print the token. Refuse rather than leak.
case "$-" in
*x*) echo "FATAL: refusing to run under 'set -x' — xtrace would print the token" >&2; exit 1 ;;
esac
umask 077
HOST=gitea.home.keithjackson.dev
OWNER=Home-Network
REPO=home-network-k3s-impl
# Legal secret name: NOT prefixed GITEA_ (lesson 1), and the name the workflows already use.
SECRET=CONFORMANCE_TRIGGER_KUBECONFIG
VARIABLE=CLUSTER_AVAILABLE
NS=conformance
SA=conformance-trigger
RUNNER_SA=conformance-runner
POLICY=conformance-trigger-job-shape
# The image prefix the admission policy admits. Kept in sync with
# platform/kyverno/admission/conformance-trigger-job-shape.yaml by the P2 check below.
RUNNER_IMAGE_PREFIX='gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:'
KCTX=k3s-prod
DURATION=720h
DRY_RUN=false
ARM=false
SERVER_OVERRIDE=""
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
step() { printf '\n==> %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf ' WARN: %s\n' "$*" >&2; }
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'USAGE'
conformance-trigger-kubeconfig.sh — provision the narrow L2/L3 conformance CI trigger credential.
--dry-run measure every precondition; mint nothing, write nothing (read-only)
--arm-cluster-available additionally set vars.CLUSTER_AVAILABLE=true (guarded + confirmed)
--server URL API server URL to embed (default: the operator context's own;
must be reachable FROM THE CI RUNNER POD — the kube-API VIP)
--context NAME kube context to read the CA/server from (default: k3s-prod)
--duration D requested token lifetime (default: 720h)
-h | --help this text
Read the header of this file before running it: the CLUSTER_AVAILABLE guard, the split between
mint-blocking and arm-blocking preconditions, and why nothing secret is ever printed.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--arm-cluster-available) ARM=true ;;
--server) SERVER_OVERRIDE="${2:?--server needs a URL}"; shift ;;
--context) KCTX="${2:?--context needs a name}"; shift ;;
--duration) DURATION="${2:?--duration needs a value}"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown argument: $1 (try --help)" ;;
esac
shift
done
if $DRY_RUN && $ARM; then die "--dry-run and --arm-cluster-available are contradictory"; fi
for t in kubectl jq curl git base64; do
command -v "$t" >/dev/null || die "missing required tool: $t"
done
WORK="$(mktemp -d)"
KC="${WORK}/conformance-trigger.kubeconfig"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# ========================================================================================
# 0. Gitea credentials — from the credential helper git already has for this host, exactly
# as branch-protection.sh / set-ci-token.sh / require-gate.sh do. Never in argv: curl
# reads them from a --config file descriptor.
# ========================================================================================
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")"
unset creds
[ -n "$U" ] && [ -n "$P" ] || die "no stored credential for ${HOST} (git credential fill returned nothing)"
basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
API="https://${HOST}/api/v1/repos/${OWNER}/${REPO}"
# ========================================================================================
# 1. PRECONDITIONS — measured, never assumed (CLAUDE.md §2b: query the thing itself).
# Two independent tallies, per the split documented in the header.
# ========================================================================================
MINT_FAIL=0
ARM_FAIL=0
pass() { printf ' [ ok ] %s\n' "$*"; }
fail_mint() { printf ' [MINT-BLOCK] %s\n' "$*" >&2; MINT_FAIL=$((MINT_FAIL + 1)); }
fail_arm() { printf ' [ARM-BLOCK] %s\n' "$*" >&2; ARM_FAIL=$((ARM_FAIL + 1)); }
step "preconditions — cluster side (these block the MINT: without them the token IS privileged)"
kubectl --context "$KCTX" get --raw /version >/dev/null 2>&1 \
|| die "cannot reach the API server via context '${KCTX}' — nothing below can be measured"
pass "API server reachable via context ${KCTX}"
# C1: the trigger identity exists (created by Flux from conformance/incluster/rbac.yaml via
# clusters/base/conformance.yaml). We print object names only, never contents.
if kubectl --context "$KCTX" -n "$NS" get serviceaccount "$SA" -o name >/dev/null 2>&1; then
pass "ServiceAccount ${NS}/${SA} exists"
else
fail_mint "ServiceAccount ${NS}/${SA} is ABSENT — is the 'conformance' Flux Kustomization reconciled?"
fi
for kind in role rolebinding; do
if kubectl --context "$KCTX" -n "$NS" get "$kind" "$SA" -o name >/dev/null 2>&1; then
pass "${kind} ${NS}/${SA} exists"
else
fail_mint "${kind} ${NS}/${SA} is ABSENT — the token would authenticate but authorize nothing"
fi
done
# C2: THE SECURITY BOUNDARY. Present, Enforce, and Ready. Anything else and we do not mint.
# `validationFailureAction: Audit` is not a boundary; an unready policy is not a boundary.
if pol="$(kubectl --context "$KCTX" get clusterpolicy "$POLICY" -o json 2>/dev/null)"; then
action="$(jq -r '.spec.validationFailureAction // .spec.rules[0].validate.failureAction // "UNSET"' <<<"$pol")"
failpol="$(jq -r '.spec.failurePolicy // "UNSET"' <<<"$pol")"
# Kyverno moved readiness from .status.ready to a Ready condition; accept either, and
# treat "neither key present" as UNMEASURABLE -> block (fail closed, do not assume ready).
ready="$(jq -r '
if (.status.ready != null) then (.status.ready | tostring)
elif (.status.conditions != null) then
((.status.conditions[] | select(.type == "Ready") | .status) // "UNKNOWN")
else "UNKNOWN" end' <<<"$pol")"
info "ClusterPolicy ${POLICY}: validationFailureAction=${action} failurePolicy=${failpol} ready=${ready}"
[ "$action" = "Enforce" ] || fail_mint "ClusterPolicy ${POLICY} is '${action}', not Enforce — the trigger would be unbounded"
[ "$failpol" = "Fail" ] || fail_mint "ClusterPolicy ${POLICY} failurePolicy='${failpol}', not Fail — a Kyverno outage would admit arbitrary Jobs"
case "$ready" in
true|True) pass "ClusterPolicy ${POLICY} reports Ready" ;;
*) fail_mint "ClusterPolicy ${POLICY} does not report Ready (got '${ready}') — refusing to assume it is loaded" ;;
esac
else
fail_mint "ClusterPolicy ${POLICY} is ABSENT — plan §4's boundary does not exist; a token with 'create jobs' here can run arbitrary code as ${RUNNER_SA}"
fi
step "preconditions — repo side (these block ONLY arming ${VARIABLE}; the secret may be stored first)"
# R1: the runner image is built and digest-pinned. Without it the Job cannot run run.sh at
# all (the stock chainsaw image has no bash/python3/kubectl/git) — plan §3 item 1.
VERSIONS="${REPO_ROOT}/conformance/versions.env"
if grep -Eq '^[[:space:]]*CONFORMANCE_RUNNER_IMAGE=.*@sha256:[0-9a-f]{64}' "$VERSIONS" 2>/dev/null; then
pass "CONFORMANCE_RUNNER_IMAGE is digest-pinned in conformance/versions.env"
else
fail_arm "conformance/versions.env has no digest-pinned CONFORMANCE_RUNNER_IMAGE (plan §3 item 1) — the Job cannot run ./run.sh"
fi
# R2: the Job template is the real thing, not the skeleton: the admitted image AND an
# init-container that populates the empty `workspace` volume with the suite at the PR SHA
# (plan §3 item 2). A template whose image the policy will not admit is a guaranteed denial.
JOBTPL="${REPO_ROOT}/conformance/incluster/chainsaw-job.yaml"
if grep -q 'initContainers' "$JOBTPL" 2>/dev/null; then
pass "chainsaw-job.yaml declares an initContainers block (workspace population)"
else
fail_arm "chainsaw-job.yaml has no initContainers — 'workspace' is an empty emptyDir and the suite is never delivered (plan §3 item 2)"
fi
if grep -q "$RUNNER_IMAGE_PREFIX" "$JOBTPL" 2>/dev/null; then
pass "chainsaw-job.yaml uses the digest-pinned image the admission policy admits"
else
fail_arm "chainsaw-job.yaml does not use ${RUNNER_IMAGE_PREFIX}… — every triggered Job would be DENIED by ${POLICY}"
fi
# R3: ci/conformance.sh actually drives the in-cluster Job. Today it mentions
# CONFORMANCE_TRIGGER_KUBECONFIG only in a TODO comment and otherwise runs run.sh LOCALLY in
# the CI job — where there is no credential at all (the runner sets
# automountServiceAccountToken: false by design), so it can only scaffold-skip. Hence the
# check must ignore comment lines: a mention in prose is not an implementation.
#
# ONE awk, NOT `grep -v … | grep -q …`: under `set -o pipefail` a successful `grep -q` exits
# early, the upstream grep takes EPIPE, and the pipeline reports non-zero — which would read
# here as "unwired" exactly when the seam IS wired. A single process cannot have that bug.
CONF_STAGE="${REPO_ROOT}/ci/conformance.sh"
if awk '!/^[[:space:]]*#/ && /CONFORMANCE_TRIGGER_KUBECONFIG/ { found = 1 } END { exit !found }' \
"$CONF_STAGE" 2>/dev/null; then
pass "ci/conformance.sh references CONFORMANCE_TRIGGER_KUBECONFIG in code (in-cluster trigger path wired)"
else
fail_arm "ci/conformance.sh mentions CONFORMANCE_TRIGGER_KUBECONFIG only in comments — the trigger path is UNIMPLEMENTED (plan §3 item 4); arming ${VARIABLE} now only changes which way the stage fails"
fi
info ""
info "precondition tally: mint-blocking failures=${MINT_FAIL} arm-blocking failures=${ARM_FAIL}"
if [ "$MINT_FAIL" -gt 0 ]; then
cat >&2 <<EOF
REFUSING TO MINT. ${MINT_FAIL} cluster-side precondition(s) failed.
Minting a token for ${NS}/${SA} while the admission boundary is absent/Audit/unready hands CI
an identity that can create a Job running as ${RUNNER_SA} (nodes/patch, pods/exec,
namespaces/patch) with an arbitrary command. That is precisely the escalation
docs/plans/l2-l3-conformance-ci-wiring.md §4 exists to close, and RBAC cannot close it.
Fix the cluster side first (reconcile platform/kyverno/admission/ and conformance/incluster/,
confirm the ClusterPolicy is Enforce + Ready), then re-run. Nothing was created.
EOF
exit 1
fi
if $ARM && [ "$ARM_FAIL" -gt 0 ]; then
cat >&2 <<EOF
REFUSING TO ARM ${VARIABLE}. ${ARM_FAIL} repo-side precondition(s) failed.
Measured 2026-09-29: with ${VARIABLE}=true and no working trigger path, conformance/run.sh
scaffold-skips all 54 tests at rc 0 and ci/conformance.sh used to print
"[ ok ] L2/L3 conformance passed" — a fake green on an enforcing required status check. The
guard now in ci/conformance.sh turns that into exit 1 instead, so arming today does not
enable L2/L3: it breaks every PR while proving nothing.
Land plan §3 items 1, 2 and 4 first. Re-run WITHOUT --arm-cluster-available to provision the
secret in the meantime — that part is safe and is step 6's intended order.
EOF
exit 1
fi
if $DRY_RUN; then
step "DRY RUN — stopping here. Nothing was minted, nothing was written."
info "secret that WOULD be written: ${SECRET} (repo ${OWNER}/${REPO})"
info "variable that WOULD be set: none — ${VARIABLE} is never touched in --dry-run"
# Read-only visibility into what is already there. Gitea never returns secret VALUES.
names="$(basic "${API}/actions/secrets" | jq -rc '[.. | objects | select(has("name")) | .name] | unique')" || true
info "existing Actions secret names: ${names:-<unreadable>}"
cur="$(basic -o "${WORK}/var.json" -w '%{http_code}' "${API}/actions/variables/${VARIABLE}")" || true
if [ "$cur" = 200 ]; then
info "existing ${VARIABLE} = $(jq -r '.data // "<no data field>"' "${WORK}/var.json")"
else
info "existing ${VARIABLE}: not set (HTTP ${cur})"
fi
exit 0
fi
# ========================================================================================
# 2. MINT the ServiceAccount token (TokenRequest). This is the only write this script makes
# against the cluster, and it creates no durable object.
# ========================================================================================
step "minting a ServiceAccount token for ${NS}/${SA} (requested duration ${DURATION})"
TOKEN="$(kubectl --context "$KCTX" -n "$NS" create token "$SA" --duration="$DURATION")" \
|| die "kubectl create token failed for ${NS}/${SA}"
[ -n "$TOKEN" ] || die "kubectl create token returned an empty token"
info "got a ${#TOKEN}-character token (value NOT shown)"
# Report the ACTUAL expiry from the JWT rather than trusting --duration: the API server caps
# TokenRequest lifetimes (--service-account-max-token-expiration) and silently shortens.
jwt_exp() {
local payload pad b64
payload="$(cut -d. -f2 <<<"$1")"
pad=$(( (4 - ${#payload} % 4) % 4 ))
b64="$(tr '_-' '/+' <<<"$payload")$(printf '%*s' "$pad" '' | tr ' ' '=')"
printf '%s' "$b64" | base64 -d 2>/dev/null | jq -r '.exp // empty' 2>/dev/null || true
}
exp="$(jwt_exp "$TOKEN")"
if [ -n "$exp" ]; then
info "token expires: $(date -u -d "@${exp}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "epoch ${exp}") — re-run this script to rotate"
else
warn "could not decode the token's exp claim; lifetime is UNVERIFIED (requested ${DURATION})"
fi
# ========================================================================================
# 3. ASSEMBLE the kubeconfig — SA token + cluster CA + server. Nothing else.
#
# The CA and the server URL are read from the operator's context, but the operator's
# client certificate/key are NEVER copied: only .cluster fields are extracted. `config
# view --raw` output is held in a variable and discarded, never printed.
# Built with jq (not `kubectl config set-credentials --token=…`) so the token never
# appears in argv where `ps` can see it.
# ========================================================================================
step "assembling the kubeconfig (SA token only — never cluster-admin, never the operator's)"
raw="$(kubectl --context "$KCTX" config view --raw --minify -o json)" \
|| die "could not read context '${KCTX}' from the operator kubeconfig"
SERVER="$(jq -r '.clusters[0].cluster.server // empty' <<<"$raw")"
CADATA="$(jq -r '.clusters[0].cluster["certificate-authority-data"] // empty' <<<"$raw")"
CAFILE="$(jq -r '.clusters[0].cluster["certificate-authority"] // empty' <<<"$raw")"
INSECURE="$(jq -r '.clusters[0].cluster["insecure-skip-tls-verify"] // false | tostring' <<<"$raw")"
unset raw
[ -n "$SERVER" ] || die "context '${KCTX}' has no cluster.server"
if [ -n "$SERVER_OVERRIDE" ]; then
info "overriding server ${SERVER} -> ${SERVER_OVERRIDE}"
SERVER="$SERVER_OVERRIDE"
fi
case "$SERVER" in
*localhost*|*127.0.0.1*|*://0.0.0.0*)
die "server '${SERVER}' is loopback — the CI runner pod cannot reach it. Pass --server with the address the runner uses (the kube-API VIP)." ;;
esac
if [ -z "$CADATA" ] && [ -n "$CAFILE" ]; then
[ -r "$CAFILE" ] || die "certificate-authority file '${CAFILE}' is not readable"
CADATA="$(base64 -w0 <"$CAFILE")"
fi
[ -n "$CADATA" ] || die "no cluster CA available for '${KCTX}' (insecure-skip-tls-verify=${INSECURE}); refusing to emit a kubeconfig that trusts anything"
[ "$INSECURE" = "false" ] || die "context '${KCTX}' sets insecure-skip-tls-verify; refusing to propagate that into CI"
info "server: ${SERVER}"
info "CA: ${#CADATA} base64 characters embedded (public certificate, not a secret)"
jq -n --arg srv "$SERVER" --arg ca "$CADATA" --arg tok "$TOKEN" --arg ns "$NS" --arg sa "$SA" '
{
apiVersion: "v1",
kind: "Config",
clusters: [ { name: "conformance", cluster: { server: $srv, "certificate-authority-data": $ca } } ],
users: [ { name: $sa, user: { token: $tok } } ],
contexts: [ { name: "conformance", context: { cluster: "conformance", user: $sa, namespace: $ns } } ],
"current-context": "conformance"
}' >"$KC"
chmod 600 "$KC"
info "kubeconfig written to the temp dir: $(wc -c <"$KC") bytes (content NOT shown)"
# Prove kubectl can PARSE the file we generated (JSON is valid YAML, but assert it rather
# than assume it) — this prints only the server we already printed.
parsed="$(kubectl --kubeconfig "$KC" config view -o jsonpath='{.clusters[0].cluster.server}')" \
|| die "kubectl cannot parse the generated kubeconfig"
[ "$parsed" = "$SERVER" ] || die "generated kubeconfig parses to server '${parsed}', expected '${SERVER}'"
pass "kubectl parses the generated kubeconfig"
# ========================================================================================
# 4. PROVE it is the narrow identity. Two independent proofs:
# a) who the token authenticates AS (SelfSubjectReview)
# b) an allow/deny matrix (SelfSubjectAccessReview) — both halves matter: "can create
# jobs" proves it works, "cannot get secrets / delete jobs / exec" proves it is not
# something wider that happens to include the trigger rights.
# All read-only. `auth can-i` answers from the API server's own authorizer.
# ========================================================================================
step "verifying the credential is the NARROW trigger identity"
EXPECT_USER="system:serviceaccount:${NS}:${SA}"
if who="$(kubectl --kubeconfig "$KC" auth whoami -o jsonpath='{.status.userInfo.username}' 2>/dev/null)"; then
[ "$who" = "$EXPECT_USER" ] || die "token authenticates as '${who}', expected '${EXPECT_USER}' — WRONG CREDENTIAL, nothing stored"
pass "authenticates as ${who}"
else
warn "'kubectl auth whoami' unavailable (older kubectl / SelfSubjectReview denied) — identity UNVERIFIED by this check; the matrix below still constrains it"
fi
# A real authorization round-trip, not just a review: `list jobs` is granted by the Role.
kubectl --kubeconfig "$KC" -n "$NS" get jobs -o name >/dev/null \
|| die "the minted credential cannot list Jobs in ns ${NS} — the RoleBinding is not effective; nothing stored"
pass "can list Jobs in ns ${NS} (end-to-end authentication + authorization)"
cani() { # cani <expected yes|no> <verb> <resource...>
local want="$1"; shift
local got
got="$(kubectl --kubeconfig "$KC" auth can-i "$@" 2>/dev/null || true)"
case "$got" in
yes|no) : ;;
*) die "auth can-i '$*' returned '${got:-<nothing>}' — cannot measure the identity's rights; refusing to store an unverified credential" ;;
esac
if [ "$got" = "$want" ]; then
pass "can-i $* -> ${got} (expected)"
else
die "can-i $* -> ${got}, expected ${want}. This credential is NOT the narrow trigger identity described in conformance/incluster/rbac.yaml. Nothing stored. Do NOT widen that Role to make this pass — investigate what it is actually bound to."
fi
}
# Granted by the trigger Role (create/get/list/watch jobs; get/list/watch pods + pods/log).
cani yes create jobs.batch -n "$NS"
cani yes get pods -n "$NS"
cani yes get pods/log -n "$NS"
# Explicitly NOT granted — each is a specific escape route the Role's comments call out.
cani no delete jobs.batch -n "$NS" # a trigger that can delete can hide a failed run
cani no create pods/exec -n "$NS" # no execution channel into the runner pod
cani no get secrets -n "$NS"
cani no create jobs.batch -n default # namespaced Role: no reach outside ns conformance
cani no get nodes # the RUNNER has nodes/patch; the trigger must not
cani no '*' '*' --all-namespaces # not cluster-admin, stated as a measurement
# ========================================================================================
# 5. STORE as the Gitea Actions repo secret, then VERIFY BY READING IT BACK (lesson 2).
# The body goes in a 0600 file so the token is not in argv.
# ========================================================================================
step "storing the kubeconfig as repo Actions secret ${SECRET}"
jq -n --rawfile d "$KC" '{data:$d}' >"${WORK}/body.json"
chmod 600 "${WORK}/body.json"
code="$(basic -o "${WORK}/put.out" -w '%{http_code}' -X PUT \
-H 'Content-Type: application/json' --data @"${WORK}/body.json" \
"${API}/actions/secrets/${SECRET}")"
unset TOKEN
rm -f "${WORK}/body.json" "$KC"
if [ "$code" != 201 ] && [ "$code" != 204 ]; then
# Safe to echo: the response body is Gitea's error message, not our payload. HTTP 400
# "invalid secret name" here means the name hit the reserved GITEA_ prefix (lesson 1).
die "PUT ${SECRET} failed: HTTP ${code} -- $(head -c 300 "${WORK}/put.out" 2>/dev/null)"
fi
info "PUT returned HTTP ${code}"
# curl -sS exits 0 on an HTTP error, so the status check above is necessary but NOT
# sufficient: read the object back. Gitea never returns secret VALUES, so this proves
# PRESENCE BY NAME only — say so rather than implying the content was checked.
names="$(basic "${API}/actions/secrets" | jq -rc '[.. | objects | select(has("name")) | .name] | unique')"
grep -q "$SECRET" <<<"$names" \
|| die "PUT reported HTTP ${code} but ${SECRET} is NOT in ${names} — refusing to claim success"
pass "stored and VERIFIED PRESENT by read-back: ${names}"
info "(read-back proves the secret EXISTS; Gitea never returns secret values, so its CONTENT"
info " is proven only by the first CI run that uses it — see the NOTE below.)"
info ""
info "FOR WHOEVER WRITES plan §3 item 4: this secret holds the kubeconfig CONTENT, not a path."
info "The plan's sketch writes 'kubectl --kubeconfig=\$CONFORMANCE_TRIGGER_KUBECONFIG', which"
info "would treat it as a filename. ci/conformance.sh must write the env var into a 0600 temp"
info "file (umask 077, trap-removed) and pass THAT path — an Actions secret is a string, so a"
info "kubeconfig cannot reach CI any other way."
# ========================================================================================
# 6. vars.CLUSTER_AVAILABLE — the loaded gun. Only here, only on request, only confirmed.
# ========================================================================================
if ! $ARM; then
cat <<EOF
==> ${VARIABLE} NOT TOUCHED (no --arm-cluster-available). This is the safe default.
The secret is in place; the conformance job stays gated off until the variable is set. That
is plan §5's order: land items 1-6, prove them, and only then flip step 7's switch.
EOF
else
step "arming vars.${VARIABLE}=true — every precondition above is green"
cat <<EOF
This makes .gitea/workflows/gate.yml's conformance job RUN on every PR. If the
in-cluster trigger path misbehaves, ci/conformance.sh will BLOCK (exit 1) rather than
fake a green — so the failure mode is loud PRs, not silent false confidence. Undo with:
curl -X DELETE .../repos/${OWNER}/${REPO}/actions/variables/${VARIABLE}
EOF
[ -t 0 ] || die "refusing to arm ${VARIABLE} non-interactively — this needs a human confirmation"
printf ' Type exactly "ARM %s" to proceed: ' "$VARIABLE"
read -r confirm
[ "$confirm" = "ARM ${VARIABLE}" ] || die "not confirmed (got '${confirm}') — ${VARIABLE} unchanged. The secret IS stored."
# Gitea's variables API: POST creates, PUT updates, and the body field is `value` — NOT
# `data` as in the secrets API above. Try create, fall back to update. The value "true" is
# not a secret, so response bodies are printed here to make a failure diagnosable.
vcode="$(basic -o "${WORK}/var.out" -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' --data '{"value":"true"}' \
"${API}/actions/variables/${VARIABLE}")"
if [ "$vcode" != 201 ] && [ "$vcode" != 204 ]; then
info "POST returned HTTP ${vcode} ($(head -c 200 "${WORK}/var.out" 2>/dev/null)); trying PUT (variable may already exist)"
vcode="$(basic -o "${WORK}/var.out" -w '%{http_code}' -X PUT \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg n "$VARIABLE" '{name:$n,value:"true"}')" \
"${API}/actions/variables/${VARIABLE}")"
fi
[ "$vcode" = 201 ] || [ "$vcode" = 204 ] \
|| die "could not set ${VARIABLE}: HTTP ${vcode} -- $(head -c 300 "${WORK}/var.out" 2>/dev/null)"
info "write returned HTTP ${vcode}"
# Unlike a secret, a variable's value IS readable — so verify the VALUE, not just presence.
# gate.yml compares `vars.CLUSTER_AVAILABLE == 'true'`, so it must be exactly "true".
gcode="$(basic -o "${WORK}/varget.json" -w '%{http_code}' "${API}/actions/variables/${VARIABLE}")"
[ "$gcode" = 200 ] || die "stored HTTP ${vcode} but GET ${VARIABLE} returned HTTP ${gcode} — refusing to claim success"
val="$(jq -r '.data // .value // empty' "${WORK}/varget.json")"
[ "$val" = "true" ] || die "${VARIABLE} reads back as '${val}', not 'true' — gate.yml compares against 'true' exactly"
pass "${VARIABLE}=${val} stored and VERIFIED by read-back"
fi
cat <<EOF
NEXT — and none of it is proven until you watch it happen:
1. Open a THROWAWAY PR. Read the conformance job's LOG, not its tick: a skipped Gitea job
reports success ("Has been skipped"), and a green tick is not evidence anything ran.
2. Confirm a Job appeared in ns ${NS}, ran the touched --requirements subset, and that its
artifacts came back. Then break an L3 test deliberately and confirm the gate BLOCKS.
3. Only after seeing BOTH directions should the conformance check be added to branch
protection (infra/gitea/require-gate.sh's status_check_contexts).
4. Diary the token expiry printed above. Re-run this script to rotate; CI fails loudly
(401 on the trigger) rather than skipping when it lapses.
ROLLBACK: DELETE the ${VARIABLE} variable (instant SKIP, gate reverts to L0/L1-only) and/or
DELETE the ${SECRET} secret. The token itself is revoked by deleting the ${NS}/${SA}
ServiceAccount, which Flux will recreate with a fresh identity.
EOF