1c00af607c
push_scope.py classifies a push as code or documents from an ALLOW-LIST of document paths - everything else, including any new top-level directory, is code. Every uncertainty (first push, force-push, merge commit, empty range, unreadable stdin) answers code: guessing 'documents' would hand out the exemption by accident. repo_gates.py gains a fifth GATES field and --scope=code|docs. On a documents-only push a golden-currency CONVICTION prints as ADVISORY in its own block and does not refuse; every other gate still refuses every push, and golden-currency still refuses a push touching code. The gate itself is UNCHANGED - its verdict, exit codes and wording are byte-identical. What changed is who is refused. Measured on git 2.47.3: a pre-push hook receives <local ref> <local sha> <remote ref> <remote sha> on stdin, one line per ref; a first push carries an all-zero remote sha and a deletion an all-zero local sha. Both land on code.
242 lines
14 KiB
YAML
242 lines
14 KiB
YAML
# 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: Classify the push - code or documents (R-404)
|
||
# ONE RULE, NOT TWO. The pre-push hook exempts a golden-currency CONVICTION on a
|
||
# documents-only push; if CI did not do the same, a drill night would still produce red CI
|
||
# runs indistinguishable from real ones, which is R-417 exactly and is half the reason this
|
||
# change exists.
|
||
#
|
||
# CI CANNOT USE A COMMIT RANGE. The checkout above is `--depth 1` of a single SHA, so there
|
||
# is no history here to diff against — `git diff before..after` would fail, and deepening
|
||
# the fetch to make it work would slow every run to solve a problem the push event has
|
||
# already answered. So the file list comes from the push event payload instead, and is fed
|
||
# to the SAME classifier the hook uses (`--files-from`), so there is one implementation of
|
||
# "what counts as a document" and not two.
|
||
#
|
||
# FAIL CLOSED, EVERY PATH. No payload, no `commits` array, an empty array, unreadable JSON,
|
||
# a missing classifier — all write `code`, which is exactly today's behaviour. This step can
|
||
# therefore only ever make CI as strict as it is now, never looser. That is also why it is
|
||
# safe to ship before it has been observed on a real push: the untested direction is the
|
||
# safe one.
|
||
run: |
|
||
set -u
|
||
python3 - > /tmp/pushed-files.txt <<'PY' || : > /tmp/pushed-files.txt
|
||
import json, os, sys
|
||
path = os.environ.get("GITHUB_EVENT_PATH", "")
|
||
if not path or not os.path.isfile(path):
|
||
sys.stderr.write("no GITHUB_EVENT_PATH - the file list is unknown\n")
|
||
raise SystemExit(0)
|
||
try:
|
||
ev = json.load(open(path))
|
||
except Exception as e:
|
||
sys.stderr.write("event payload unreadable: %s\n" % e)
|
||
raise SystemExit(0)
|
||
commits = ev.get("commits") or []
|
||
if not commits:
|
||
sys.stderr.write("the payload carries no commits array - unknown\n")
|
||
raise SystemExit(0)
|
||
seen = []
|
||
for c in commits:
|
||
for key in ("added", "modified", "removed"):
|
||
for f in (c.get(key) or []):
|
||
if f not in seen:
|
||
seen.append(f)
|
||
sys.stderr.write("%d commit(s), %d distinct path(s) in the payload\n"
|
||
% (len(commits), len(seen)))
|
||
for f in seen:
|
||
print(f)
|
||
PY
|
||
echo "--- paths the push event reported ---"
|
||
cat /tmp/pushed-files.txt
|
||
echo "-------------------------------------"
|
||
if [ -s /tmp/pushed-files.txt ] && [ -f scripts/push_scope.py ]; then
|
||
SCOPE=$(python3 scripts/push_scope.py --files-from /tmp/pushed-files.txt) || SCOPE=code
|
||
else
|
||
echo "no usable file list - treating this push as CODE (fail-closed)"
|
||
SCOPE=code
|
||
fi
|
||
[ "$SCOPE" = "docs" ] || SCOPE=code
|
||
echo "PUSH_SCOPE=$SCOPE" >> "$GITHUB_ENV"
|
||
echo "scope: $SCOPE"
|
||
|
||
- 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.
|
||
#
|
||
# A documents-only run that convicts ONLY on golden-currency prints the advisory and stays
|
||
# green. THE DEBT IS NOT HIDDEN WHEN THAT HAPPENS — three things still carry it: the
|
||
# advisory block in this run's own log, `STATUS.md`, and the controller repo's golden-notice,
|
||
# which prints at the moment a release is committed, where someone can actually act on it.
|
||
# Those are the compensating controls that make this green honest. Every other gate still
|
||
# fails this job on any push, and golden-currency still fails it on a push touching code.
|
||
run: python3 scripts/repo_gates.py --fast --scope="${PUSH_SCOPE:-code}"
|
||
|
||
- 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 <monitoring@felhom.eu>",
|
||
"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
|