v31.1-roots.1 · Commit 8: [PATCH 08/17] chore(roots): integrate v31 identity and maintenance tooling
ci/test/roots-regressions.py
Before: /dev/null · After: ci/test/roots-regressions.py
File metadata
new file mode 100644 index 0000000000000000000000000000000000000000..cb6d11eec5a42943cc4a5c2dada2f61a50248b56
Download original patch · Release notes
Diff rows 1–136 of 136. Line numbers refer to the original before/after files.
+ Added− RemovedUnmarked lines are context.
Scroll the code area horizontally to read long lines. Before is on the left; after is on the right. Unified view fits narrow screens more easily.
ci/test/roots-regressions.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# Copyright (c) 2026-present The Bitcoin Roots developers
|
| 3 |
+
# Distributed under the MIT software license, see the accompanying file COPYING.
|
| 4 |
+
"""Select affected Roots regressions and enforce fast/nightly execution budgets.
|
| 5 |
+
|
| 6 |
+
This selects commands for the native runners; it is not a release-state store.
|
| 7 |
+
Incomplete or unknown paths deliberately select every Roots regression group.
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
import signal
|
| 14 |
+
import subprocess
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
GROUPS = {
|
| 18 |
+
"policy": ("feature_policy_args.py", "mempool_datacarrier.py", "mempool_subdust_fee_penalty.py", "mempool_dynamic_dust.py", "mempool_roots_local_policy.py"),
|
| 19 |
+
"mining": ("mining_roots_template.py", "mempool_truc.py"),
|
| 20 |
+
"wallet": ("wallet_send.py", "wallet_sweepprivkeys.py", "wallet_signer.py"),
|
| 21 |
+
"net": ("p2p_filter.py", "p2p_invalid_messages.py", "rpc_net.py"),
|
| 22 |
+
"runtime": ("feature_config_args.py", "rpc_effective_policy.py", "rpc_uptime.py"),
|
| 23 |
+
}
|
| 24 |
+
FUZZ = {
|
| 25 |
+
"policy": ("policy_args", "subdust_fee_penalty", "dynamic_dust_fee"),
|
| 26 |
+
"mining": ("tx_pool", "txgraph"),
|
| 27 |
+
"wallet": ("script", "psbt"),
|
| 28 |
+
"net": ("process_message",),
|
| 29 |
+
"runtime": ("policy_args", "string"),
|
| 30 |
+
}
|
| 31 |
+
BUDGETS = {"fast": {"functional": 900, "fuzz": 5}, "nightly": {"functional": 7200, "fuzz": 60}}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def affected(files):
|
| 35 |
+
groups = set()
|
| 36 |
+
if not isinstance(files, list) or not files:
|
| 37 |
+
return sorted(GROUPS)
|
| 38 |
+
for path in files:
|
| 39 |
+
if not isinstance(path, str) or any(part in ("", ".", "..") for part in path.split('/')) or '\\' in path:
|
| 40 |
+
return sorted(GROUPS)
|
| 41 |
+
if path.startswith(('doc/man/', 'share/examples/')):
|
| 42 |
+
return sorted(GROUPS)
|
| 43 |
+
if path.startswith(('doc/', 'share/', 'src/qt/res/')) or path == 'README.md':
|
| 44 |
+
continue
|
| 45 |
+
if path.startswith(('src/wallet/', 'src/qt/')) or path.startswith('test/functional/wallet_'):
|
| 46 |
+
groups.add('wallet')
|
| 47 |
+
elif path.startswith(('src/net', 'src/protocol', 'test/functional/p2p_')) or path.endswith('rpc_net.py'):
|
| 48 |
+
groups.add('net')
|
| 49 |
+
elif path.startswith(('src/node/miner', 'src/rpc/mining')) or 'mining_' in path:
|
| 50 |
+
groups.update(('mining', 'policy'))
|
| 51 |
+
elif path.startswith('src/node/mempool'):
|
| 52 |
+
groups.update(('policy', 'mining', 'runtime'))
|
| 53 |
+
elif path.startswith(('src/policy/', 'src/txmempool', 'src/txgraph', 'src/validation')) or path.startswith('test/functional/mempool_'):
|
| 54 |
+
groups.update(('policy', 'mining'))
|
| 55 |
+
elif path.startswith(('src/common/', 'src/util/', 'src/rpc/')) or path.endswith(('feature_config_args.py', 'rpc_effective_policy.py', 'rpc_uptime.py')):
|
| 56 |
+
groups.update(('runtime', 'policy'))
|
| 57 |
+
else:
|
| 58 |
+
return sorted(GROUPS)
|
| 59 |
+
return sorted(groups)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def selection(files, cadence):
|
| 63 |
+
groups = affected(files)
|
| 64 |
+
return {"groups": groups, "functional": sorted({test for group in groups for test in GROUPS[group]}),
|
| 65 |
+
"fuzz": sorted({target for group in groups for target in FUZZ[group]}), "budgets": BUDGETS[cadence]}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def run_bounded(command, timeout, log, env=None):
|
| 69 |
+
"""Kill only this owned process group on timeout and retain failure output."""
|
| 70 |
+
with log.open('w', encoding='utf-8') as output:
|
| 71 |
+
try:
|
| 72 |
+
process = subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT, env=env,
|
| 73 |
+
start_new_session=os.name != 'nt')
|
| 74 |
+
except OSError as error:
|
| 75 |
+
output.write(str(error) + '\n')
|
| 76 |
+
return 127
|
| 77 |
+
try:
|
| 78 |
+
return process.wait(timeout=timeout)
|
| 79 |
+
except subprocess.TimeoutExpired:
|
| 80 |
+
if os.name == 'nt':
|
| 81 |
+
subprocess.run(['taskkill', '/F', '/T', '/PID', str(process.pid)], check=False, stdout=output, stderr=output)
|
| 82 |
+
else:
|
| 83 |
+
os.killpg(process.pid, signal.SIGTERM)
|
| 84 |
+
try:
|
| 85 |
+
process.wait(timeout=5)
|
| 86 |
+
except subprocess.TimeoutExpired:
|
| 87 |
+
os.killpg(process.pid, signal.SIGKILL)
|
| 88 |
+
process.wait()
|
| 89 |
+
return 124
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def execute(plan, kind, build, artifacts, runner_args):
|
| 93 |
+
artifacts.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
records = []
|
| 95 |
+
if kind == 'functional' and plan['functional']:
|
| 96 |
+
command = [sys.executable, str(build / 'test/functional/test_runner.py'), *plan['functional'], *runner_args]
|
| 97 |
+
timeout = plan['budgets']['functional']
|
| 98 |
+
result = run_bounded(command, timeout, artifacts / 'functional.log')
|
| 99 |
+
records.append({'command': command, 'timeout': timeout, 'exit': result})
|
| 100 |
+
elif kind == 'fuzz':
|
| 101 |
+
for target in plan['fuzz']:
|
| 102 |
+
# Native libFuzzer flags bound generation by time, not input counts.
|
| 103 |
+
budget = plan['budgets']['fuzz']
|
| 104 |
+
command = [str(build / 'bin/fuzz'), f'-max_total_time={budget}', '-timeout=5', '-seed=1',
|
| 105 |
+
'-max_len=4096', '-rss_limit_mb=2048', '-artifact_prefix=' + str(artifacts / (target + '-'))]
|
| 106 |
+
result = run_bounded(command, budget + 10, artifacts / (target + '.log'), os.environ | {'FUZZ': target})
|
| 107 |
+
records.append({'target': target, 'command': command, 'timeout': budget + 10, 'exit': result})
|
| 108 |
+
(artifacts / (kind + '-results.json')).write_text(json.dumps({'selection': plan, 'results': records}, indent=2) + '\n')
|
| 109 |
+
return 1 if any(record['exit'] != 0 for record in records) else 0
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def main():
|
| 113 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 114 |
+
parser.add_argument('kind', choices=['functional', 'fuzz', 'plan'])
|
| 115 |
+
parser.add_argument('--cadence', choices=BUDGETS, default='fast')
|
| 116 |
+
parser.add_argument('--files-json', default=os.getenv('ROOTS_CHANGED_FILES_JSON', 'null'))
|
| 117 |
+
parser.add_argument('--build-dir', type=Path)
|
| 118 |
+
parser.add_argument('--artifacts', type=Path)
|
| 119 |
+
args, runner_args = parser.parse_known_args()
|
| 120 |
+
try:
|
| 121 |
+
files = json.loads(args.files_json)
|
| 122 |
+
except (ValueError, TypeError):
|
| 123 |
+
files = None
|
| 124 |
+
plan = selection(files, args.cadence)
|
| 125 |
+
if args.kind == 'plan':
|
| 126 |
+
print(json.dumps(plan, sort_keys=True))
|
| 127 |
+
return 0
|
| 128 |
+
if not args.build_dir or not args.artifacts:
|
| 129 |
+
parser.error('execution requires --build-dir and --artifacts')
|
| 130 |
+
if runner_args and runner_args[0] == '--':
|
| 131 |
+
runner_args = runner_args[1:]
|
| 132 |
+
return execute(plan, args.kind, args.build_dir.resolve(), args.artifacts.resolve(), runner_args)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == '__main__':
|
| 136 |
+
sys.exit(main())
|