v29.4-roots.1 · Commit 8: [PATCH 08/13] ci: restore assurance and isolate Core fixtures
ci/change-classifier.py
Before: /dev/null · After: ci/change-classifier.py
File metadata
new file mode 100755 index 0000000000000000000000000000000000000000..c4276f8b170a7c317535be5400fb4c5dc33d1320
Download original patch · Release notes
Diff rows 1–142 of 142. Line numbers refer to the original before/after files.
+ Added− RemovedUnmarked lines are context.
Scroll the code area horizontally to read long lines. The two line-number columns refer to before and after.
ci/change-classifier.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright (c) 2026 The Bitcoin Roots developers
|
|
3
|
+
# Distributed under the MIT software license, see the accompanying file COPYING.
|
|
4
|
+
"""Classify a complete pull-request file list into additive CI selections.
|
|
5
|
+
|
|
6
|
+
The caller must treat a missing, malformed, truncated, or ambiguous file list
|
|
7
|
+
as incomplete. Incomplete input deliberately selects broad coverage.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
POLICY_PATH = os.path.join(os.path.dirname(__file__), "change-classifier-policy.json")
|
|
16
|
+
KNOWN_CATEGORIES = ("docs-only", "branding", "gui", "wallet", "build", "critical", "cpp", "unknown")
|
|
17
|
+
KNOWN_LABELS = ("ci:full", "ci:sanitizers", "ci:fuzz", "ci:compat", "ci:platforms")
|
|
18
|
+
BROAD_SELECTION = ("gui", "wallet", "sanitizers", "fuzz", "compat", "platforms")
|
|
19
|
+
OPTIONAL_SELECTION = ("nightly_sanitizers", "nightly_fuzz", "nightly_platforms", "nightly_full")
|
|
20
|
+
SAFE_POLICY = {"version": 1, "defaults": {"unknown": "broad"}}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_json(value, name):
|
|
24
|
+
try:
|
|
25
|
+
parsed = json.loads(value)
|
|
26
|
+
except (TypeError, json.JSONDecodeError) as error:
|
|
27
|
+
raise ValueError(f"{name} is not valid JSON") from error
|
|
28
|
+
return parsed
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def path_is_ambiguous(path):
|
|
32
|
+
if not isinstance(path, str) or not path or "\\" in path or path.startswith("/"):
|
|
33
|
+
return True
|
|
34
|
+
# Inspect the raw spelling: PurePosixPath normalizes repeated separators
|
|
35
|
+
# and dot segments, which would turn an ambiguous API value into a match.
|
|
36
|
+
parts = path.split("/")
|
|
37
|
+
return any(part in ("", ".", "..") for part in parts)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def matches(path, rule):
|
|
41
|
+
return any(path.startswith(prefix) for prefix in rule["prefixes"]) or any(path.endswith(suffix) for suffix in rule["suffixes"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify_path(path, policy):
|
|
45
|
+
if path_is_ambiguous(path):
|
|
46
|
+
return "unknown"
|
|
47
|
+
for rule in policy["path_rules"]:
|
|
48
|
+
if matches(path, rule):
|
|
49
|
+
return rule["category"]
|
|
50
|
+
return "unknown"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def result_for(files, labels, truncated, error, policy):
|
|
54
|
+
complete = (isinstance(files, list) and isinstance(labels, list) and
|
|
55
|
+
all(isinstance(label, str) for label in labels) and
|
|
56
|
+
not truncated and not error)
|
|
57
|
+
categories = set()
|
|
58
|
+
if complete:
|
|
59
|
+
for path in files:
|
|
60
|
+
categories.add(classify_path(path, policy))
|
|
61
|
+
if not categories:
|
|
62
|
+
complete = False
|
|
63
|
+
if not complete or "unknown" in categories:
|
|
64
|
+
categories.add("unknown")
|
|
65
|
+
|
|
66
|
+
labels = sorted({label for label in labels if isinstance(label, str) and label in KNOWN_LABELS}) if isinstance(labels, list) else []
|
|
67
|
+
broad = any(policy["defaults"][category] == "broad" for category in categories)
|
|
68
|
+
selected = {"baseline": True, "docs": categories == {"docs-only"}}
|
|
69
|
+
selected.update({name: False for name in BROAD_SELECTION})
|
|
70
|
+
selected.update({name: False for name in OPTIONAL_SELECTION})
|
|
71
|
+
for category in categories:
|
|
72
|
+
defaults = policy["defaults"][category]
|
|
73
|
+
for name in BROAD_SELECTION if defaults == "broad" else defaults:
|
|
74
|
+
selected[name] = True
|
|
75
|
+
for label in labels:
|
|
76
|
+
if label == "ci:full":
|
|
77
|
+
selected.update({name: True for name in BROAD_SELECTION})
|
|
78
|
+
selected["nightly_full"] = True
|
|
79
|
+
elif label == "ci:sanitizers":
|
|
80
|
+
selected["sanitizers"] = True
|
|
81
|
+
selected["nightly_sanitizers"] = True
|
|
82
|
+
elif label == "ci:fuzz":
|
|
83
|
+
selected["fuzz"] = True
|
|
84
|
+
selected["nightly_fuzz"] = True
|
|
85
|
+
elif label == "ci:platforms":
|
|
86
|
+
selected["platforms"] = True
|
|
87
|
+
selected["nightly_platforms"] = True
|
|
88
|
+
else:
|
|
89
|
+
selected[label.removeprefix("ci:")] = True
|
|
90
|
+
if selected["nightly_full"]:
|
|
91
|
+
selected.update({name: False for name in OPTIONAL_SELECTION if name != "nightly_full"})
|
|
92
|
+
return {
|
|
93
|
+
"version": policy["version"],
|
|
94
|
+
"categories": [category for category in KNOWN_CATEGORIES if category in categories],
|
|
95
|
+
"labels": labels,
|
|
96
|
+
"broad": broad,
|
|
97
|
+
"complete": complete and "unknown" not in categories,
|
|
98
|
+
"selected": selected,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_github_output(result, output_path):
|
|
103
|
+
lines = [f"{key}={'true' if value else 'false'}" for key, value in sorted(result["selected"].items())]
|
|
104
|
+
lines.extend((
|
|
105
|
+
f"broad={'true' if result['broad'] else 'false'}",
|
|
106
|
+
f"complete={'true' if result['complete'] else 'false'}",
|
|
107
|
+
"categories=" + json.dumps(result["categories"], separators=(",", ":")),
|
|
108
|
+
"labels=" + json.dumps(result["labels"], separators=(",", ":")),
|
|
109
|
+
"result=" + json.dumps(result, sort_keys=True, separators=(",", ":")),
|
|
110
|
+
))
|
|
111
|
+
with open(output_path, "a", encoding="utf-8") as output:
|
|
112
|
+
output.write("\n".join(lines) + "\n")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
117
|
+
parser.add_argument("--files-json", required=True, help="JSON array of repository-relative changed paths")
|
|
118
|
+
parser.add_argument("--labels-json", default="[]", help="JSON array of pull-request labels")
|
|
119
|
+
parser.add_argument("--truncated", action="store_true", help="Fail open because the file list is incomplete")
|
|
120
|
+
parser.add_argument("--error", action="store_true", help="Fail open because file retrieval failed")
|
|
121
|
+
parser.add_argument("--github-output", help="Path to append GitHub Actions outputs")
|
|
122
|
+
args = parser.parse_args()
|
|
123
|
+
try:
|
|
124
|
+
files = load_json(args.files_json, "--files-json")
|
|
125
|
+
labels = load_json(args.labels_json, "--labels-json")
|
|
126
|
+
with open(POLICY_PATH, encoding="utf-8") as policy_file:
|
|
127
|
+
policy = json.load(policy_file)
|
|
128
|
+
if (tuple(policy["categories"]) != KNOWN_CATEGORIES or
|
|
129
|
+
tuple(policy["labels"]) != KNOWN_LABELS or
|
|
130
|
+
set(policy["defaults"]) != {"baseline", *KNOWN_CATEGORIES}):
|
|
131
|
+
raise ValueError("policy has an unsupported schema")
|
|
132
|
+
result = result_for(files, labels, args.truncated, args.error, policy)
|
|
133
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
|
134
|
+
# The script itself must fail open if its API or policy interface changes.
|
|
135
|
+
result = result_for(None, None, True, True, SAFE_POLICY)
|
|
136
|
+
if args.github_output:
|
|
137
|
+
write_github_output(result, args.github_output)
|
|
138
|
+
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|