|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +"""Preview the main ruleset cutover from pre-commit.ci to GitHub Actions. |
| 5 | +
|
| 6 | +Writes require --apply, merged workflows, and successful representative checks. |
| 7 | +This tool does not uninstall the pre-commit.ci GitHub App. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import argparse |
| 13 | +import copy |
| 14 | +import json |
| 15 | +import re |
| 16 | +import subprocess |
| 17 | +import sys |
| 18 | +from urllib.parse import urlparse |
| 19 | + |
| 20 | +OLD_CONTEXT = "pre-commit.ci - pr" |
| 21 | +OLD_INTEGRATION = 68672 |
| 22 | +ACTIONS_INTEGRATION = 15368 |
| 23 | +CHECK_WORKFLOWS = { |
| 24 | + "Pre-commit (Linux)": ".github/workflows/pre-commit.yml", |
| 25 | + "Pre-commit (Windows)": ".github/workflows/pre-commit.yml", |
| 26 | + "Documentation links": ".github/workflows/lychee.yml", |
| 27 | +} |
| 28 | + |
| 29 | + |
| 30 | +def github_api(endpoint: str, *, method: str = "GET", payload: dict | None = None, paginate: bool = False): |
| 31 | + """Call gh with structured JSON input, without invoking a shell.""" |
| 32 | + command = ["gh", "api", "--method", method, endpoint, "-H", "Accept: application/vnd.github+json"] |
| 33 | + if paginate: |
| 34 | + command.extend(["--paginate", "--slurp"]) |
| 35 | + if payload is not None: |
| 36 | + command.extend(["--input", "-"]) |
| 37 | + result = subprocess.run( # noqa: S603 - arguments are passed directly, without a shell. |
| 38 | + command, |
| 39 | + input=json.dumps(payload) if payload is not None else None, |
| 40 | + capture_output=True, |
| 41 | + text=True, |
| 42 | + check=True, |
| 43 | + ) |
| 44 | + return json.loads(result.stdout) |
| 45 | + |
| 46 | + |
| 47 | +def migrate_rules(rules: list[dict]) -> list[dict]: |
| 48 | + """Replace only the known pre-commit.ci requirement, preserving all other rules.""" |
| 49 | + migrated = copy.deepcopy(rules) |
| 50 | + for rule in migrated: |
| 51 | + if rule["type"] != "required_status_checks": |
| 52 | + continue |
| 53 | + checks = rule["parameters"]["required_status_checks"] |
| 54 | + old_checks = [check for check in checks if check["context"] == OLD_CONTEXT] |
| 55 | + if not old_checks: |
| 56 | + continue |
| 57 | + if any(check.get("integration_id") != OLD_INTEGRATION for check in old_checks): |
| 58 | + raise RuntimeError(f"{OLD_CONTEXT!r} is not bound to expected integration {OLD_INTEGRATION}") |
| 59 | + existing = set() |
| 60 | + for check in checks: |
| 61 | + context = check["context"] |
| 62 | + if context in CHECK_WORKFLOWS: |
| 63 | + if check.get("integration_id") != ACTIONS_INTEGRATION: |
| 64 | + raise RuntimeError(f"{context!r} already exists with a different or unspecified integration") |
| 65 | + existing.add(context) |
| 66 | + replacement = [ |
| 67 | + {"context": context, "integration_id": ACTIONS_INTEGRATION} |
| 68 | + for context in CHECK_WORKFLOWS |
| 69 | + if context not in existing |
| 70 | + ] |
| 71 | + updated = [] |
| 72 | + for check in checks: |
| 73 | + if check["context"] == OLD_CONTEXT: |
| 74 | + updated.extend(replacement) |
| 75 | + replacement = [] |
| 76 | + else: |
| 77 | + updated.append(check) |
| 78 | + rule["parameters"]["required_status_checks"] = updated |
| 79 | + return migrated |
| 80 | + |
| 81 | + |
| 82 | +def discover_changes(repository: str) -> list[dict]: |
| 83 | + """Find active requirements on main and fetch their complete repository rulesets.""" |
| 84 | + pages = github_api(f"repos/{repository}/rules/branches/main?per_page=100", paginate=True) |
| 85 | + ruleset_ids = set() |
| 86 | + for rule in (rule for page in pages for rule in page): |
| 87 | + if rule["type"] != "required_status_checks": |
| 88 | + continue |
| 89 | + checks = rule["parameters"]["required_status_checks"] |
| 90 | + if not any(check["context"] == OLD_CONTEXT for check in checks): |
| 91 | + continue |
| 92 | + if rule["ruleset_source_type"] != "Repository" or rule["ruleset_source"].lower() != repository.lower(): |
| 93 | + raise RuntimeError( |
| 94 | + f"Requirement is inherited from {rule['ruleset_source_type']} {rule['ruleset_source']} " |
| 95 | + f"ruleset {rule['ruleset_id']}; its owner must migrate it. No repository changes were made." |
| 96 | + ) |
| 97 | + ruleset_ids.add(rule["ruleset_id"]) |
| 98 | + |
| 99 | + changes = [] |
| 100 | + for ruleset_id in sorted(ruleset_ids): |
| 101 | + endpoint = f"repos/{repository}/rulesets/{ruleset_id}" |
| 102 | + original = github_api(endpoint) |
| 103 | + if original["source_type"] != "Repository" or original["source"].lower() != repository.lower(): |
| 104 | + raise RuntimeError(f"Ruleset {ruleset_id} is not owned by {repository}") |
| 105 | + if original["target"] != "branch" or original["enforcement"] != "active": |
| 106 | + raise RuntimeError(f"Ruleset {ruleset_id} changed scope or enforcement; rerun the preview") |
| 107 | + migrated = migrate_rules(original["rules"]) |
| 108 | + if migrated != original["rules"]: |
| 109 | + changes.append({"endpoint": endpoint, "original": original, "payload": {"rules": migrated}}) |
| 110 | + return changes |
| 111 | + |
| 112 | + |
| 113 | +def verify_readiness(repository: str, verified_sha: str | None) -> str: |
| 114 | + """Require merged workflow files and green checks on main or an identified main PR.""" |
| 115 | + metadata = github_api(f"repos/{repository}") |
| 116 | + if metadata["default_branch"] != "main": |
| 117 | + raise RuntimeError("This migration is scoped to repositories whose default branch is main") |
| 118 | + main_sha = github_api(f"repos/{repository}/branches/main")["commit"]["sha"] |
| 119 | + for path in sorted(set(CHECK_WORKFLOWS.values())): |
| 120 | + content = github_api(f"repos/{repository}/contents/{path}?ref={main_sha}") |
| 121 | + if not isinstance(content, dict) or content.get("type") != "file": |
| 122 | + raise RuntimeError(f"{path} is not a workflow file on current main") |
| 123 | + |
| 124 | + sha = verified_sha or main_sha |
| 125 | + if sha != main_sha: |
| 126 | + pages = github_api(f"repos/{repository}/commits/{sha}/pulls?per_page=100", paginate=True) |
| 127 | + matching_prs = [ |
| 128 | + pr |
| 129 | + for page in pages |
| 130 | + for pr in page |
| 131 | + if pr["state"] == "open" |
| 132 | + and pr["base"]["ref"] == "main" |
| 133 | + and pr["base"]["repo"]["full_name"].lower() == repository.lower() |
| 134 | + and sha in (pr["head"]["sha"], pr.get("merge_commit_sha")) |
| 135 | + ] |
| 136 | + if not matching_prs: |
| 137 | + raise RuntimeError("--verified-sha must be current main or the head/merge SHA of an open PR targeting main") |
| 138 | + |
| 139 | + pages = github_api(f"repos/{repository}/commits/{sha}/check-runs?filter=latest&per_page=100", paginate=True) |
| 140 | + checks = [check for page in pages for check in page["check_runs"]] |
| 141 | + runs = {} |
| 142 | + for context, workflow in CHECK_WORKFLOWS.items(): |
| 143 | + matching = [check for check in checks if check["name"] == context and check["app"]["id"] == ACTIONS_INTEGRATION] |
| 144 | + if not matching: |
| 145 | + raise RuntimeError(f"Missing GitHub Actions check {context!r} on {sha}") |
| 146 | + check = max(matching, key=lambda check: check["id"]) |
| 147 | + if check["status"] != "completed" or check["conclusion"] != "success": |
| 148 | + raise RuntimeError(f"Check {context!r} on {sha} is not completed and successful") |
| 149 | + url = urlparse(check["details_url"]) |
| 150 | + match = re.fullmatch(rf"/{re.escape(repository)}/actions/runs/([0-9]+)/job/[0-9]+", url.path, re.IGNORECASE) |
| 151 | + if url.hostname != "github.com" or match is None: |
| 152 | + raise RuntimeError(f"Cannot identify the workflow run for {context!r}") |
| 153 | + run_id = match.group(1) |
| 154 | + if run_id not in runs: |
| 155 | + runs[run_id] = github_api(f"repos/{repository}/actions/runs/{run_id}") |
| 156 | + run = runs[run_id] |
| 157 | + if ( |
| 158 | + run["path"].split("@", 1)[0] != workflow |
| 159 | + or run["check_suite_id"] != check["check_suite"]["id"] |
| 160 | + or run["status"] != "completed" |
| 161 | + or run["conclusion"] != "success" |
| 162 | + ): |
| 163 | + raise RuntimeError(f"Check {context!r} does not belong to a successful {workflow} run") |
| 164 | + return sha |
| 165 | + |
| 166 | + |
| 167 | +def apply_changes(repository: str, changes: list[dict], verified_sha: str | None) -> str: |
| 168 | + """Preflight all changes before issuing narrowly scoped rules updates.""" |
| 169 | + sha = verify_readiness(repository, verified_sha) |
| 170 | + for change in changes: |
| 171 | + if github_api(change["endpoint"]) != change["original"]: |
| 172 | + raise RuntimeError(f"{change['endpoint']} changed after discovery; rerun the preview") |
| 173 | + for change in changes: |
| 174 | + updated = github_api(change["endpoint"], method="PUT", payload=change["payload"]) |
| 175 | + original = change["original"] |
| 176 | + settings = ("name", "target", "enforcement", "conditions", "bypass_actors") |
| 177 | + if updated["rules"] != change["payload"]["rules"] or any( |
| 178 | + updated.get(key) != original.get(key) for key in settings |
| 179 | + ): |
| 180 | + raise RuntimeError(f"Unexpected response after updating {change['endpoint']}; inspect that ruleset") |
| 181 | + return sha |
| 182 | + |
| 183 | + |
| 184 | +def main(argv: list[str] | None = None) -> int: |
| 185 | + parser = argparse.ArgumentParser(description=__doc__) |
| 186 | + parser.add_argument("--repo", required=True, choices=("NVIDIA/cuda-python", "NVIDIA-dev/cuda-python-private")) |
| 187 | + parser.add_argument("--verified-sha", help="Full SHA of current main or a representative open PR head/merge commit") |
| 188 | + parser.add_argument( |
| 189 | + "--apply", action="store_true", help="Update required checks after explicit cutover authorization" |
| 190 | + ) |
| 191 | + args = parser.parse_args(argv) |
| 192 | + if args.verified_sha and re.fullmatch(r"[0-9a-f]{40}", args.verified_sha) is None: |
| 193 | + parser.error("--verified-sha must be a full 40-character lowercase commit SHA") |
| 194 | + |
| 195 | + try: |
| 196 | + changes = discover_changes(args.repo) |
| 197 | + preview = { |
| 198 | + "repository": args.repo, |
| 199 | + "branch": "main", |
| 200 | + "mode": "apply" if args.apply else "dry-run", |
| 201 | + "changes": [ |
| 202 | + { |
| 203 | + "endpoint": change["endpoint"], |
| 204 | + "before": change["original"], |
| 205 | + "put_payload": change["payload"], |
| 206 | + } |
| 207 | + for change in changes |
| 208 | + ], |
| 209 | + } |
| 210 | + print(json.dumps(preview, indent=2)) |
| 211 | + if args.apply and changes: |
| 212 | + sha = apply_changes(args.repo, changes, args.verified_sha) |
| 213 | + print(f"Updated {len(changes)} ruleset(s); representative checks verified on {sha}", file=sys.stderr) |
| 214 | + elif not changes: |
| 215 | + print("No matching active pre-commit.ci requirement on main; no updates made.", file=sys.stderr) |
| 216 | + else: |
| 217 | + print( |
| 218 | + "Dry run: no writes made. --apply checks merged workflows and successful checks before writing.", |
| 219 | + file=sys.stderr, |
| 220 | + ) |
| 221 | + except (RuntimeError, subprocess.CalledProcessError, json.JSONDecodeError) as exc: |
| 222 | + if isinstance(exc, subprocess.CalledProcessError) and exc.stderr: |
| 223 | + print(exc.stderr.strip(), file=sys.stderr) |
| 224 | + print(f"error: {exc}", file=sys.stderr) |
| 225 | + return 2 |
| 226 | + return 0 |
| 227 | + |
| 228 | + |
| 229 | +if __name__ == "__main__": |
| 230 | + sys.exit(main()) |
0 commit comments