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]>
infra/gitea — platform settings that are NOT GitOps-managed
Gitea's own repo settings (branch protection, required status checks, approvals) live in Gitea's database, not in a manifest Flux reconciles. They are therefore drift-prone and invisible to the gate — exactly how the state below went unnoticed.
The gap this closes
Verified against the live API on 2026-09-29, against a DECISIONS-LOG.md entry
(2026-08-31) asserting the opposite:
| Claimed | Measured |
|---|---|
main is a protected branch, gate is a required status check |
branch_protections → []; protected:false, enable_status_check:false, required_approvals:0, user_can_push:true |
Runner incluster-dind online |
zero runners registered at repo, org and site level |
So main accepted direct pushes, and the gate could not run at all. CLAUDE.md is the
rule; this directory is how the rule gets enforced by the machine.
branch-protection.sh
Blocks direct pushes to main. Run it, then run the push probe it prints — verify,
do not trust the API response, since Gitea may let site admins bypass protection.
./infra/gitea/branch-protection.sh
It authenticates with the credential git already has stored for this host, and never
prints the secret.
runner-token-rotate.sh
Mints a fresh instance-level registration token from the Gitea API and writes it into
apps/wave-1/gitea-actions-runner-secret.sops.yaml via sops set. The token value is
never printed.
./infra/gitea/runner-token-rotate.sh
A fresh token is usually not sufficient on its own. act_runner registers exactly once
and persists its identity in /data/.runner on the act-runner-data PVC. If Gitea no
longer holds a matching runner row, the pod starts, sees .runner, skips registration,
and polls with an identity Gitea does not recognise — so it never appears in the runner
list. That is precisely the observed symptom: zero runners, pod running, no error anyone
looks at. Clearing the PVC is what forces a real re-registration; the script prints the
commands.
Sequence — DONE 2026-09-29
All four steps are complete and each was verified, not assumed:
| Step | State |
|---|---|
| 1. Runner registered | ✅ incluster-dind online. Root cause of its absence was a stale /data/.runner identity; clearing the PVC forced re-registration |
| 2. Gate observed green on a real PR | ✅ gate / gate (pull_request) ~1m35s |
3. enable_status_check: true |
✅ context gate / gate (pull_request) — that one ONLY |
| 4. Collaborator + approvals | ✅ jakman85 is owner; required_approvals: 1 |
Direct push to main was provoked and rejected (pre-receive hook declined) as
gitea-admin, a site admin — so push protection is not merely declared, it holds for the
account agents authenticate as.
5. Block the admin override — the step the first pass MISSED
enable_status_check and required_approvals were set on 2026-09-29 and still did not
bite, because block_admin_merge_override was false. Gitea lets repo admins override
merge requirements without it, and jakman85 holds owner — so the two settings that looked
like the gate were overridable by precisely the account that merges.
The evidence was sitting in plain sight: PRs #5 through #9 all merged with
required_approvals: 1 in force and zero approvals recorded on any of them. Checking the
branch view (/branches/main) hid this — it does not expose the field. Read the rule object
(/branch_protections/main) and enumerate its keys.
Do not probe for field names you assume exist. This Gitea (1.24.3) has no
apply_to_admins; askingjqfor it returnsnull, which reads like "set to false" but means "key absent". Enumerate withto_entriesand act on what is really there.
Now block_admin_merge_override: true. That is still only a setting, not evidence — it is
proven when a green PR is REFUSED a merge until approved, and not before.
require-gate.sh / verify-then-enforce.sh
verify-then-enforce.sh is the re-runnable form of the whole safe order. It merges nothing —
merging is a human's reviewed act — but it forces the Flux reconcile and then aborts unless
the runner pod is healthy, a runner is online, and a real workflow goes green on it, before
enabling enforcement and probing the push. require-gate.sh is the enforcement step alone.
Re-run verify-then-enforce.sh after any future change to the act-runner Deployment.
expected-branch-protection.json + ci/gitea-settings.sh — the detector
expected-branch-protection.json declares the desired rule. ci/gitea-settings.sh runs as
a merge-gate stage, reads the LIVE rule, and fails the gate on any divergence — plus asserts
at least one runner is online. It is the only thing in this repository that can see Gitea's own
database.
It reads /branch_protections/main, not /branches/main: the branch view does not expose
block_admin_merge_override, and it showed all-green for hours while the rule was overridable.
Behaviour, proven by test rather than assumed:
| Situation | Exit | Effect |
|---|---|---|
| live matches the declaration | 0 | green |
| any key drifted, or no runner online | 1 | blocks the merge |
no GITEA_SETTINGS_TOKEN, or Gitea unreachable |
3 | skips loudly; never a pass, never a block |
The skip is deliberately not a block: an unwired credential blocking every PR is the
lock-yourself-out failure this repo keeps hitting. Wire the token with set-ci-token.sh, confirm
the stage goes green on a real PR, and only then set REQUIRE_GITEA_SETTINGS=true in
gate.yml so a future missing token blocks instead of skipping.
Changing the declaration is how you change policy. If the live state is what you now want, edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
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 conformance gate can read. Nothing detects drift from what this directory declares. That is the root cause of the entire 2026-09-29 session: branch protection was off and the runner was gone for a month while the log recorded both as working, and no check existed that could have caught it.
These scripts encode the desired state and can re-assert it; ci/gitea-settings.sh above is the
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.
Historical sequence (kept for context)
Original ordering notes
- Get an Actions runner registered —
runner-token-rotate.sh, then clear the PVC so the runner actually re-registers, then confirm it showsonlinein the API. Do not skip the confirmation: the whole reason this went unnoticed is that a runner was recorded as online without anyone checking. - Watch the gate actually report on a real PR, and note the context name it produced.
- Only then set
enable_status_check: truewith that exact context. - Add the human reviewer as a repo collaborator, then raise
required_approvalsto 1.
Do not do step 3 before step 1. A required status check with no runner leaves every PR permanently pending — including the PR that would fix it. That half is measured: PR #1 sat
pendingfor 30.6 minutes across 122 polls with no runner registered, and never reported.
Measured, once the runner came up (2026-09-29)
The first CI jobs this repo ever ran, on PR #1 and PR #2:
| Context | Result | Time |
|---|---|---|
gate / gate (pull_request) |
success | 1m58s (PR #1), 1m34s (PR #2) |
gate / gate (push) |
success | 1m32s (PR #2 only) |
gate / conformance (pull_request) |
success — "Has been skipped" | — |
Require gate / gate (pull_request). Do NOT require either conformance context —
but not for the reason an earlier draft of this file gave. A skipped Gitea Actions job
does not hang a status check: Gitea reports it as success with the description
"Has been skipped". So requiring it would not deadlock anything — it would pass
vacuously, every time, forever, while nothing ran. Under HARD RULE 4 that is worse than
a deadlock: a deadlock is loud, a permanently-green check that verifies nothing is
exactly the fail-open shape this repo keeps finding.
The (push) contexts appear only for branches matching gate.yml's push trigger
(feature/**, fix/**, docs/**, chore/**). feat/** is not in that list, which
is why PR #1's feat/ branch produced only pull_request contexts. Require only the
pull_request context, or PRs from non-matching prefixes will never satisfy the rule.