From 3af2cac248e710362a8ceed976d10c2ed9fdb151 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sat, 20 Jun 2026 08:49:14 +0000 Subject: [PATCH] ci: generic test-allowlist drift-guard (NOT_BUILT fail-closed) Add tools/ci/check_test_target_allowlist.py + a standalone, build-free CI job that fails closed when a per-coin test executable declared in src/impl//test/CMakeLists.txt is absent from the build.yml --target allowlist. Such a target is never compiled, so CTest reports it NOT_BUILT and silently passes -- the DGB #137 / test_dgb_subsidy class that red'd master. Generic across all coin lanes (bch/btc/dgb/ltc/nmc); expands foreach(t IN LISTS VAR) + add_executable(prefix_${t}) generators. Escape hatch for intentionally unbuilt targets: # ci-allowlist-exempt: -- . Currently audits 48 targets across 5 lanes, all present (zero drift). --- .github/workflows/build.yml | 13 +++ tools/ci/check_test_target_allowlist.py | 137 ++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tools/ci/check_test_target_allowlist.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e991d71f..d0efe34c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,19 @@ on: jobs: # ════════════════════════════════════════════════════════════════════════════ + # Test-allowlist drift-guard (fast, no build): every per-coin test executable + # declared in src/impl//test/CMakeLists.txt must appear in a build.yml + # --target list, else it becomes a NOT_BUILT CTest sentinel that silently + # passes (the DGB #137 / test_dgb_subsidy class that red'd master). + # ════════════════════════════════════════════════════════════════════════════ + test-allowlist-guard: + name: Test allowlist drift-guard + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Check coin test targets vs build.yml --target allowlist + run: python3 tools/ci/check_test_target_allowlist.py + # ════════════════════════════════════════════════════════════════════════════ # Linux (Ubuntu 24.04, GCC 13, Conan) # ════════════════════════════════════════════════════════════════════════════ linux: diff --git a/tools/ci/check_test_target_allowlist.py b/tools/ci/check_test_target_allowlist.py new file mode 100644 index 000000000..62ba88374 --- /dev/null +++ b/tools/ci/check_test_target_allowlist.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# CI drift-guard: every per-coin test executable declared in +# src/impl//test/CMakeLists.txt MUST appear as a `--target` argument in +# .github/workflows/build.yml. A test target added to CMake but missing from the +# build.yml allowlist is never compiled, so CTest reports it as a NOT_BUILT +# sentinel that *silently passes* -- this is the exact failure mode that red'd +# master in the DGB #137 / test_dgb_subsidy regression. This guard fails closed +# on that drift so it is caught at PR time, not after merge. +# +# Scope: per-coin test trees only (the integrator-scoped surface). Core/shared +# test targets are out of scope. +# +# Escape hatch: a target intentionally NOT built in CI (e.g. a compile-only TU +# or a live-only harness) must be declared explicitly with a comment line: +# # ci-allowlist-exempt: -- +# anywhere in that coin's test CMakeLists.txt. Fail-closed: silence is a failure. + +import os +import re +import sys +import glob + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +BUILD_YML = os.path.join(REPO, ".github", "workflows", "build.yml") +COIN_GLOB = os.path.join(REPO, "src", "impl", "*", "test", "CMakeLists.txt") + + +def strip_comment(line): + # CMake line comments start at '#'. The test CMakeLists do not use bracket + # comments or '#' inside target names, so a plain cut is sufficient. + return line.split("#", 1)[0] + + +def build_yml_targets(path): + """Collect every token passed as a `cmake --build ... --target ` + argument. Target names may contain '-' (e.g. c2pool-bch); option flags + (-j$(nproc), --config) start with '-' and terminate a target run.""" + with open(path) as f: + raw = f.read() + # Fold YAML/shell line continuations so a --target block is one stream. + raw = raw.replace("\\\n", " ") + targets = set() + for m in re.finditer(r"--target\b(.*?)(?:--config\b|-j|\n\s*\n|$)", raw, re.S): + for tok in m.group(1).split(): + if tok in ("\\",): + continue + if tok.startswith("-"): + break + targets.add(tok) + return targets + + +def parse_exemptions(text): + return set( + m.group(1) + for m in re.finditer(r"ci-allowlist-exempt:\s*([A-Za-z0-9_-]+)", text) + ) + + +def parse_coin_targets(path): + """Return the set of concrete test-executable target names declared in a + coin test CMakeLists.txt, expanding `foreach(t IN LISTS VAR)` + + `add_executable(prefix_${t} ...)` generator patterns.""" + with open(path) as f: + raw = f.read() + clean = "\n".join(strip_comment(l) for l in raw.splitlines()) + + lists = {} + targets = set() + foreach_stack = [] # list of (loopvar, [items]) + + # Commands of interest; their args never contain nested parens here. + cmd_re = re.compile( + r"\b(set|foreach|endforeach|add_executable)\s*\(([^()]*)\)", re.S + ) + for m in cmd_re.finditer(clean): + cmd, args = m.group(1), m.group(2).split() + if cmd == "set" and args: + lists[args[0]] = [a for a in args[1:] if "$" not in a and '"' not in a] + elif cmd == "foreach": + loopvar = args[0] if args else None + items = [] + if "LISTS" in args: + for lv in args[args.index("LISTS") + 1:]: + items.extend(lists.get(lv, [])) + foreach_stack.append((loopvar, items)) + elif cmd == "endforeach": + if foreach_stack: + foreach_stack.pop() + elif cmd == "add_executable" and args: + name = args[0] + names = [name] + if "$" in name: + # Expand against every enclosing foreach loop variable. + for loopvar, items in reversed(foreach_stack): + if loopvar and ("${%s}" % loopvar) in name: + names = [n.replace("${%s}" % loopvar, it) + for n in names for it in items] + for n in names: + if "$" not in n: # only fully-resolved targets + targets.add(n) + return targets + + +def main(): + allowlist = build_yml_targets(BUILD_YML) + violations = [] + audited = 0 + for cml in sorted(glob.glob(COIN_GLOB)): + coin = cml.split(os.sep)[-3] + with open(cml) as f: + exempt = parse_exemptions(f.read()) + for tgt in sorted(parse_coin_targets(cml)): + audited += 1 + if tgt in allowlist or tgt in exempt: + continue + violations.append((coin, tgt, cml)) + + if violations: + print("CI drift-guard FAILED: coin test target(s) missing from " + ".github/workflows/build.yml --target allowlist (NOT_BUILT risk):\n") + for coin, tgt, cml in violations: + print(" [%s] %s (declared in %s)" + % (coin, tgt, os.path.relpath(cml, REPO))) + print("\nFix: add the target to the relevant build.yml --target list, " + "or declare it '# ci-allowlist-exempt: -- ' in " + "the coin test CMakeLists.txt.") + return 1 + + print("CI drift-guard OK: %d per-coin test target(s) across %d coin lane(s) " + "all present in build.yml --target allowlist." + % (audited, len(glob.glob(COIN_GLOB)))) + return 0 + + +if __name__ == "__main__": + sys.exit(main())