# 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 steps: - name: Fetch the pushed commit run: | # 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, 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") 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\n" "The failing gate names itself in the run log.\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), }).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