mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 15:02:52 +01:00
110 lines
4.6 KiB
Python
110 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
|
|
|
|
This checks GitHub's current run and job records, not a prior PR's check marks.
|
|
The real Frame smoke and independent package checks remain release-procedure
|
|
gates documented in docs/releasing.md.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from urllib.parse import quote
|
|
|
|
|
|
REPO = "saphid/frame-control"
|
|
MAIN_JOBS = {
|
|
"checks", "e2e", "ios", "wireless-windows",
|
|
"server-tests (ubuntu-latest, 3.13)",
|
|
"server-tests (macos-latest, 3.12)",
|
|
"server-tests (windows-latest, 3.12)",
|
|
}
|
|
RELEASE_JOBS = {
|
|
"draft", "checksums",
|
|
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
|
|
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
|
|
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
|
|
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
|
|
}
|
|
DECKY_JOBS = {"decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)"}
|
|
|
|
|
|
class GateError(Exception):
|
|
pass
|
|
|
|
|
|
def api(path):
|
|
# publish-release.sh already requires sh. Resolve gh through that same
|
|
# shell: on Git Bash, Python's Windows CreateProcess skips an extensionless
|
|
# test gh shim in PATH and accidentally launches the real gh.exe instead.
|
|
command = ["sh", "-c", 'gh api "$1"', "sh", f"repos/{REPO}/{path}"]
|
|
result = subprocess.run(command, text=True, capture_output=True, check=False)
|
|
if result.returncode:
|
|
raise GateError(f"GitHub gate read failed for {path}: {result.stderr.strip()}")
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except json.JSONDecodeError as error:
|
|
raise GateError(f"invalid GitHub gate response for {path}: {error}") from error
|
|
|
|
|
|
def tag_commit(tag):
|
|
target = api(f"git/ref/tags/{quote(tag, safe='')}").get("object") or {}
|
|
for _ in range(5):
|
|
kind, sha = target.get("type"), target.get("sha")
|
|
if not isinstance(sha, str) or len(sha) != 40:
|
|
raise GateError(f"{tag} has no valid tag target")
|
|
if kind == "commit":
|
|
return sha
|
|
if kind != "tag":
|
|
raise GateError(f"{tag} points to unsupported Git object {kind!r}")
|
|
target = api(f"git/tags/{sha}").get("object") or {}
|
|
raise GateError(f"{tag} has too many nested tag objects")
|
|
|
|
|
|
def exact_run(workflow, branch, sha, event):
|
|
query = f"actions/workflows/{workflow}/runs?branch={quote(branch, safe='')}&head_sha={sha}&per_page=100"
|
|
runs = api(query).get("workflow_runs") or []
|
|
matching = [run for run in runs if run.get("head_sha") == sha
|
|
and run.get("head_branch") == branch and run.get("event") == event]
|
|
if not matching:
|
|
raise GateError(f"no exact {workflow} {event} run for {branch} at {sha}")
|
|
run = max(matching, key=lambda item: item["id"])
|
|
if run.get("status") != "completed" or run.get("conclusion") != "success":
|
|
raise GateError(f"{workflow} run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
|
|
return run
|
|
|
|
|
|
def successful_jobs(run, required):
|
|
attempt = run.get("run_attempt")
|
|
if not isinstance(attempt, int) or attempt < 1:
|
|
raise GateError(f"run {run['id']} has no valid attempt number")
|
|
data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100")
|
|
jobs = data.get("jobs") or []
|
|
if data.get("total_count") != len(jobs):
|
|
raise GateError(f"run {run['id']} has more jobs than the gate inspected")
|
|
names = [job.get("name") for job in jobs]
|
|
if len(names) != len(set(names)) or not required.issubset(names):
|
|
missing = sorted(required.difference(names))
|
|
raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}")
|
|
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
|
|
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
|
|
if bad:
|
|
raise GateError(f"run {run['id']} has unfinished, failed, cancelled, or skipped jobs: {bad}")
|
|
print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)")
|
|
|
|
|
|
def main(tag):
|
|
if not tag.startswith("v") or not tag[1:]:
|
|
raise GateError("expected a version tag beginning with v")
|
|
sha = tag_commit(tag)
|
|
successful_jobs(exact_run("checks.yml", "main", sha, "push"), MAIN_JOBS)
|
|
successful_jobs(exact_run("decky-runtime.yml", "main", sha, "push"), DECKY_JOBS)
|
|
successful_jobs(exact_run("release.yml", tag, sha, "push"), RELEASE_JOBS)
|
|
print(f"exact release commit: {sha}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main(sys.argv[1])
|
|
except (IndexError, GateError) as error:
|
|
sys.exit(f"release gate failed: {error}")
|