Skip to content
Bitcoin Roots

v31.1-roots.1 · Commit 8: [PATCH 08/17] chore(roots): integrate v31 identity and maintenance tooling

ci/change-classifier.py

Before: /dev/null · After: ci/change-classifier.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..11caa47e2367156f0f87573342c61e92ebb86ed6

Download original patch · Release notes

Diff rows 1–149 of 149. 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,149 @@
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", "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, reuse_canonical_ci=False):
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["nightly_fuzz"] = True
84
+ elif label == "ci:platforms":
85
+ selected["platforms"] = True
86
+ selected["nightly_platforms"] = True
87
+ else:
88
+ selected[label.removeprefix("ci:")] = True
89
+ if selected["nightly_full"]:
90
+ selected.update({name: False for name in OPTIONAL_SELECTION if name != "nightly_full"})
91
+ if reuse_canonical_ci and complete and "unknown" not in categories and not labels:
92
+ # The caller has verified exact canonical CI and promotion tree parity.
93
+ # Baseline lint/classifier and separate main documentation checks remain.
94
+ selected.update({name: False for name in BROAD_SELECTION})
95
+ broad = False
96
+ return {
97
+ "version": policy["version"],
98
+ "categories": [category for category in KNOWN_CATEGORIES if category in categories],
99
+ "labels": labels,
100
+ "broad": broad,
101
+ "complete": complete and "unknown" not in categories,
102
+ "selected": selected,
103
+ }
104
+
105
+
106
+ def write_github_output(result, output_path):
107
+ lines = [f"{key}={'true' if value else 'false'}" for key, value in sorted(result["selected"].items())]
108
+ lines.extend((
109
+ f"broad={'true' if result['broad'] else 'false'}",
110
+ f"complete={'true' if result['complete'] else 'false'}",
111
+ "categories=" + json.dumps(result["categories"], separators=(",", ":")),
112
+ "labels=" + json.dumps(result["labels"], separators=(",", ":")),
113
+ "result=" + json.dumps(result, sort_keys=True, separators=(",", ":")),
114
+ ))
115
+ with open(output_path, "a", encoding="utf-8") as output:
116
+ output.write("\n".join(lines) + "\n")
117
+
118
+
119
+ def main():
120
+ parser = argparse.ArgumentParser(description=__doc__)
121
+ parser.add_argument("--files-json", required=True, help="JSON array of repository-relative changed paths")
122
+ parser.add_argument("--labels-json", default="[]", help="JSON array of pull-request labels")
123
+ parser.add_argument("--truncated", action="store_true", help="Fail open because the file list is incomplete")
124
+ parser.add_argument("--error", action="store_true", help="Fail open because file retrieval failed")
125
+ parser.add_argument("--github-output", help="Path to append GitHub Actions outputs")
126
+ parser.add_argument("--reuse-canonical-ci", action="store_true",
127
+ help="Suppress duplicate compiled jobs after verified promotion proof")
128
+ args = parser.parse_args()
129
+ try:
130
+ files = load_json(args.files_json, "--files-json")
131
+ labels = load_json(args.labels_json, "--labels-json")
132
+ with open(POLICY_PATH, encoding="utf-8") as policy_file:
133
+ policy = json.load(policy_file)
134
+ if (tuple(policy["categories"]) != KNOWN_CATEGORIES or
135
+ tuple(policy["labels"]) != KNOWN_LABELS or
136
+ set(policy["defaults"]) != {"baseline", *KNOWN_CATEGORIES}):
137
+ raise ValueError("policy has an unsupported schema")
138
+ result = result_for(files, labels, args.truncated, args.error, policy,
139
+ args.reuse_canonical_ci)
140
+ except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
141
+ # The script itself must fail open if its API or policy interface changes.
142
+ result = result_for(None, None, True, True, SAFE_POLICY)
143
+ if args.github_output:
144
+ write_github_output(result, args.github_output)
145
+ print(json.dumps(result, sort_keys=True, separators=(",", ":")))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()