Skip to content

Commit 708afa5

Browse files
committed
ci: add required-check migration tooling and cutover guide
1 parent 27b3cf9 commit 708afa5

4 files changed

Lines changed: 597 additions & 3 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,9 @@ separately so its local hook and CI binary stay aligned. The local lychee hook
228228
keeps its current behavior until a stable release supports an explicit cache
229229
location.
230230

231-
See [the pre-commit workflow guide](ci/README-pre-commit-migration.md) for manual
232-
workflow testing. The existing pre-commit.ci service remains required until the
233-
required-check cutover is complete.
231+
See [the pre-commit migration procedure](ci/README-pre-commit-migration.md) for
232+
manual workflow testing and the required-check cutover. The existing
233+
pre-commit.ci service remains required until that cutover is complete.
234234

235235
To set yourself up for running pre-commit checks locally and to catch issues before pushing your changes, follow these steps:
236236

‎ci/README-pre-commit-migration.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,58 @@ PIXI_LOCKED=true pixi run --manifest-path cuda_core/pixi.toml -e docs \
4949
The build verifies all three sibling libraries import from the checkout,
5050
installs metapackage metadata without dependencies, and assembles all four
5151
latest documentation trees under `artifacts/docs`.
52+
53+
## Preview the ruleset change
54+
55+
Run from the repository root with an authenticated GitHub CLI:
56+
57+
```sh
58+
pixi exec --spec python python ci/tools/migrate_precommit_checks.py \
59+
--repo NVIDIA/cuda-python > /tmp/cuda-python-precommit-cutover.json
60+
```
61+
62+
The default mode only reads GitHub. It discovers active rules applying to
63+
`main`, fetches complete repository rulesets, and prints their original
64+
configuration and the exact proposed PUT payload. Review that JSON before
65+
cutover. The payload changes only `rules`: unrelated rules and required checks,
66+
strictness, branch conditions, bypass actors, and enforcement remain in place.
67+
The tool rejects inherited organization rulesets and unexpected integration IDs;
68+
an organization ruleset's owner must handle that migration separately.
69+
70+
## Cut over after merge
71+
72+
1. Merge `.github/workflows/pre-commit.yml` and `.github/workflows/lychee.yml`.
73+
Check that they exist on the current default `main` branch.
74+
2. Obtain successful, completed runs of all three replacement checks on current
75+
`main` or a representative open PR targeting `main`. Record the full checked
76+
commit SHA. This also verifies the PR event path when using a PR; a branch
77+
push run alone does not validate PR-specific behavior.
78+
3. After explicit authorization for the ruleset cutover, run the preview again,
79+
then run the same command with `--apply --verified-sha FULL_COMMIT_SHA`.
80+
Omitting `--verified-sha` requires checks on the current `main` commit.
81+
4. Confirm the active rules now require all three replacement contexts from the
82+
GitHub Actions App and still contain every unrelated requirement. Verify on
83+
a representative PR that an intentional formatting or broken-link failure
84+
is reported as a required failing check and blocks merging; repair it and
85+
verify the same check passes. Check this independently of the PR's draft
86+
status, which also prevents merging.
87+
5. Only after that verification, remove this repository from the pre-commit.ci
88+
GitHub App installation's repository access, or disable its service through
89+
the repository/service settings. Preserve access for other repositories
90+
using the same installation. The migration script never changes App access.
91+
92+
`--apply` verifies the workflow files on `main`, the selected commit's checks,
93+
their App IDs, and their successful owning workflow runs before writing. It
94+
re-reads all affected rulesets and refuses changes made since discovery.
95+
GitHub does not provide an atomic transaction across ruleset updates: if a
96+
write fails partway through, inspect the emitted preview and actual rulesets
97+
before retrying. Do not remove the service while any old required check remains.
98+
99+
## Validate the helper
100+
101+
```sh
102+
pixi exec --spec python --spec pytest python -m pytest \
103+
--noconftest ci/tools/tests/test_migrate_precommit_checks.py
104+
```
105+
106+
The tests mock GitHub; they cannot change repository settings.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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

Comments
 (0)