15 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 — see §12 for the closing 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.