Skip to content
Bitcoin Roots

v29.4-roots.4 · Commit 8: [PATCH 08/37] 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()