enable_status_check and required_approvals were set earlier today and recorded as done. They did not bite. block_admin_merge_override was false, and Gitea lets repo admins override merge requirements without it. jakman85 holds owner, so both requirements were bypassable by exactly the account that performs every merge. The evidence was already on the record: PRs #5-#9 all merged while required_approvals: 1 was in force, with ZERO approvals recorded on any of them. Two process failures, both the same shape as the defect this whole session started with: 1. Verified with the wrong view. /branches/main reports protected, enable_status_check and required_approvals but NOT block_admin_merge_override -- it showed everything green while the rule was overridable. /branch_protections/main is authoritative. 2. Probed for a field name assumed to 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", so a PATCH that silently did nothing produced a convincing before/after. jq returning null is not a value -- it may be an absence. Enumerate with to_entries. require-gate.sh now sets block_admin_merge_override: true; block-admin-override.sh fixes it in isolation. Both carry the field-name warning. This PR is the test. If it merges WITHOUT an approval, the fix did not work. Also records the governance question: with only jakman85 (human) and gitea-admin (agent), a human-authored PR now needs the automation account to approve it. That is ceremony, not review, and the choice between keeping it, dropping approvals in favour of the status check, or adding a second human is the operator's to make. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
141 lines
7.4 KiB
Markdown
141 lines
7.4 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: 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`; 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.
|
|
|
|
### 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, but they are not a detector. The
|
|
durable fix is a conformance check that reads the live Gitea API and fails when it diverges
|
|
from what is declared here — turning a class of silent, invisible failure into a loud one.
|
|
**That check does not exist yet and is the highest-value remaining work in this directory.**
|
|
|
|
## 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.
|
|
|
|
> **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.
|