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. Before is on the left; after is on the right. Unified view fits narrow screens more easily.
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()
|