automate publishing: release github + release pypi
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
"""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/<version>/*.whl`` into ``orisys-sdk-release/release/<version>/``
|
||||
5. commit, tag ``v<version>``, 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
|
||||
Reference in New Issue
Block a user