2026-09-16 14:02:46 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Fail unless required GitHub check runs succeeded on a commit SHA."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
REQUIRED_CHECK_NAMES = (
|
2026-09-19 15:36:40 -04:00
|
|
|
"ci-complete",
|
2026-09-16 14:02:46 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_recency(run: dict) -> tuple:
|
|
|
|
|
"""Order check runs so a later rerun wins over an earlier result."""
|
|
|
|
|
started = run.get("started_at") or ""
|
|
|
|
|
completed = run.get("completed_at") or ""
|
|
|
|
|
run_id = run.get("id") or 0
|
|
|
|
|
return (started, completed, run_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def classify_checks(
|
|
|
|
|
check_runs: list[dict], required_names: tuple[str, ...] = REQUIRED_CHECK_NAMES
|
|
|
|
|
) -> tuple[str, list[str]]:
|
|
|
|
|
"""Return ('success'|'pending'|'failure', detail lines)."""
|
|
|
|
|
by_name: dict[str, dict] = {}
|
|
|
|
|
for run in check_runs:
|
|
|
|
|
name = run.get("name")
|
|
|
|
|
if name not in required_names:
|
|
|
|
|
continue
|
|
|
|
|
current = by_name.get(name)
|
|
|
|
|
if current is None or _run_recency(run) > _run_recency(current):
|
|
|
|
|
by_name[name] = run
|
|
|
|
|
|
|
|
|
|
missing = [name for name in required_names if name not in by_name]
|
|
|
|
|
if missing:
|
|
|
|
|
return "pending", [f"missing: {name}" for name in missing]
|
|
|
|
|
|
|
|
|
|
details: list[str] = []
|
|
|
|
|
pending = False
|
|
|
|
|
failed = False
|
|
|
|
|
for name in required_names:
|
|
|
|
|
run = by_name[name]
|
|
|
|
|
status = run.get("status")
|
|
|
|
|
conclusion = run.get("conclusion")
|
|
|
|
|
details.append(f"{name} status={status} conclusion={conclusion}")
|
|
|
|
|
if status != "completed":
|
|
|
|
|
pending = True
|
|
|
|
|
elif conclusion != "success":
|
|
|
|
|
failed = True
|
|
|
|
|
if failed:
|
|
|
|
|
return "failure", details
|
|
|
|
|
if pending:
|
|
|
|
|
return "pending", details
|
|
|
|
|
return "success", details
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_check_runs(repo: str, sha: str, token: str) -> list[dict]:
|
|
|
|
|
url = (
|
|
|
|
|
f"https://api.github.com/repos/{repo}/commits/{urllib.parse.quote(sha)}"
|
|
|
|
|
"/check-runs?per_page=100"
|
|
|
|
|
)
|
|
|
|
|
request = urllib.request.Request(
|
|
|
|
|
url,
|
|
|
|
|
headers={
|
|
|
|
|
"Accept": "application/vnd.github+json",
|
|
|
|
|
"Authorization": f"Bearer {token}",
|
|
|
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(request) as response:
|
|
|
|
|
payload = json.load(response)
|
|
|
|
|
except urllib.error.HTTPError as exc:
|
|
|
|
|
body = exc.read().decode("utf-8", "replace")
|
|
|
|
|
raise SystemExit(f"GitHub check-runs HTTP {exc.code}: {body}") from exc
|
|
|
|
|
return payload.get("check_runs") or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
parser = argparse.ArgumentParser()
|
|
|
|
|
parser.add_argument("--repo", required=True)
|
|
|
|
|
parser.add_argument("--sha", required=True)
|
|
|
|
|
parser.add_argument("--timeout-seconds", type=int, default=1200)
|
|
|
|
|
parser.add_argument("--poll-seconds", type=int, default=15)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
token = __import__("os").environ.get("GITHUB_TOKEN") or __import__("os").environ.get(
|
|
|
|
|
"GH_TOKEN"
|
|
|
|
|
)
|
|
|
|
|
if not token:
|
|
|
|
|
print("FAIL: GITHUB_TOKEN or GH_TOKEN is required", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
deadline = time.time() + args.timeout_seconds
|
|
|
|
|
while True:
|
|
|
|
|
runs = fetch_check_runs(args.repo, args.sha, token)
|
|
|
|
|
state, details = classify_checks(runs)
|
|
|
|
|
for line in details:
|
|
|
|
|
print(line)
|
|
|
|
|
if state == "success":
|
|
|
|
|
print(f"PASS: required checks succeeded on {args.sha}")
|
|
|
|
|
return 0
|
|
|
|
|
if state == "failure":
|
|
|
|
|
print(f"FAIL: required checks did not succeed on {args.sha}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
if time.time() >= deadline:
|
|
|
|
|
print(
|
|
|
|
|
f"FAIL: timed out waiting for required checks on {args.sha}",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
return 1
|
|
|
|
|
print(f"waiting {args.poll_seconds}s for checks...")
|
|
|
|
|
time.sleep(args.poll_seconds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|