Files
felhom.eu/scripts/script_tests_gate.py
T
admin ecd82abab8
gates / gates (push) Successful in 3m23s
ci(R-1297 A): heavy suites by path on a branch push, all on a tag and nightly; no second full gate run
- script_tests_gate.py: test_gate_decoys / test_due_checks_gate / dooplex-offsite run on a CI branch push only
  when one of their paths changed (FELHOM_CHANGED_FILES); no list, an empty or unreadable list = every suite
- gates.yml: nightly schedule 03:20 UTC; the list is set only for push:refs/heads/*; a trimmed commits array
  (total_commits > listed) is an unknown list
- test_repo_gates.py and test_due_checks_gate.py group G drive the real runner with GATES narrowed to their
  labels, instead of the whole gate set a second time (130 s each in CI)
- red-proved: filter cases, workflow pins (old gates.yml), inert run_gate, unregistered due-checks
2026-10-26 18:14:23 +01:00

148 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""script_tests_gate.py — every Python test suite under scripts/ runs on every push (R-885).
Usage: python3 scripts/script_tests_gate.py [<repo-root>]
Exit 0 every suite exited 0 · 1 a suite failed, timed out, or NO suite was found.
WHY. `repo_gates.py` ran gates, not the `test_*.py` suites beside them, so a change that broke one — the 15 tests of
the DooPlex hub-DB push/restore scripts, the 73 of the instructions gate — was caught only if someone ran it by hand.
SCOPE IS A FACT (R-421): the suites are FOUND by walking scripts/ (os.walk, every depth), never listed by hand, so a new
suite is covered the day it is added. Finding none is a FAILURE — a gate that checked nothing must not read as green.
A suite's verdict is its EXIT CODE, never its output: a suite that prints "OK" and exits 1 has failed.
NESTING. `test_repo_gates.py` runs `repo_gates.py`, which runs this gate, which would run `test_repo_gates.py` again.
The gate marks its children with FELHOM_SCRIPT_TESTS_GATE=1 and, when it finds the mark, steps aside (exit 0, said
out loud). Pinned by scripts/test_script_tests_gate.py (decoys).
"""
import os
import subprocess
import sys
import time
# A gate's own console must not decide its verdict. These scripts quote back Hungarian copy, file
# paths and arrow characters; a Windows console here is cp1250, and printing one of them raised
# UnicodeEncodeError *before the gate had decided anything* — reuse_refs_check.py died while printing
# a NOTE, which the runner then reported as a failure (2026-10-10). Same trap class as
# poster_facts_gate.py's, which was found by its own red-proof.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError): # pragma: no cover - old Python, or a pipe
pass
MARK = "FELHOM_SCRIPT_TESTS_GATE"
TIMEOUT_S = 300
# R-1209 (2026-10-21 night): the decoy suite runs every gate's decoys and took 250–255 s on the CI runner on green runs,
# then over 300 s on every push that night (CI jobs 2177–2189) — ~155 s on DooPlex. A time-out is still a FAILURE (rc
# „timeout"); only this one suite gets a longer limit. Pinned by test_script_tests_gate.py (timeout_for).
SUITE_TIMEOUT_S = {"scripts/test_gate_decoys.py": 600}
def timeout_for(rel):
return SUITE_TIMEOUT_S.get(rel.replace(os.sep, "/"), TIMEOUT_S)
SKIP_DIRS = {"__pycache__", ".git", "node_modules"}
# R-1297 (A, 2026-10-26): THREE HEAVY SUITES RUN ON A CI PUSH ONLY WHEN ONE OF THEIR OWN PATHS CHANGED. Measured in CI run
# 2366: the decoy suite 232 s, the due-checks suite 133 s, the DooPlex off-site suite 69 s — of a 13-minute run, on every
# push, documents included. The decoys test the GATE SCRIPTS (they plant a fixture and expect a conviction), so a push
# that touches no script cannot change their verdict; the other two test their own scripts only.
#
# A RUN WITHOUT A LIST RUNS EVERY SUITE. CI sets FELHOM_CHANGED_FILES only for a branch push whose file list is known
# (gates.yml); a release tag, the nightly schedule, a manual dispatch and the pre-push hook set nothing, so they run all
# of them. An unreadable or empty list is unknown, and unknown runs everything (fail-closed). What the filter may cost:
# a document change that breaks a decoy's FIXTURE (not a gate) is seen by the nightly run, within a day — said here so
# nobody reads the filter as free. Pinned by scripts/test_script_tests_gate.py (FILTER_CASES).
CHANGED_ENV = "FELHOM_CHANGED_FILES"
PATH_FILTERED = {
"scripts/test_gate_decoys.py": ("scripts/", ".gitea/", ".githooks/"),
"scripts/test_due_checks_gate.py": ("scripts/due_checks_gate.py", "scripts/test_due_checks_gate.py",
"scripts/repo_gates.py", "scripts/script_tests_gate.py"),
"scripts/dooplex-offsite/test_dooplex_offsite.py": ("scripts/dooplex-offsite/",),
}
def changed_paths():
"""The pushed paths, or None when no usable list exists (then every suite runs)."""
p = os.environ.get(CHANGED_ENV, "")
if not p:
return None
try:
with open(p, encoding="utf-8") as fh:
paths = [l.strip() for l in fh if l.strip()]
except OSError as e:
print("script-tests: %s=%s unreadable (%s) — every suite runs" % (CHANGED_ENV, p, e))
return None
if not paths:
print("script-tests: %s=%s is empty — every suite runs" % (CHANGED_ENV, p))
return None
return paths
def skip_reason(rel, changed):
"""Why a suite is skipped on this push, or "" when it runs."""
prefixes = PATH_FILTERED.get(rel.replace(os.sep, "/"))
if changed is None or not prefixes:
return ""
if any(c.startswith(pfx) for c in changed for pfx in prefixes):
return ""
return "none of its paths (%s) changed in this push — it runs on a release tag and nightly" % ", ".join(prefixes)
def find_suites(scripts_dir):
out = []
for dp, dns, fns in os.walk(scripts_dir):
dns[:] = sorted(d for d in dns if d not in SKIP_DIRS)
for f in sorted(fns):
if f.startswith("test_") and f.endswith(".py"):
out.append(os.path.join(dp, f))
return out
def main(argv):
if os.environ.get(MARK):
print("script-tests: nested run (inside a suite this gate started) — stepping aside")
return 0
root = os.path.abspath(argv[1]) if len(argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
suites = find_suites(os.path.join(root, "scripts"))
if not suites:
print("script-tests: FAILED — no test_*.py found under %s/scripts (a gate that checked nothing is not green)" % root)
return 1
env = dict(os.environ, **{MARK: "1"})
changed = changed_paths()
failed = []
skipped = 0
for s in suites:
rel = os.path.relpath(s, root)
why = skip_reason(rel, changed)
if why:
print(" SKIP %-55s %s" % (rel, why))
skipped += 1
continue
t0 = time.time()
limit = timeout_for(rel)
try:
p = subprocess.run([sys.executable, "-W", "ignore", s], cwd=root, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=limit)
rc, tail = p.returncode, p.stdout.decode("utf-8", "replace").strip().splitlines()[-3:]
except subprocess.TimeoutExpired:
rc, tail = "timeout", ["(killed after %d s)" % limit]
verdict = "OK " if rc == 0 else "FAIL"
print(" %s %-55s %5.1fs rc=%s" % (verdict, rel, time.time() - t0, rc))
if rc != 0:
failed.append(rel)
for line in tail:
print(" | " + line[:200])
if failed:
print("script-tests: FAILED — %d of %d suite(s): %s" % (len(failed), len(suites), ", ".join(failed)))
return 1
print("script-tests: OK — %d suite(s) ran, every one exited 0%s" % (
len(suites) - skipped, (", %d skipped by path (R-1297)" % skipped) if skipped else ""))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))