# gates — re-run this repo's gate entry point on every push, on a machine that does not care who # pushed or what they typed. # # *** THIS REPORTS. IT CANNOT REFUSE. *** # # felhom repos push straight to `main` with no pull request, so there is no merge for a status # check to stand at. The refusing half is `.githooks/pre-push`, which is local to a clone and which # `git push --no-verify` skips; this half is what notices when that happened. Neither half is the # whole thing, and both are named in documentation/backlog/OPEN-ITEMS.md R-168. # # NO `uses:` STEP ANYWHERE, deliberately: JavaScript actions need a node runtime in the runner, and # the runner is a host-mode container with python3 and git and nothing else (see # homelab-manifests/gitea-system/act-runner.yaml for why it is not privileged). Probe P3 measured # that a plain `git fetch` of the pushed SHA from the in-cluster Gitea service is enough. # # A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one # layer up. That is the last step, and it runs ONLY on failure. name: gates on: [push] jobs: gates: runs-on: felhom-gates # R-265: A RUN THAT HANGS MUST FAIL ITSELF, LOUDLY AND WITH A LOG. # # Run 264 (2026-08-08) took 834 s and was reaped by the platform, leaving NO log at all — the # log fetch returns HTTP 500 "264.log.zst: file does not exist". Every honest run in that same # session finished in 18–34 s, and every real gate failure finished in under 35 s WITH a log. So # the alarm mail below arrived pointing at a run log that does not exist, telling the operator # "the failing gate names itself in the run log" when nothing could. # # 5 minutes is ~9x the slowest honest run and far under whatever reaped 264, so a hang now ends # as a JOB failure — which produces a log and a step record — rather than as a platform reap, # which produces neither. # # ⚠ WHAT THIS DOES NOT ANSWER, and must not be read as answering: whether the `if: failure()` # alarm step runs at all for a REAPED job is still UNKNOWN. This makes the reap unreachable in # practice; it does not tell us what happens in it. Recorded as still open in R-265. timeout-minutes: 5 steps: - name: Fetch the pushed commit run: | # R-265: the start stamp the alarm reports, so a mail can never again describe a run # without saying how long it took. echo "GATES_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" # Shallow, and pinned to the exact SHA that was pushed — not to the branch tip, which can # move under us if two pushes race. Probe P3 proved the two are equal when done this way. git init -q . git remote add origin http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git git fetch -q --depth 1 origin "$GITHUB_SHA" git checkout -q FETCH_HEAD echo "checked out $(git rev-parse HEAD)" - name: Fetch the agent (wire-contract gate needs BOTH sibling repos) # G-1's gate (scripts/wire_contract_gate.py) compares what one component EMITS against what # the other can RECEIVE, so it needs the source of the controller AND the agent, not just a # CHANGELOG. Without this the gate exits 2 (INCONCLUSIVE) and CI is red for ever. # # ⚠ THIS STEP WAS MISSING FOR ONE COMMIT AND CI WENT RED, exactly as the alarm mail below # predicts: "if the local pre-push hook was GREEN, then CI and the hook disagree — that is a # finding about the gates themselves". It was. The pre-push hook runs on a workstation where # every sibling is a real clone, so a gate needing a sibling passes locally and is # INCONCLUSIVE here; the two homes are NOT interchangeable and a new gate must be checked in # both. Fixed by giving the gate what it needs — never by letting it skip, which would be # the fail-open shape and would leave it running in NEITHER home (R-29). run: | git init -q ../felhom-agent cd ../felhom-agent git remote add origin http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom-agent.git git fetch -q --depth 1 origin main git checkout -q FETCH_HEAD echo "agent at $(git rev-parse --short=12 HEAD)" - name: Fetch the controller CHANGELOG (golden-currency gate needs the sibling repo) # R-242's gate compares the newest RELEASED controller against the newest golden baked here, # and it reads the released version from the sibling clone's CHANGELOG.md — the same sibling # assumption reuse_refs_check.py and instructions_gate.py already make on a workstation. # # CI checks out ONE repo, shallow, so without this the gate exits 2 (INCONCLUSIVE) and CI is # red for ever. **A permanently-red CI is the detector-nobody-hears failure this whole # workflow exists to prevent**, so the fix is to give the gate what it needs rather than to # let it skip: a silent skip would be the fail-open shape, and the gate would then run in # NEITHER of its two automated homes. # # Depth 1, pinned to main, and only this repo's CHANGELOG is used. If the fetch fails the # gate still reports INCONCLUSIVE rather than passing — not knowing is never a pass. run: | git init -q ../felhom-controller cd ../felhom-controller git remote add origin http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom-controller.git git fetch -q --depth 1 origin main git checkout -q FETCH_HEAD echo "controller CHANGELOG at $(git rev-parse --short=12 HEAD): $(head -1 CHANGELOG.md)" - name: Run the gate entry point # The ONLY thing CI runs. No go build, no go test, no linting, no deploy — those are either # already reliably run by a person or none of CI's business. The exit code IS the result: # no `|| true`, no pipe that could swallow it. run: python3 scripts/repo_gates.py --fast - name: Alarm on failure # THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO # notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches # is exactly the shape R-29 filed against. So the run sends its own alarm, on the project's # existing transactional path (Resend, the same one the hub uses), and prints the provider's # accepted id so "a message left the machine" is an observable, not an assumption. # # Pure python3 and urllib, NOT curl: the runner image carries python3 and git and nothing # else on purpose, and the first version of this step died on `curl: command not found`. # Reaching for a bigger image to send one HTTP request would have been the wrong trade. if: failure() env: RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} run: | python3 - <<'PY' import json, os, sys, time, urllib.request, urllib.error key = os.environ.get("RESEND_API_KEY", "") if not key: sys.exit("ALARM FAILED: RESEND_API_KEY is empty — the alarm cannot be sent, and a " "silent alarm is worse than none. Set the user-level Actions secret.") repo = os.environ.get("GITHUB_REPOSITORY", "?") sha = os.environ.get("GITHUB_SHA", "?") run = os.environ.get("GITHUB_RUN_NUMBER", "?") srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu") # R-265: how long the run took, so a reap is self-identifying. An honest gate failure # lands in well under a minute; a multi-minute figure means the job hit its own timeout # and the interesting question is the runner, not the gates. started = os.environ.get("GATES_STARTED_AT", "") try: # An ABSENT stamp is "unknown", never a number. Defaulting to 0 would print an elapsed # of ~1.7 billion seconds, which is a confident wrong answer — the exact failure mode # this whole session is about. elapsed = "%d s" % (int(time.time()) - int(started)) if started else "unknown (no start stamp)" except (TypeError, ValueError): elapsed = "unknown (unparseable start stamp %r)" % started body = json.dumps({ "from": "Felhom CI ", "to": ["admin@felhom.eu"], "subject": "[felhom CI] gates FAILED in %s" % repo, "text": ( "The gate entry point exited non-zero.\n\n" "Repository : %s\n" "Commit : %s\n" "Run : %s/%s/actions/runs/%s\n" "Elapsed : %s\n\n" "The failing gate names itself in the run log - WHEN THERE IS ONE. A run that\n" "hung and was reaped by the platform leaves no log at all (R-265, run 264: 834 s,\n" "log fetch 500). The job now times out at 5 minutes so that case should fail as a\n" "job and keep its log; if Elapsed above is minutes rather than seconds, suspect\n" "the runner before the gates, and if the log is missing say so rather than\n" "guessing which gate it was.\n\n" "If the local pre-push hook was GREEN for this commit, then CI and the hook\n" "disagree - that is a finding about the gates themselves, not about CI, and it\n" "outranks whatever the push was for.\n" ) % (repo, sha, srv, repo, run, elapsed), }).encode() req = urllib.request.Request( "https://api.resend.com/emails", data=body, method="POST", headers={"Authorization": "Bearer %s" % key, "Content-Type": "application/json", # Cloudflare fronts api.resend.com and BLOCKS the default # "Python-urllib/3.x" agent with its own 403 (error 1010) — which looks # exactly like an auth failure and is not one. Measured 2026-08-02. "User-Agent": "felhom-ci/1.0"}) try: with urllib.request.urlopen(req, timeout=30) as r: print("RESEND-ACCEPTED id=%s" % json.load(r)["id"]) except urllib.error.HTTPError as e: sys.exit("ALARM FAILED: Resend returned HTTP %s: %s" % (e.code, e.read().decode()[:300])) PY