Skip to content

Commit 3af2cac

Browse files
committed
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/<coin>/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: <target> -- <reason>. Currently audits 48 targets across 5 lanes, all present (zero drift).
1 parent f785aea commit 3af2cac

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

.github/workflows/build.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ on:
99

1010
jobs:
1111
# ════════════════════════════════════════════════════════════════════════════
12+
# Test-allowlist drift-guard (fast, no build): every per-coin test executable
13+
# declared in src/impl/<coin>/test/CMakeLists.txt must appear in a build.yml
14+
# --target list, else it becomes a NOT_BUILT CTest sentinel that silently
15+
# passes (the DGB #137 / test_dgb_subsidy class that red'd master).
16+
# ════════════════════════════════════════════════════════════════════════════
17+
test-allowlist-guard:
18+
name: Test allowlist drift-guard
19+
runs-on: ubuntu-24.04
20+
steps:
21+
- uses: actions/checkout@v4
22+
- name: Check coin test targets vs build.yml --target allowlist
23+
run: python3 tools/ci/check_test_target_allowlist.py
24+
# ════════════════════════════════════════════════════════════════════════════
1225
# Linux (Ubuntu 24.04, GCC 13, Conan)
1326
# ════════════════════════════════════════════════════════════════════════════
1427
linux:
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
# CI drift-guard: every per-coin test executable declared in
3+
# src/impl/<coin>/test/CMakeLists.txt MUST appear as a `--target` argument in
4+
# .github/workflows/build.yml. A test target added to CMake but missing from the
5+
# build.yml allowlist is never compiled, so CTest reports it as a NOT_BUILT
6+
# sentinel that *silently passes* -- this is the exact failure mode that red'd
7+
# master in the DGB #137 / test_dgb_subsidy regression. This guard fails closed
8+
# on that drift so it is caught at PR time, not after merge.
9+
#
10+
# Scope: per-coin test trees only (the integrator-scoped surface). Core/shared
11+
# test targets are out of scope.
12+
#
13+
# Escape hatch: a target intentionally NOT built in CI (e.g. a compile-only TU
14+
# or a live-only harness) must be declared explicitly with a comment line:
15+
# # ci-allowlist-exempt: <target_name> -- <reason>
16+
# anywhere in that coin's test CMakeLists.txt. Fail-closed: silence is a failure.
17+
18+
import os
19+
import re
20+
import sys
21+
import glob
22+
23+
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24+
BUILD_YML = os.path.join(REPO, ".github", "workflows", "build.yml")
25+
COIN_GLOB = os.path.join(REPO, "src", "impl", "*", "test", "CMakeLists.txt")
26+
27+
28+
def strip_comment(line):
29+
# CMake line comments start at '#'. The test CMakeLists do not use bracket
30+
# comments or '#' inside target names, so a plain cut is sufficient.
31+
return line.split("#", 1)[0]
32+
33+
34+
def build_yml_targets(path):
35+
"""Collect every token passed as a `cmake --build ... --target <tokens>`
36+
argument. Target names may contain '-' (e.g. c2pool-bch); option flags
37+
(-j$(nproc), --config) start with '-' and terminate a target run."""
38+
with open(path) as f:
39+
raw = f.read()
40+
# Fold YAML/shell line continuations so a --target block is one stream.
41+
raw = raw.replace("\\\n", " ")
42+
targets = set()
43+
for m in re.finditer(r"--target\b(.*?)(?:--config\b|-j|\n\s*\n|$)", raw, re.S):
44+
for tok in m.group(1).split():
45+
if tok in ("\\",):
46+
continue
47+
if tok.startswith("-"):
48+
break
49+
targets.add(tok)
50+
return targets
51+
52+
53+
def parse_exemptions(text):
54+
return set(
55+
m.group(1)
56+
for m in re.finditer(r"ci-allowlist-exempt:\s*([A-Za-z0-9_-]+)", text)
57+
)
58+
59+
60+
def parse_coin_targets(path):
61+
"""Return the set of concrete test-executable target names declared in a
62+
coin test CMakeLists.txt, expanding `foreach(t IN LISTS VAR)` +
63+
`add_executable(prefix_${t} ...)` generator patterns."""
64+
with open(path) as f:
65+
raw = f.read()
66+
clean = "\n".join(strip_comment(l) for l in raw.splitlines())
67+
68+
lists = {}
69+
targets = set()
70+
foreach_stack = [] # list of (loopvar, [items])
71+
72+
# Commands of interest; their args never contain nested parens here.
73+
cmd_re = re.compile(
74+
r"\b(set|foreach|endforeach|add_executable)\s*\(([^()]*)\)", re.S
75+
)
76+
for m in cmd_re.finditer(clean):
77+
cmd, args = m.group(1), m.group(2).split()
78+
if cmd == "set" and args:
79+
lists[args[0]] = [a for a in args[1:] if "$" not in a and '"' not in a]
80+
elif cmd == "foreach":
81+
loopvar = args[0] if args else None
82+
items = []
83+
if "LISTS" in args:
84+
for lv in args[args.index("LISTS") + 1:]:
85+
items.extend(lists.get(lv, []))
86+
foreach_stack.append((loopvar, items))
87+
elif cmd == "endforeach":
88+
if foreach_stack:
89+
foreach_stack.pop()
90+
elif cmd == "add_executable" and args:
91+
name = args[0]
92+
names = [name]
93+
if "$" in name:
94+
# Expand against every enclosing foreach loop variable.
95+
for loopvar, items in reversed(foreach_stack):
96+
if loopvar and ("${%s}" % loopvar) in name:
97+
names = [n.replace("${%s}" % loopvar, it)
98+
for n in names for it in items]
99+
for n in names:
100+
if "$" not in n: # only fully-resolved targets
101+
targets.add(n)
102+
return targets
103+
104+
105+
def main():
106+
allowlist = build_yml_targets(BUILD_YML)
107+
violations = []
108+
audited = 0
109+
for cml in sorted(glob.glob(COIN_GLOB)):
110+
coin = cml.split(os.sep)[-3]
111+
with open(cml) as f:
112+
exempt = parse_exemptions(f.read())
113+
for tgt in sorted(parse_coin_targets(cml)):
114+
audited += 1
115+
if tgt in allowlist or tgt in exempt:
116+
continue
117+
violations.append((coin, tgt, cml))
118+
119+
if violations:
120+
print("CI drift-guard FAILED: coin test target(s) missing from "
121+
".github/workflows/build.yml --target allowlist (NOT_BUILT risk):\n")
122+
for coin, tgt, cml in violations:
123+
print(" [%s] %s (declared in %s)"
124+
% (coin, tgt, os.path.relpath(cml, REPO)))
125+
print("\nFix: add the target to the relevant build.yml --target list, "
126+
"or declare it '# ci-allowlist-exempt: <target> -- <reason>' in "
127+
"the coin test CMakeLists.txt.")
128+
return 1
129+
130+
print("CI drift-guard OK: %d per-coin test target(s) across %d coin lane(s) "
131+
"all present in build.yml --target allowlist."
132+
% (audited, len(glob.glob(COIN_GLOB))))
133+
return 0
134+
135+
136+
if __name__ == "__main__":
137+
sys.exit(main())

0 commit comments

Comments
 (0)