c718aad1bc
gates / gates (push) Successful in 7s
SPIKE-ci-runner-2026-08-02.md: all six probes with method, measurement and ruling; none STOPped. P2 (stock image has git but no python3) and P6 (a runner that loses its state re-registers and orphans the old record) changed the design; P5 (a failed run signals NOTHING) is why the alarm exists at all. R-168 SHIPPED with its evidence. R-29 CLOSED — on the demonstrated alarm and not on a green run, as required: the class it opened is answered at both ends, the hook refusing locally and CI catching a --no-verify bypass and emailing. R-161 noted: its automatic half now exists for the STATIC gate, while its original scope, the runtime gate, is deliberately still not automatic and should stay that way. NEW R-169 (grep established R-168 was the highest in use): CI can only report, because there is no gate in the road. Making it blocking needs branch protection plus a PR workflow, both of which change how the operator works — so it is theirs to decide, and the row states the cost honestly rather than recommending it. CONTEXT gains S-8 (CI detects, does not block, and why that is structural), S-9 (a detector that tells no one is not finished, plus the curl and Cloudflare-1010 traps), S-10 (the runner is unprivileged because DooPlex is Tier 2), S-11 (CI reproduces the sibling layout). CLAUDE.md gains the rule earned by red-proofing: a go test -run pattern that matches no test prints ok and exits 0, and an instrument that can silently drop results is not a measurement.
221 lines
10 KiB
Markdown
221 lines
10 KiB
Markdown
# SPIKE — a Gitea Actions runner for the felhom gate entry points (R-168, 2026-08-02)
|
|
|
|
**Verdict: the mechanism works and is SHIPPED.** All six probes answered; none produced a STOP.
|
|
The runner is unprivileged, host-mode, one registration for all four repos, and a failed run now
|
|
sends its own alarm — measured, not assumed.
|
|
|
|
**Context.** Session 1 (same day) gave every felhom repo one gate entry point and a
|
|
`.githooks/pre-push` that runs it and refuses a failing push. That hook is per-clone and
|
|
`git push --no-verify` skips it, so nothing independent of the person pushing ever saw whether the
|
|
gates passed. This spike built the independent half.
|
|
|
|
---
|
|
|
|
## Arrival state, re-confirmed live (all matched the anchor)
|
|
|
|
| Fact | Expected | Measured |
|
|
|---|---|---|
|
|
| Gitea version | 1.26.2 | **1.26.2** |
|
|
| Actions enabled | all five repos | **all five** (`repo_unit` type 10 present on each) |
|
|
| Runners registered | 0 | **0** (`action_runner` empty) |
|
|
| Workflow runs, ever | 0 | **0** (`action_run` empty) |
|
|
| Branch protections | 0 | **0** (`protected_branch` empty) |
|
|
| `.gitea/` directory | none | **none in any of the five** |
|
|
| ArgoCD `gitea` app | `path: gitea-system`, auto-sync off | **confirmed**, Synced at `420e819`, Healthy |
|
|
|
|
Repo visibility, which decided P3's design: the four product repos are **public**;
|
|
`homelab-manifests` is private.
|
|
|
|
> **A near-miss worth recording.** My first census query reported Actions enabled on only five
|
|
> *unrelated* repos, which looked like a baseline drift big enough to change the task. It was a
|
|
> `| tail -5` inside my own query helper truncating the result. The measurement was never wrong;
|
|
> the instrument was. Re-run without the pipe, all five target repos had the Actions unit. **A tool
|
|
> that silently drops rows is indistinguishable from a finding** — the same class as the
|
|
> `go test -run` filter that matches nothing and prints `ok`.
|
|
|
|
---
|
|
|
|
## P1 — does a registered runner pick up a job at all?
|
|
|
|
**Method.** Registered one runner at **owner scope** for `admin` (`repo_id=0`), label `felhom-gates`,
|
|
via a token minted with `gitea actions generate-runner-token --scope admin` and stored out-of-band as
|
|
`Secret/act-runner-registration`. Pushed a temporary `.gitea/workflows/probe.yml` with one
|
|
`run: echo` step to `felhom.eu`.
|
|
|
|
**Measured.** Run #1 appeared against the pushed commit, was claimed by `felhom-gates-runner`, and
|
|
finished **success**:
|
|
|
|
```
|
|
felhom-gates-runner(version:v0.6.1) received task 1 of job probe, be triggered by event: push
|
|
P1-OK runner picked up the job
|
|
🏁 Job succeeded
|
|
```
|
|
|
|
**Ruling: PASS.** No STOP.
|
|
|
|
---
|
|
|
|
## P2 — can the stock image run our checks without a container runtime?
|
|
|
|
**Method.** Inspected `gitea/act_runner:0.6.1` directly, then re-measured *inside a job* — because
|
|
what matters is what the **job** sees, not what the image contains, and in host mode those are the
|
|
same thing only if host mode is really in effect.
|
|
|
|
**Measured.** Stock image (Alpine Linux v3.23):
|
|
|
|
```
|
|
python3: sh: python3: not found ABSENT
|
|
git: git version 2.52.0 PRESENT
|
|
```
|
|
|
|
In-job, after building the minimal image:
|
|
|
|
```
|
|
P2 python3: Python 3.12.13
|
|
P2 git: git version 2.52.0
|
|
```
|
|
|
|
**Ruling: the known branch, not a failure.** Host mode works; only `python3` was missing. Built
|
|
`gitea.dooplex.hu/admin/felhom-act-runner:0.1.0` = stock (pinned) + `python3`, nothing else, from
|
|
`homelab-manifests/gitea-system/act-runner/Dockerfile`. Verified by deleting the local copy and
|
|
**re-pulling from the registry** rather than trusting the push's own output.
|
|
|
|
**Host mode was never in doubt, so the privileged/dind pattern was never reached for** (§5/§12 of the
|
|
task, and the reason is written into `act-runner.yaml`: DooPlex is Tier 2 and *is* the recovery
|
|
chain).
|
|
|
|
---
|
|
|
|
## P3 — can a workflow obtain the source without JavaScript actions?
|
|
|
|
**Method.** No `uses:` anywhere. A plain `run:` step clones from the **in-cluster** Gitea Service
|
|
(`http://gitea.gitea-system.svc.cluster.local:3000`) — no ingress, no TLS hop, no geo rule — and
|
|
checks out `$GITHUB_SHA`. The four product repos are public, so no credential is needed at all.
|
|
|
|
**Measured.**
|
|
|
|
```
|
|
P3 pushed sha = bbd62319096a1fb92f72218a28756e6fa101e87b
|
|
P3 checked-out sha = bbd62319096a1fb92f72218a28756e6fa101e87b
|
|
P3-OK checkout equals pushed commit
|
|
```
|
|
|
|
**Ruling: PASS.** The shipped workflows use the tighter form — `git init` + `git fetch --depth 1
|
|
origin $GITHUB_SHA` + `checkout FETCH_HEAD` — which is both shallow and pinned to the **exact pushed
|
|
commit** rather than the branch tip, so two racing pushes cannot make a run test the wrong tree. The
|
|
full clone in the probe took ~48 s for `felhom.eu`; the shallow fetch is materially faster.
|
|
|
|
---
|
|
|
|
## P4 — does one registration serve all four repos?
|
|
|
|
**Method.** The registration is owner-scoped (`owner_id=1`, `repo_id=0`). Pushed the real workflow to
|
|
all four repos and read which runner claimed each task.
|
|
|
|
**Measured.**
|
|
|
|
```
|
|
task repo runner_id runner
|
|
7 felhom.eu 2 felhom-gates-runner
|
|
8 felhom-controller 2 felhom-gates-runner
|
|
9 felhom-agent 2 felhom-gates-runner
|
|
10 app-catalog-felhom.eu 2 felhom-gates-runner
|
|
```
|
|
|
|
**Ruling: PASS.** One registration, four repos. No per-repo registration needed.
|
|
|
|
---
|
|
|
|
## P5 — does a failed run signal anything outside the UI? *(the probe that decided Part 4)*
|
|
|
|
**Method.** Broke a gate deliberately and pushed it **with `--no-verify`**, which is precisely the
|
|
bypass CI exists to catch. Run #3 concluded `failure`. Then looked for any outbound signal in the
|
|
ten minutes around it: Gitea pod logs filtered for mail/SMTP/notification activity, and the
|
|
`notification` table.
|
|
|
|
**Measured — nothing left the machine.**
|
|
|
|
```
|
|
gitea pod logs, mail/smtp/notif lines since the failure : (none)
|
|
notification rows created in the last 10 minutes : 0
|
|
```
|
|
|
|
Gitea's mailer is *configured and enabled* (`[mailer] ENABLED = true`, Gmail SMTP, FROM is the
|
|
operator's own address) and the sole user `admin` is active with
|
|
`email_notifications_preference = enabled` — so this is not a disabled-mailer artefact at the
|
|
config level.
|
|
|
|
**Honest limit on this measurement.** I did not independently prove that Gitea's SMTP path can
|
|
*deliver*, so "no mail" cannot be split with certainty between *"1.26.2 has no action-failure
|
|
notification"* and *"the mailer is broken"*. That distinction does not change the design: the alarm
|
|
is built on **Resend**, a different path entirely, and Scenario C proves that path end to end. It is
|
|
recorded here so nobody later reads this probe as a clean bill of health for Gitea's mailer.
|
|
|
|
**Ruling: Part 4 applies — build the alarm.** A red tick in a web UI nobody watches is exactly the
|
|
defect R-29 filed, rebuilt one layer up.
|
|
|
|
---
|
|
|
|
## P6 — does the runner need persistent state?
|
|
|
|
**Method.** Two measurements, not one, because "it survived a restart" and "the PVC is load-bearing"
|
|
are different claims.
|
|
|
|
- **P6a — restart with the PVC intact:** deleted the pod, let it come back.
|
|
- **P6b — restart with state lost:** removed `/data/.runner` (exactly what ephemeral storage would
|
|
do), deleted the pod, let it come back.
|
|
|
|
**Measured.**
|
|
|
|
| | runner list before | after | runner log |
|
|
|---|---|---|---|
|
|
| P6a | `1 felhom-gates-runner` | `1 felhom-gates-runner` | no re-registration; straight to `Starting runner daemon` |
|
|
| P6b | `1 felhom-gates-runner` | `1 …` **and** `2 …` | `Registering runner…` → `Runner registered successfully` |
|
|
|
|
**Ruling: the PVC is load-bearing.** Without persistence, every restart mints a new registration and
|
|
leaves the previous one behind as a permanently-offline record — the runner list would silently fill
|
|
with corpses. Shipped with a 5 Gi Longhorn PVC (largest repo checked out is ~125 MiB).
|
|
|
|
**The P6b casualty was cleaned up immediately**, not left for the teardown section:
|
|
`DELETE /api/v1/admin/actions/runners/1 → 204`, leaving exactly one runner.
|
|
|
|
---
|
|
|
|
## What the probes changed about the design
|
|
|
|
1. **A custom image** (P2) — stock + `python3` only, base pinned by tag.
|
|
2. **Shallow fetch of the exact SHA** rather than a clone of the branch tip (P3).
|
|
3. **A PVC** (P6), with the reason recorded in the manifest.
|
|
4. **A self-sent alarm** (P5) — the task's whole second half.
|
|
5. **A sibling clone for two of the four repos** — discovered while writing the workflows, not by a
|
|
probe. `controller_gates.py` and `agent_gates.py` invoke the shared `reuse_refs_check.py` that
|
|
lives in the `felhom.eu` clone next door and is deliberately never copied, and both repos'
|
|
`REUSE.md` files cite a path that lives in the hub. Without the sibling, CI would have failed
|
|
**closed** — correctly, but for the wrong reason. CI now reproduces the workspace's sibling
|
|
layout, and the resulting tallies match the local hook exactly (controller 126 exact / 6 suffix /
|
|
1 cross-repo; agent 88 / 1 / 1). **CI and the hook agree.**
|
|
|
|
## Two things that failed on the way, and why they are recorded
|
|
|
|
Both were caught because the step failed **loudly**; either would have shipped as a silent
|
|
non-alarm if the step had swallowed its exit code.
|
|
|
|
- **`curl: command not found`** — the first alarm used `curl`, which the deliberately minimal image
|
|
does not carry. Fixed by using `python3` + `urllib` rather than by growing the image: reaching for
|
|
a bigger base to send one HTTP request is the wrong trade, and every added tool becomes something
|
|
the next person assumes is load-bearing.
|
|
- **Cloudflare 403, error 1010** — `api.resend.com` sits behind Cloudflare, which blocks the default
|
|
`Python-urllib/3.x` User-Agent. **This failure looks exactly like an auth failure and is not one**,
|
|
which is the reason it is written down: the next person to see a 403 from Resend should check the
|
|
agent before rotating a key. Verified the fix from inside the runner image with a deliberately
|
|
invalid payload — with a User-Agent set, Resend answers `422 missing_required_field`, i.e. the
|
|
request reaches the API rather than the CDN.
|
|
|
|
## Standing limit, stated so it is not mistaken for something it is not
|
|
|
|
**CI here DETECTS. It does not BLOCK.** Every felhom repo pushes straight to `main` with no pull
|
|
request, so there is no merge for a status check to gate. This is not a gap in the runner — there is
|
|
no gate in the road. The refusing half is the local pre-push hook; this half notices when that hook
|
|
was skipped. Making CI blocking requires branch protection and a pull-request workflow, which is a
|
|
change to how the operator works and is **their** decision, not this task's.
|