`required_approvals: 1` on `main` was a lock-out, not a protection. Gitea
refuses to let a PR's author approve their own PR, and `jakman85` is the only
collaborator — so no account in existence could approve a PR the operator
opened, including the PR that would fix that. Measured 2026-10-01 on PR #35:
gate green in 1m41s, reviews `[]`, `required_approvals: 1`, unmergeable.
It was never the documented design either: `ci/approval-policy.sh`'s header and
`ci/gate.sh:194` both already describe the model as "required_approvals drops
to 0 and the GATE decides", and `require-gate.sh`'s own closing note named this
failure and prescribed this remedy. 2026-09-30's entry flagged the 1-vs-0
disagreement as unresolved; this settles it at 0.
Layer 1 — branch protection. required_approvals 1 -> 0 in the declaration, plus
infra/gitea/allow-admin-self-merge.sh to apply it live (target read FROM the
declaration, so the script cannot drift from what the gate checks).
require-gate.sh moves to 0 too; left at 1 it would re-impose the lock-out on its
next run. The narrow choice is deliberate: the other way to unblock an owner is
block_admin_merge_override: false, which would re-open what
block-admin-override.sh was written to close (PRs #5-#9, zero approvals). This
drops the approval COUNT only — block_admin_merge_override and
enable_status_check stay true and are asserted after the PATCH, so a red gate
stays unmergeable by everyone, administrators included.
Layer 2 — ci/approval-policy.sh. Sensitive paths still need a non-author
approval the sole admin cannot get, so an administrator may waive it with an
`/approve-override <reason>` comment: directive must start a line, reason
mandatory, permission re-checked LIVE, invalidated by a new commit, and the
overrider + reason printed in the gate log and job summary. Candidate selection
is the pure, offline ci/approval-override.py (23 unit assertions, all passing);
authorisation stays in the fail-closed stage. Every new failure mode blocks.
The cost, stated not buried: for an administrator, "a change to the gate cannot
be gated by the gate" is now advisory rather than structural. Unavoidable with
one human; what is bought back is an attributed, commit-scoped record instead of
a silent bypass. And the uncomfortable part, accepted as an operator decision:
authorisation is by live permission alone, and the AGENT account `gitea-admin`
also holds `owner` — so an agent can clear its own PR this way. Documented in
approval-policy.json, the stage header, and CLAUDE.md (which tells agents not
to). Narrowing needs no code change: add `override_admins` and the stage already
requires membership.
Adjacent fix, in scope because `make -C ci test` is the only runner for the
suite added here: ci/tests/gate-decision.test.sh hand-maintained a stub list
that "must name EVERY stage gate.sh runs" and omitted `approval-policy` (added
to gate.sh by PR #17), so run_stage hit exit 127 and 15 of 50 assertions failed.
Verified pre-existing on pristine origin/main @ 0c7e8dd. The list is now derived
from gate.sh's own run_stage lines, so the omission cannot recur; the suite goes
15-failed -> 50 passing and `make -C ci test` goes FAILED -> all suites passed.
Toolbox gate: rc 3 UNDETERMINED — every stage green except `gitea-settings`
(no SETTINGS_API_TOKEN locally) and `conformance` (no cluster). NOT a green run.
The live rule is UNCHANGED and still reads 1 — mutating it is an operator
action. Until allow-admin-self-merge.sh is run, ci/gitea-settings.sh will
correctly report DRIFT and this PR will be red.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
286 lines
16 KiB
Markdown
286 lines
16 KiB
Markdown
# 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` was raised to 1 here and **lowered back to 0 on 2026-10-01** — see §6, it was a lock-out |
|
|
|
|
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`; asking `jq` for it returns `null`, which reads like "set to false" but
|
|
> means "key absent". Enumerate with `to_entries` and 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 `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
|
|
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.
|
|
|
|
### 6. `allow-admin-self-merge.sh` — undoing the lock-out step 4 created (2026-10-01)
|
|
|
|
Step 4 above raised `required_approvals` to 1. That was a **lock-out, not a protection**, and the
|
|
note at the bottom of `require-gate.sh` had already said why: *Gitea will not let an author approve
|
|
their own PR.* With `jakman85` the only collaborator, there was no account in existence that could
|
|
approve a PR `jakman85` opened.
|
|
|
|
Measured 2026-10-01 — **PR #35**: `gate / gate (pull_request)` **success in 1m41s**, reviews `[]`,
|
|
`required_approvals: 1`, `block_admin_merge_override: true`. Unmergeable, permanently, with no
|
|
action available to its author. The same was true of any PR fixing it.
|
|
|
|
It was also never the documented design. `ci/approval-policy.sh`'s header and `ci/gate.sh:194`
|
|
both already describe the model as *"`required_approvals` drops to 0 and the GATE decides"*, and
|
|
`decisions/2026-09-30-flux-deploy-notification-inhibited.md` flagged the 1-vs-0 disagreement as
|
|
unresolved and not that session's to settle. So this is a correction toward the stated intent, not
|
|
a weakening away from it.
|
|
|
|
./infra/gitea/allow-admin-self-merge.sh # required_approvals -> 0 (read from the declaration)
|
|
|
|
**What stays switched on, and why that is the point.** The other way to unblock an owner is
|
|
`block_admin_merge_override: false`, and it would have been the wrong one — that re-opens exactly
|
|
what §5 above closed, letting the one account that merges bypass the gate itself. This script
|
|
touches the approval **count** only and *verifies* afterwards that `block_admin_merge_override` and
|
|
`enable_status_check` are both still `true`, failing loudly if the PATCH disturbed either:
|
|
|
|
| | before | after |
|
|
|---|---|---|
|
|
| `required_approvals` | 1 | **0** |
|
|
| `block_admin_merge_override` | true | true (asserted) |
|
|
| `enable_status_check` | true | true (asserted) |
|
|
|
|
So a **red gate is still unmergeable by everyone, administrators included**. What changed is that
|
|
a *green* one no longer needs a signature that cannot exist.
|
|
|
|
**Security-relevant changes are still reviewed** — by `ci/approval-policy.sh` against
|
|
`approval-policy.json` `sensitive_paths`, not by this field. An administrator waives that review
|
|
only by posting `/approve-override <reason>` on the PR: reason mandatory, permission re-checked
|
|
live, invalidated by a new commit, and recorded by account in the gate summary. Note that the
|
|
**agent account `gitea-admin` also reports permission `owner`**, so it can clear its own PRs that
|
|
way; that is an accepted consequence of authorising by permission alone, and the route to
|
|
narrowing it (an `override_admins` allowlist, already honoured by the code) is written down in
|
|
`approval-policy.json`.
|
|
|
|
**Sequencing.** `ci/gitea-settings.sh` compares live against the declaration *from the branch under
|
|
test*, and `REQUIRE_GITEA_SETTINGS=true`. So merge any other open PRs **before** running this, then
|
|
run it, then re-run the gate on the branch carrying the new declaration. Any PR left open across
|
|
the change declares 1 against a live 0 and goes **red until rebased** — the detector working, not
|
|
a fault. The script's header spells the order out.
|
|
|
|
## `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 `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)
|
|
|
|
### Original ordering notes
|
|
|
|
1. **Get an Actions runner registered** — `runner-token-rotate.sh`, then clear the PVC so
|
|
the runner actually re-registers, then confirm it shows `online` in the API. Do not
|
|
skip the confirmation: the whole reason this went unnoticed is that a runner was
|
|
*recorded* as online without anyone checking.
|
|
2. **Watch the gate actually report on a real PR**, and note the context name it produced.
|
|
3. **Only then set `enable_status_check: true`** with that exact context.
|
|
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
|
|
— **Step 4 was REVERSED on 2026-10-01 (§6).** It assumed a reviewer distinct from the author
|
|
would exist. One never did, so this step produced a lock-out. Do not re-apply it.
|
|
|
|
> **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 `pending` for 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.
|