The override did not work on its first real test, and the reason was invisible from the outside. Run 203 on PR #38: a well-formed `/approve-override` posted by jakman85 in the PR comment box, 29 minutes newer than the head commit, and the stage still blocked with: approval-override: COMMENTS_JSON is not a list SETTINGS_API_TOKEN is minted [read:repository, read:admin], and Gitea puts /issues/{n}/comments behind the *issue* scope — so Gitea answered with an error OBJECT, `jq -e .` accepted it as valid JSON, and the stage fell through to 'no approving review'. A permission failure wearing the costume of a missing override. /pulls/{n}/files and /pulls/{n}/reviews are under read:repository, which is why every other part of the same stage worked. 1. The diagnosis no longer lies. COMMENTS_JSON leaves the must-parse-or-block loop and is checked for being an ARRAY; when it is not, the stage prints Gitea's own .message, names the missing scope and says where to post instead. An unreadable source can only cause a false BLOCK, never a false pass, so a loud warning is the honest behaviour and blocking was not. 2. An override is accepted from a PR REVIEW body as well as a comment, so the hatch is not hostage to one API scope — a review of type 'Comment' works on the token CI has today. The gate names which source it used; PENDING (unsubmitted, author-only) reviews are dropped. 3. set-ci-token.sh requests read:issue, making the ordinary comment box work — on a RE-RUN only, since an existing token's scopes cannot be widened. ci/tests/approval-override.test.sh: 23 -> 38 assertions, covering the reviews source, PENDING reviews, an error object from the comments API, 'an unreadable comments source must not suppress a valid review override', latest-wins across both sources, and that the diagnostic quotes Gitea's message. Also measured and recorded: run 203 shows gitea-settings PASS after the operator ran the live PATCH, with block_admin_merge_override still true — the narrow change held, the approval count moved and the gate's teeth did not. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
297 lines
17 KiB
Markdown
297 lines
17 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.
|
|
|
|
> **Post the override as a PR *review* (type "Comment"), not in the plain comment box** — until
|
|
> the CI token is re-minted. `SETTINGS_API_TOKEN` is `[read:repository, read:admin]`, and
|
|
> `/issues/{n}/comments` needs Gitea's *issue* scope, so Gitea answers with an error **object**
|
|
> instead of an array and the override is invisible. Measured on PR #38 (2026-10-02): a correct,
|
|
> fresh, well-formed override in the comment box produced `approval-policy: FAIL` reading as "no
|
|
> approving review" — a permission failure disguised as a missing one. `/pulls/{n}/reviews` is
|
|
> under `read:repository` and worked in the same run. The stage now reads **both** sources, names
|
|
> which one it used, and says plainly when the comments API is unreadable. `set-ci-token.sh`
|
|
> requests `read:issue` as of 2026-10-02, so **re-run it** and the comment box works too — token
|
|
> scopes cannot be widened in place. 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.
|