"""Sync the public release repo and publish a GitHub Release. ``python build/run.py release github`` runs, in order: 1. certified-wheels gate (a passing ``release preflight`` report must exist and every wheel must still hash to what it recorded) 2. refuse if the version is already tagged or already has a GitHub Release 3. sync the open-source subset of this checkout into ``orisys-sdk-release`` (``tools/sync_downstreams.py`` + the manifest's ``checks``) 4. stage ``SDK//*.whl`` into ``orisys-sdk-release/release//`` 5. commit, tag ``v``, push branch + tag 6. create the GitHub Release with the wheels attached, then verify the assets Nothing mutates anything unless ``--apply`` is passed: the default is a dry run that prints every command it would run. """ from __future__ import annotations import json import shutil import subprocess import sys from dataclasses import dataclass, field from pathlib import Path _BUILD_ROOT = Path(__file__).resolve().parents[1] if str(_BUILD_ROOT) not in sys.path: sys.path.insert(0, str(_BUILD_ROOT)) from common.paths import REPO_ROOT # noqa: E402 from release.publish import GateResult, PublishGateError, load_gate # noqa: E402 MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json" SYNC_TOOL = REPO_ROOT / "tools" / "sync_downstreams.py" RELEASE_TARGET = "orisys-sdk-release" @dataclass class Options: version: str apply: bool = False notes_file: Path | None = None attach_preflight: bool = False allow_dirty_release: bool = False skip_fetch: bool = False skip_checks: bool = False skip_release_upload: bool = False @dataclass class CommandStep: description: str argv: list[str] cwd: Path | None = None @dataclass class Plan: release_repo: Path branch: str tag: str assets: list[Path] = field(default_factory=list) steps: list[CommandStep] = field(default_factory=list) def manifest_entry() -> dict: """The manifest entry for the public release repo.""" payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) for item in payload.get("downstreams", []): if item.get("name") == RELEASE_TARGET: return item raise PublishGateError(f"{MANIFEST_PATH} has no downstream named {RELEASE_TARGET!r}") def release_repo_path() -> Path: return (REPO_ROOT / manifest_entry()["path"]).resolve() def _git(repo: Path, *args: str, check: bool = True) -> str: completed = subprocess.run( ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False, ) if check and completed.returncode != 0: raise PublishGateError( f"git {' '.join(args)} failed in {repo}:\n{completed.stderr.strip()}" ) return (completed.stdout or "").strip() def build_release_notes(version: str, wheels: list[Path], git_commit: str | None) -> str: lines = [ f"orisys {version}", "", "Wheels (Python 3.10 / 3.11 / 3.12, Windows x64 and Linux x86_64):", ] lines.extend(f"- `{wheel.name}`" for wheel in wheels) lines.append("") if git_commit: lines.append( f"Built from `{git_commit[:12]}` and certified by `release preflight`." ) return "\n".join(lines) + "\n" def build_github_steps( *, version: str, branch: str, tag: str, assets: list[Path], notes_file: Path | None, title: str | None = None, ) -> list[CommandStep]: """The mutating commands, as pure data so they can be asserted in tests.""" release_argv = [ "gh", "release", "create", tag, "--verify-tag", "--title", title or f"v{version}", "--latest", ] if notes_file is not None: release_argv += ["--notes-file", str(notes_file)] release_argv += [str(asset) for asset in assets] return [ CommandStep("create tag", ["git", "tag", tag]), CommandStep("push branch", ["git", "push", "origin", branch]), CommandStep("push tag", ["git", "push", "origin", tag]), CommandStep("create GitHub Release with assets", release_argv), ] def _check_gh() -> list[str]: """Problems that would stop the release step, reported before any mutation.""" problems: list[str] = [] if shutil.which("gh") is None: problems.append( "the `gh` CLI is not on PATH. Install it (`winget install --id GitHub.cli`) " "and authenticate (`gh auth login`), or use --skip-release-upload" ) return problems completed = subprocess.run( ["gh", "auth", "status"], capture_output=True, text=True, check=False ) if completed.returncode != 0: problems.append("`gh auth status` failed; run `gh auth login` first") return problems def _existing_release_tags(repo: Path, version: str) -> list[str]: wanted = {version, f"v{version}"} tags = set(_git(repo, "tag", "--list").splitlines()) return sorted(wanted & tags) def _release_exists(tag: str) -> bool: completed = subprocess.run( ["gh", "release", "view", tag, "--json", "tagName"], capture_output=True, text=True, check=False, ) return completed.returncode == 0 def _sync(*, run_checks: bool) -> None: """Copy the public subset into the release repo and run its checks.""" python = sys.executable for command in ( [python, str(SYNC_TOOL), "apply", "--target", RELEASE_TARGET], *( [[python, str(SYNC_TOOL), "run-checks", "--target", RELEASE_TARGET]] if run_checks else [] ), ): completed = subprocess.run(command, cwd=REPO_ROOT, check=False) if completed.returncode != 0: raise PublishGateError( f"`{' '.join(command[1:])}` failed with exit {completed.returncode}" ) def _stage_wheels(wheels: list[Path], asset_dir: Path) -> None: asset_dir.mkdir(parents=True, exist_ok=True) for wheel in wheels: shutil.copy2(wheel, asset_dir / wheel.name) def publish(options: Options) -> int: version = options.version mode = "APPLY" if options.apply else "DRY RUN" print(f"=== release github: orisys {version} ({mode}) ===") # --- 1. gate ------------------------------------------------------- try: gate: GateResult = load_gate(REPO_ROOT, version) except PublishGateError as exc: print(f"[FAIL] {exc}") return 1 print(f"[ok ] preflight report: {gate.report_path}") print(f" certified commit: {gate.git_commit}") for wheel in gate.wheels: print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]} {wheel.size} bytes") for warning in gate.warnings: print(f"[warn] {warning}") # --- 2. target repo ------------------------------------------------ try: repo = release_repo_path() except PublishGateError as exc: print(f"[FAIL] {exc}") return 1 if not (repo / ".git").exists(): print(f"[FAIL] {repo} is not a git repository") return 1 try: if not options.skip_fetch: _git(repo, "fetch", "--tags", "--quiet") print(f"[ok ] git fetch --tags in {repo}") branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD") dirty = _git(repo, "status", "--porcelain") except PublishGateError as exc: print(f"[FAIL] {exc}") return 1 if dirty and not options.allow_dirty_release: preview = "\n".join(dirty.splitlines()[:10]) print( f"[FAIL] {repo} has uncommitted changes; commit or stash them first " f"(or pass --allow-dirty-release):\n{preview}" ) return 1 print(f"[ok ] release repo: {repo} ({branch}) clean={not dirty}") tag = f"v{version}" existing = _existing_release_tags(repo, version) if existing: print( f"[FAIL] tag(s) {existing} already exist in {repo}; " f"{version} looks already released" ) return 1 if shutil.which("gh") and _release_exists(tag): print(f"[FAIL] GitHub Release {tag} already exists") return 1 print(f"[ok ] no existing tag or release for {tag}") # A dry run is a preview of what would happen, so missing tooling is a warning # there and a hard stop only when we are about to mutate something. if options.skip_release_upload: print("[warn] --skip-release-upload: the GitHub Release will be left to you") else: gh_problems = _check_gh() if gh_problems: for problem in gh_problems: print(f"[{'FAIL' if options.apply else 'warn'}] {problem}") if options.apply: return 1 else: print("[ok ] gh CLI is installed and authenticated") # --- 3. plan the mutating steps ------------------------------------ wheels = [REPO_ROOT / "SDK" / version / record.name for record in gate.wheels] missing = [str(wheel) for wheel in wheels if not wheel.is_file()] if missing: print(f"[FAIL] wheels missing from disk: {missing}") return 1 asset_dir = repo / "release" / version assets = [asset_dir / wheel.name for wheel in wheels] notes_file = options.notes_file steps = build_github_steps( version=version, branch=branch, tag=tag, assets=assets, notes_file=notes_file ) print() print(f"[plan] sync {len(manifest_entry().get('sync', []))} entry(ies) into {repo}") print(f"[plan] stage {len(wheels)} wheel(s) into {asset_dir}") print(f"[plan] commit {repo} and tag {tag}") for step in steps: manual = options.skip_release_upload and step.description.startswith( "create GitHub Release" ) prefix = "[plan] (left manual)" if manual else "[plan]" print(f"{prefix} {step.description}: {' '.join(step.argv)}") print() if not options.apply: print("dry run: nothing was changed. Re-run with --apply to execute.") return 0 # --- 4. execute ---------------------------------------------------- try: commit_before = _git(repo, "rev-parse", "HEAD") _sync(run_checks=not options.skip_checks) _stage_wheels(wheels, asset_dir) if options.attach_preflight: shutil.copy2(gate.report_path, asset_dir / f"{version}-preflight.json") _git(repo, "add", "-A") pending = _git(repo, "status", "--porcelain") if pending: _git(repo, "commit", "-m", f"release {version}") print(f"[ok ] committed {len(pending.splitlines())} change(s) in {repo}") else: print("[ok ] nothing new to commit in the release repo") _git(repo, "tag", tag) _git(repo, "push", "origin", branch) _git(repo, "push", "origin", tag) print(f"[ok ] pushed {branch} and {tag}") except PublishGateError as exc: print(f"[FAIL] {exc}") return 1 release_argv = next( step.argv for step in steps if step.description.startswith("create GitHub Release") ) if options.skip_release_upload: print() print("[todo] publish the release yourself:") print(" " + " ".join(release_argv)) print() print("=== release github: repo synced and tagged; release upload left manual ===") return 0 completed = subprocess.run(release_argv, check=False) if completed.returncode != 0: print(f"[FAIL] gh release create exited {completed.returncode}") return 1 print(f"[ok ] GitHub Release {tag} created") # --- 5. verify the published assets -------------------------------- completed = subprocess.run( ["gh", "release", "view", tag, "--json", "assets,tagName"], capture_output=True, text=True, check=False, ) if completed.returncode != 0: print("[FAIL] could not read back the release to verify assets") return 1 payload = json.loads(completed.stdout or "{}") published = {asset["name"] for asset in payload.get("assets", [])} expected = {asset.name for asset in assets} if options.attach_preflight: expected.add(f"{version}-preflight.json") if published != expected: print( f"[FAIL] release asset mismatch:\n expected {sorted(expected)}\n" f" published {sorted(published)}" ) return 1 print(f"[ok ] verified {len(published)} asset(s) on the release") print() print(f"=== release github PASSED (commit {commit_before[:12]} -> {_git(repo, 'rev-parse', 'HEAD')[:12]}) ===") return 0