# 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.
