Files
felhom.eu/documentation/audits/SPIKE-ci-runner-2026-08-02.md
T
admin c718aad1bc
gates / gates (push) Successful in 7s
docs: R-168 SHIPPED, R-29 CLOSED on the demonstrated alarm, R-169 minted
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.
2026-08-02 16:35:34 +02:00

10 KiB

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 1010api.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.