16 KiB
REPORT — CI: a Gitea Actions runner, and a red run that reaches a person (R-168, 2026-08-02)
Overwritten per the standing rule. The prior contents (session 1's gate enforcement, same day)
have their durable record in scripts/CHANGELOG.md, hub/CHANGELOG.md and CONTEXT.md S-6/S-7.
Outcome: complete, and the half that matters is demonstrated. All six probes answered; none
STOPped. The acceptance test was never a green pipeline — it was a red run with a message in
hand, and that is RESEND-ACCEPTED id=5ff34766-c5f8-4588-8104-08296aeb45ab.
1. Confirmed baselines — all matched §1 on arrival
| Repo | main on arrival |
matched? | version |
|---|---|---|---|
homelab-manifests |
420e8197769b |
yes | n/a |
felhom.eu |
af2d10388021 |
yes | hub v0.87.0 — no bump |
felhom-controller |
eaded79b1894 |
yes | v0.188.0 — no bump |
felhom-agent |
2c4efed5de32 |
yes | v0.119.0 — no bump |
app-catalog-felhom.eu |
f16f29757e95 |
yes | n/a |
Live Gitea re-confirmation, all matched: version 1.26.2; Actions enabled on all five
repos; 0 runners; 0 workflow runs ever; 0 branch protections; no .gitea/ in any
repo. ArgoCD gitea app: path: gitea-system, auto-sync off, Synced at 420e819, Healthy.
The only new artifact is the runner image, at its own 0.1.0.
2. All six probe results
Full method and measurement: documentation/audits/SPIKE-ci-runner-2026-08-02.md.
| Probe | Ruling | Measurement |
|---|---|---|
| P1 runner picks up a job | PASS | run #1 claimed by felhom-gates-runner, P1-OK, 🏁 Job succeeded |
| P2 stock image runs our checks | known branch → build an image | stock act_runner:0.6.1 (Alpine 3.23): git 2.52.0 PRESENT, python3 ABSENT. Host mode itself fine → privileged/dind never reached for |
| P3 source without JS actions | PASS | pushed sha == checked-out sha (bbd62319…), plain git clone/fetch from the in-cluster Service, no uses: |
| P4 one registration, four repos | PASS | tasks 7,8,9,10 across four repos, all runner_id=2 felhom-gates-runner |
| P5 does a failure signal anything | NOTHING SENT → build the alarm | 0 mail/SMTP/notification log lines, 0 notification rows, after a real failure run |
| P6 persistent state needed | PVC is load-bearing | PVC intact → same registration; /data/.runner removed → second record appears and the first is orphaned |
P5's honest limit, stated rather than glossed: I did not independently prove Gitea's SMTP path can deliver, so "no mail" cannot be split with certainty between no action-failure notification in 1.26.2 and a broken mailer. It does not change the design — the alarm rides Resend, a different path — and Scenario C proves that path end to end.
3. Files created / modified
homelab-manifests — gitea-system/act-runner.yaml (NEW: PVC + Deployment),
gitea-system/act-runner/Dockerfile (NEW), CHANGELOG.md, REPORT.md.
felhom.eu — .gitea/workflows/gates.yml (NEW),
documentation/audits/SPIKE-ci-runner-2026-08-02.md (NEW), CLAUDE.md, CONTEXT.md (S-8…S-11),
scripts/CHANGELOG.md, documentation/backlog/OPEN-ITEMS.md, documentation/backlog/ROADMAP.md,
STATUS.md, REPORT.md. (hub/internal/web/configs.go was broken and restored within the session
for Scenario B; it is byte-identical to its starting state — verified by the gate that objects to it.)
felhom-controller, felhom-agent, app-catalog-felhom.eu —
.gitea/workflows/gates.yml (NEW), CHANGELOG.md, REPORT.md.
4. Commits pushed to main
| Repo | Hash | What |
|---|---|---|
homelab-manifests |
c51b77c |
the runner manifest + Dockerfile |
felhom.eu |
bbd6231 |
the temporary Part 0 probe workflow |
666a34d |
real gates.yml, probe workflow deleted |
|
3252d51 |
Scenario B deliberate breakage (--no-verify) |
|
dd13f63 |
alarm step v1 — died on curl: command not found |
|
f7dbc33 |
alarm rewritten in python3/urllib | |
9530de7 |
alarm User-Agent (Cloudflare 1010) — the run that produced the accepted-id | |
4cc1238 |
breakage reverted, main green | |
felhom-controller |
dcc400e |
gates.yml (with sibling clone) |
felhom-agent |
eb99144 |
gates.yml (with sibling clone) |
app-catalog-felhom.eu |
aa57588 |
gates.yml (--fast, no sibling needed) |
Plus the documentation commits listed at the end of this session.
5. Scenario results A-G
| # | Result | Positive observable |
|---|---|---|
| A | PASS | run #2 (666a34d) SUCCESS. Every member gate's own stdout in the CI log, not just the summary: site gates OK — BOM, emoji=0…, hostinstall gates: ALL PASS, hub confirm gate OK…, manifest bearer gate OK…, OK [hostexecutor]: 61 cited paths — exact 60, suffix 1…, then all felhom.eu gates OK |
| B | PASS | run #3 (3252d51) FAILURE. The failing gate's own text: FAIL: hub/internal/web/configs.go:34 carries const hostInstallVersion…, hostinstall gates: 3 FAILURE(S), CONVICTED: hostinstall, 🏁 Job failed |
| C | PASS | run #6 FAILURE → RESEND-ACCEPTED id=5ff34766-c5f8-4588-8104-08296aeb45ab. See §6 |
| D | PASS | four repos, four runs, one runner: tasks 7/8/9/10 all runner_id=2 felhom-gates-runner; conclusions success ×4 |
| E | PASS | live pod spec (§8) and the refusal in a job log: /root/.cache/act/…/3.sh: line 2: docker: command not found → E-OK docker refused |
| F | PASS | catalogue run: image-pin gate OK — 53 templates, 0 unpinned images; --fast SKIPPED: image-resolvable, volume-persistence…; resolvability/volume-persistence/canary output lines 0, image-pull lines 0 |
| G | PASS | P6: PVC intact → runner list unchanged (1 felhom-gates-runner); state removed → 1 and 2, i.e. a second registration. Orphan deleted, 204 |
Scenario A note worth keeping: the controller and agent runs prove more than "it ran" — their reuse tallies in CI (126 exact / 6 suffix / 1 cross-repo; 88 / 1 / 1) are identical to the local pre-push hook's. CI and the hook agree. Per §12 a disagreement would have outranked this whole task; there is none.
6. Scenario C evidence in full
The run was genuinely red for a genuinely broken gate, and the message is the observable — not a tick in a UI:
CONVICTED: hostinstall
RESEND-ACCEPTED id=5ff34766-c5f8-4588-8104-08296aeb45ab
🏁 Job failed
The Resend id is the provider's accepted-id, returned by api.resend.com for a message
monitoring@felhom.eu → admin@felhom.eu, subject [felhom CI] gates FAILED in admin/felhom.eu,
carrying the repo, the commit, a link to the run, and the line that matters most: if the local
pre-push hook was green for this commit, CI and the hook disagree, and that outranks whatever the
push was for.
The key never appeared in the log — Gitea masked it (RESEND_API_KEY: ***) and a grep for the
key prefix across the run log returns 0 lines.
7. Runner deployment
- GitOps only.
act-runner.yamlcommitted tohomelab-manifestsmain(c51b77c), pushed, then a deliberate ArgoCD sync of thegiteaApplication. Nokubectl apply, nokubectl set imageat any point. - ArgoCD:
sync=Synced health=Healthy, revisionc51b77c68f10…. - Pod:
act-runner-…1/1 Running; PVCact-runner-dataBound, 5Gi,longhorn. - Running image:
gitea.dooplex.hu/admin/felhom-act-runner:0.1.0, verified by deleting the local copy and re-pulling from the registry (Python 3.12.13,git version 2.52.0) rather than trusting the push's own output. - Online in Gitea's runner list, with its label:
Owner-scoped (
id=2 name=felhom-gates-runner status=online labels=[felhom-gates]owner_id=1,repo_id=0) — which is why one registration serves four repos.
8. Security posture — shown, not asserted
From the live pod spec:
privileged : False
allowPrivilegeEscalation : False
capabilities.drop : ['ALL']
automountServiceAccountToken : False
hostNetwork/hostPID/hostIPC : None None None
nodeSelector : None
volumes : [('data', ['persistentVolumeClaim'])]
hostPath volumes : NONE
docker socket mounts : NONE
serviceaccount token mounts : NONE
resources : requests 50m/128Mi, limits 500m/512Mi
And the negative proven rather than inferred — from a job log:
/root/.cache/act/187d434f3fdbb3b4/act/workflow/3.sh: line 2: docker: command not found
E-OK docker refused (output above is the refusal)
Resources are half of Gitea's limits (Gitea: 1 CPU / 1Gi) and a fifth of its requests, deliberately: Gitea shares this node and holds every repository. The heaviest entry point was measured at 11.9 MiB RSS, 0.98 s wall.
9. Teardown — all three layers
- Workload — nothing to remove: the runner is the deliverable and stays. No scratch Deployment or PVC was created beyond it.
- Host — no scratch Longhorn volume was created beyond the runner's own 5Gi PVC, which is
intended and in git. Local Docker images (
gitea/act_runner:0.6.1base and the builtfelhom-act-runner:0.1.0) remain in DooPlex's local Docker store; they are ordinary build artifacts and no global prune was run (forbidden on this host). - The Gitea-side registration records — the layer this project has missed four times.
- The P6b orphan (
id=1) was deleted immediately when it was created, not left for the end:DELETE /api/v1/admin/actions/runners/1 → 204. - Final runner list: exactly one, the intended live runner —
id=2 felhom-gates-runner status=online labels=[felhom-gates], and the live pod's own/data/.runnercarriesid=2, so the record and the process agree. A trap worth recording, because it briefly looked like a failed teardown: a rawselect id,name from action_runnerreturns two rows. Gitea soft-deletes runners — the deleted row is tombstoned (deletedset), not removed, and the admin API correctly reports one. The query without the tombstone filter was the misleading instrument, not the teardown. Same class as the| tail -5census bug and thego test -runfilter: a query that does not model the deletion semantics is not a check. - The temporary probe workflow
.gitea/workflows/probe.ymlwas deleted frommainin666a34d, the same commit that added the real one. - The admin API access token minted for this session (used to read run conclusions and to
delete the orphan runner) is revoked. The clean route —
DELETE /api/v1/users/admin/tokens/ {id}— needs HTTP Basic auth with the operator's Gitea password, not the token itself; triedGET /api/v1/users/admin/tokenswith thePASSWORDentry from~/.config/credentialsand got 401, so that is not the Gitea admin password. Revoked instead by deleting the singleaccess_tokenrow I created, matched on itscc-ci-session-*name: 1 row before, 0 after, 6 unrelated tokens untouched. The scratch file holding it was removed. It appears in no committed file, log or report.
- The P6b orphan (
10. OPEN-ITEMS.md rows opened / closed / re-ranked
- R-168 → SHIPPED, with the evidence in the row: the runner, the one-registration-four-repos measurement, the two probes that changed the design (P2, P6), and the demonstrated alarm with its accepted-id.
- R-29 → CLOSED, both halves — and closed on the demonstrated alarm, not on a green run, as the task required. The class it opened (a gate that exists, is red, and is invoked by nothing) is answered at both ends.
- R-161 → updated: its automatic half now exists for the static gate. Its original scope, the runtime volume-persistence gate, is deliberately still not automatic and should stay that way.
- R-169 → NEW.
grep -oE "R-[0-9]+" | sort -u -t- -k2 -n | tailestablished R-168 as the highest in use, so R-169 was free. 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. WAITING-ON-OPERATOR, owner: operator. The row states the cost honestly (for a single-operator project a PR workflow may be worse than the disease) and names the real gap: the window between a--no-verifypush landing and the operator reading the alarm. ROADMAP.md: R-168 collapsed to its one-liner, R-29 closed, R-169 added.architecture/00-capability-map.md— N/A, stated rather than silently skipped. No customer-visible or platform capability changed; this is developer tooling.
11. Was --no-verify used?
Yes — three times, all on felhom.eu, all deliberate and all disclosed.
| Commit | Why |
|---|---|
3252d51 |
Scenario B itself. The commit had to carry a broken gate, and pushing it with --no-verify is the bypass CI exists to catch. The local hook would have refused it — that is the point. |
dd13f63 |
The tree was still deliberately broken; pushing the alarm step required bypassing the hook that was correctly objecting. |
f7dbc33, 9530de7 |
Same, iterating on the alarm while the tree was still red on purpose. |
Every other push in this session ran its pre-push hook and passed. The breakage was reverted in
4cc1238, and the hook passed on that push.
12. Observations — noticed, recorded, NOT acted on
- CI and the pre-push hook never disagreed. §12 said a disagreement would outrank the CI work;
there was none. The controller's and agent's reuse tallies in CI match the local run exactly. The
one place they would have disagreed was designed out rather than discovered: without a sibling
felhom.euclone, both entry points fail closed withgate is MISSING. - A near-miss that outranks a probe result. My first census query reported Actions enabled on
five unrelated repos — a baseline drift big enough to change the task. It was a
| tail -5inside my own helper truncating the output. The measurement was never wrong; the instrument was. This is the same class as thego test -runfilter from session 1, and it is now aCLAUDE.mdrule: an instrument that can silently drop results is not a measurement. - Two failures on the way to the alarm, both worth their write-up —
curl: command not found(the image is minimal on purpose; fixed withurllib, not a bigger image) and Cloudflare 403 error 1010 onapi.resend.com, which blocks the defaultPython-urllibUser-Agent. The second matters because it looks exactly like an auth failure: the next person to see a 403 from Resend should check the User-Agent before rotating a key. Both are in the spike doc and inCONTEXT.mdS-9. - A full clone is slow enough to notice — the P3 probe's
git cloneoffelhom.eutook ~48 s. The shipped workflows usegit fetch --depth 1 <sha>instead, which is both faster and pinned to the exact pushed commit. Not a finding, but the reason the shipped form differs from the probe's. - Not acted on, out of scope: Gitea's own mailer is configured and enabled but was never proven to deliver (§2). If it is broken, nothing else in Gitea that relies on email works either — worth a five-minute test email from the admin UI at some point. It does not affect the CI alarm, which does not use it.
- Not acted on, out of scope:
homelab-manifestshas no gate entry point and no pre-push hook, unlike the four product repos, and now no CI workflow either. It is the repo that deploys the cluster, so that asymmetry may deserve a look — but adding one was not in this task, and the repo has no gates to run yet.