9c951e9d3c
补齐全部架构的运行证据、GitHub 发布说明和 PyPI 逐文件下载核验。已通过 161 项测试、代码和文档门禁、九个 wheel 的本地索引下载核验,以及 ARM 三个 ABI 的 40 帧 CPU 检查。
447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""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
|
|
from release.runtime import PLATFORM_LABELS, wheel_target # 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:
|
|
platforms = sorted({PLATFORM_LABELS[wheel_target(wheel)[1]] for wheel in wheels})
|
|
lines = [
|
|
f"orisys {version}",
|
|
"",
|
|
f"Wheels ({', '.join(platforms)}; Python ABI is shown in each filename):",
|
|
]
|
|
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,
|
|
create_tag: bool = True,
|
|
title: str | None = None,
|
|
) -> list[CommandStep]:
|
|
"""The mutating commands, as pure data so they can be asserted in tests.
|
|
|
|
``create_tag=False`` is the resume path: the tag already exists (a previous
|
|
run pushed it before failing), so only the remaining steps are emitted.
|
|
"""
|
|
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]
|
|
|
|
steps: list[CommandStep] = []
|
|
if create_tag:
|
|
steps.append(CommandStep("create tag", ["git", "tag", tag]))
|
|
steps.append(CommandStep("push branch", ["git", "push", "origin", branch]))
|
|
steps.append(CommandStep("push tag", ["git", "push", "origin", tag]))
|
|
steps.append(CommandStep("create GitHub Release with assets", release_argv))
|
|
return steps
|
|
|
|
|
|
def non_github_remote_problem(repo: Path) -> str | None:
|
|
"""Why ``gh`` cannot target this repo, or ``None`` if the remote is fine.
|
|
|
|
``gh`` resolves its repository from the working directory. Running it from
|
|
this checkout instead of the release repo is a real failure mode: the SDK
|
|
repo's origin is an internal host, and ``gh`` reports the confusing "none of
|
|
the git remotes ... point to a known GitHub host".
|
|
"""
|
|
remote = _git(repo, "remote", "get-url", "origin", check=False)
|
|
if "github.com" in remote:
|
|
return None
|
|
return (
|
|
f"the release repo's origin is {remote!r}, which is not a GitHub host. "
|
|
"`gh` resolves the repository from the working directory, so point origin "
|
|
"at github.com before publishing a release"
|
|
)
|
|
|
|
|
|
def _check_gh(repo: Path) -> 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
|
|
|
|
remote_problem = non_github_remote_problem(repo)
|
|
if remote_problem:
|
|
problems.append(remote_problem)
|
|
|
|
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(repo: Path, tag: str) -> bool | None:
|
|
"""Is there already a GitHub Release for ``tag``?
|
|
|
|
Returns ``None`` when that cannot be determined (``gh`` unavailable), so the
|
|
caller can distinguish "not published yet" from "unknown" instead of treating
|
|
a missing tool as proof that nothing was published.
|
|
|
|
Must run with ``cwd`` set to the release repo: `gh` resolves the target
|
|
repository from the working directory, and this checkout's own remote is an
|
|
internal host.
|
|
"""
|
|
if shutil.which("gh") is None:
|
|
return None
|
|
completed = subprocess.run(
|
|
["gh", "release", "view", tag, "--json", "tagName"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=str(repo),
|
|
)
|
|
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)
|
|
# A tag that exists without a GitHub Release means a previous run got as far
|
|
# as pushing the tag and then failed (or the upload was left manual). That is
|
|
# exactly the resumable state, so continue instead of refusing.
|
|
create_tag = not existing
|
|
if existing:
|
|
print(
|
|
f"[warn] tag(s) {existing} already exist in {repo}; skipping tag creation "
|
|
"and continuing to the release upload"
|
|
)
|
|
release_state = _release_exists(repo, tag)
|
|
if release_state is True:
|
|
print(f"[FAIL] GitHub Release {tag} already exists; nothing to publish")
|
|
return 1
|
|
if release_state is None:
|
|
print(
|
|
"[warn] cannot tell whether a GitHub Release for "
|
|
f"{tag} already exists (`gh` not installed); --apply will stop at the gh check"
|
|
)
|
|
if create_tag:
|
|
print(f"[ok ] tag {tag} does not exist in {repo}")
|
|
elif create_tag:
|
|
print(f"[ok ] no existing tag or GitHub Release for {tag}")
|
|
else:
|
|
print(f"[ok ] no GitHub Release for {tag} yet")
|
|
|
|
# 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(repo)
|
|
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 or gate.report_path.parent / "github-release-notes.md"
|
|
generated_notes = build_release_notes(version, wheels, gate.git_commit) if options.notes_file is None else None
|
|
steps = build_github_steps(
|
|
version=version,
|
|
branch=branch,
|
|
tag=tag,
|
|
assets=assets,
|
|
notes_file=notes_file,
|
|
create_tag=create_tag,
|
|
)
|
|
|
|
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}")
|
|
if create_tag:
|
|
print(f"[plan] commit {repo} and create tag {tag}")
|
|
else:
|
|
print(f"[plan] commit {repo} (tag {tag} already exists)")
|
|
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:
|
|
if generated_notes:
|
|
print(generated_notes)
|
|
print("dry run: nothing was changed. Re-run with --apply to execute.")
|
|
return 0
|
|
|
|
# --- 4. execute ----------------------------------------------------
|
|
try:
|
|
if generated_notes:
|
|
notes_file.write_text(generated_notes, encoding="utf-8")
|
|
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")
|
|
|
|
if create_tag:
|
|
_git(repo, "tag", tag)
|
|
# Pushing an already-up-to-date ref is a no-op, so resuming is safe.
|
|
_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
|
|
|
|
# `gh` resolves its target repository from the working directory, so it must
|
|
# run inside the release repo - this checkout's own origin is an internal host.
|
|
completed = subprocess.run(release_argv, check=False, cwd=str(repo))
|
|
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,
|
|
cwd=str(repo),
|
|
)
|
|
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
|