automate publishing: release github + release pypi
This commit is contained in:
+99
-3
@@ -1,4 +1,9 @@
|
||||
"""Command line for ``python build/run.py release``."""
|
||||
"""Command line for ``python build/run.py release``.
|
||||
|
||||
Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``github`` (sync
|
||||
the public repo + publish a GitHub Release), ``pypi`` (TestPyPI then PyPI).
|
||||
The publish commands are dry-run unless ``--apply`` is passed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,6 +24,7 @@ from common.verify.wheel import ( # noqa: E402
|
||||
verify_wheel_runtime,
|
||||
)
|
||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||
from release import github, pypi # noqa: E402
|
||||
from release.preflight import PreflightOptions, run_preflight # noqa: E402
|
||||
|
||||
|
||||
@@ -170,11 +176,97 @@ def _cmd_audit(argv: list[str]) -> int:
|
||||
return exit_code
|
||||
|
||||
|
||||
def _cmd_github(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="build/run.py release github",
|
||||
description="Sync orisys-sdk-release, tag, and publish a GitHub Release.",
|
||||
)
|
||||
parser.add_argument("--version", required=True, help="release version, e.g. 0.5.2")
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="actually mutate the release repo, push, and create the release",
|
||||
)
|
||||
parser.add_argument("--notes-file", type=Path, default=None, help="release notes markdown")
|
||||
parser.add_argument(
|
||||
"--attach-preflight",
|
||||
action="store_true",
|
||||
help="also upload the preflight report as a release asset (contains local paths)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-dirty-release",
|
||||
action="store_true",
|
||||
help="proceed even if the release repo has uncommitted changes",
|
||||
)
|
||||
parser.add_argument("--skip-fetch", action="store_true", help="skip `git fetch --tags`")
|
||||
parser.add_argument(
|
||||
"--skip-checks", action="store_true", help="skip the manifest's downstream checks"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-release-upload",
|
||||
action="store_true",
|
||||
help="stop after pushing the tag and print the `gh release create` command",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
return github.publish(
|
||||
github.Options(
|
||||
version=args.version,
|
||||
apply=args.apply,
|
||||
notes_file=args.notes_file,
|
||||
attach_preflight=args.attach_preflight,
|
||||
allow_dirty_release=args.allow_dirty_release,
|
||||
skip_fetch=args.skip_fetch,
|
||||
skip_checks=args.skip_checks,
|
||||
skip_release_upload=args.skip_release_upload,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cmd_pypi(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="build/run.py release pypi",
|
||||
description="Upload the certified wheels to TestPyPI and then PyPI.",
|
||||
)
|
||||
parser.add_argument("--version", required=True, help="release version, e.g. 0.5.2")
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="actually upload (uploads are permanent and cannot be overwritten)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm-pypi",
|
||||
action="store_true",
|
||||
help="after TestPyPI, also publish to the real PyPI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repository", default="testpypi", help="twine repository for the first phase"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-install-verify",
|
||||
action="store_true",
|
||||
help="skip fetching the artifact back out of the index",
|
||||
)
|
||||
parser.add_argument("--skip-check", action="store_true", help="skip `twine check`")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
return pypi.publish(
|
||||
pypi.Options(
|
||||
version=args.version,
|
||||
apply=args.apply,
|
||||
confirm_pypi=args.confirm_pypi,
|
||||
repository=args.repository,
|
||||
verify_install=not args.skip_install_verify,
|
||||
skip_check=args.skip_check,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
print("Commands: preflight | audit")
|
||||
print("Commands: preflight | audit | github | pypi")
|
||||
return 0
|
||||
|
||||
command, rest = argv[0], argv[1:]
|
||||
@@ -182,9 +274,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return _cmd_preflight(rest)
|
||||
if command == "audit":
|
||||
return _cmd_audit(rest)
|
||||
if command == "github":
|
||||
return _cmd_github(rest)
|
||||
if command == "pypi":
|
||||
return _cmd_pypi(rest)
|
||||
|
||||
print(f"Unknown release command: {command!r}", file=sys.stderr)
|
||||
print("Choose from: preflight, audit", file=sys.stderr)
|
||||
print("Choose from: preflight, audit, github, pypi", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -16,7 +16,9 @@ Steps, in order:
|
||||
8. ``wheel-runtime`` - one isolated venv per wheel: bundled assets load and
|
||||
real frames flow through the public pipeline
|
||||
|
||||
Output lands in ``build/release/reports/<version>/preflight.{json,txt}``.
|
||||
Output lands in ``build/release/reports/<version>/preflight.{json,txt}``, and is
|
||||
the contract the publish commands (``release github`` / ``release pypi``) consume:
|
||||
they refuse to ship anything this run did not certify.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Shared publish gate: certify a release from its preflight report.
|
||||
|
||||
Both publish commands (``release github`` and ``release pypi``) refuse to act
|
||||
unless a passing ``release preflight`` report exists for the exact version and
|
||||
every wheel still hashes to what that report recorded. The report is the contract
|
||||
between "we verified this" and "we shipped this"; re-deriving the checks here
|
||||
would just be a second, weaker opinion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# Options that weaken the gate. A release must not be certified by a run that
|
||||
# waived any of them: re-run preflight on a clean commit instead.
|
||||
WAIVED_GUARD_KEYS = ("skip_tests", "allow_dirty", "force", "no_runtime", "no_assets")
|
||||
|
||||
|
||||
class PublishGateError(RuntimeError):
|
||||
"""The preflight report does not certify these wheels."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WheelRecord:
|
||||
name: str
|
||||
sha256: str
|
||||
size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateResult:
|
||||
version: str
|
||||
git_commit: str | None
|
||||
report_path: Path
|
||||
wheels: list[WheelRecord] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def evaluate_gate(
|
||||
payload: dict,
|
||||
version: str,
|
||||
wheels: dict[str, Path],
|
||||
*,
|
||||
head_commit: str | None = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Return ``(problems, warnings)`` for a report payload and the wheels on disk.
|
||||
|
||||
Pure function: no I/O beyond hashing the wheels, so it is cheap to test.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if str(payload.get("version")) != version:
|
||||
problems.append(
|
||||
f"report is for version {payload.get('version')!r}, not {version!r}"
|
||||
)
|
||||
if not payload.get("ok"):
|
||||
problems.append(
|
||||
"report result is FAIL - run `release preflight --keep-going` and fix "
|
||||
"everything before publishing"
|
||||
)
|
||||
if payload.get("aborted"):
|
||||
problems.append("report stopped early (aborted); it certifies nothing")
|
||||
|
||||
options = payload.get("options") or {}
|
||||
waived = sorted(key for key in WAIVED_GUARD_KEYS if options.get(key))
|
||||
if waived:
|
||||
problems.append(
|
||||
"report waived gate options "
|
||||
+ ", ".join(f"--{key.replace('_', '-')}" for key in waived)
|
||||
+ "; commit the tree and re-run `release preflight` without them"
|
||||
)
|
||||
|
||||
recorded = {item.get("name"): item.get("sha256") for item in payload.get("wheels") or []}
|
||||
if not recorded:
|
||||
problems.append("report lists no wheels")
|
||||
|
||||
missing = sorted(set(recorded) - set(wheels))
|
||||
if missing:
|
||||
problems.append(f"wheels in the report are missing from disk: {missing}")
|
||||
extra = sorted(set(wheels) - set(recorded))
|
||||
if extra:
|
||||
problems.append(f"wheels on disk are not covered by the report: {extra}")
|
||||
|
||||
for name in sorted(set(recorded) & set(wheels)):
|
||||
actual = sha256_file(wheels[name])
|
||||
if actual != recorded[name]:
|
||||
problems.append(
|
||||
f"{name} changed after preflight: report {recorded[name][:12]}, "
|
||||
f"disk {actual[:12]} - rebuild and re-run preflight"
|
||||
)
|
||||
|
||||
commit = payload.get("git", {}).get("commit") if isinstance(payload.get("git"), dict) else None
|
||||
if head_commit and commit and commit != head_commit:
|
||||
warnings.append(
|
||||
f"report certifies commit {commit[:12]} but HEAD is {head_commit[:12]}; "
|
||||
"the wheels are unchanged, so this is usually just a later commit"
|
||||
)
|
||||
return problems, warnings
|
||||
|
||||
|
||||
def load_gate(repo_root: Path, version: str) -> GateResult:
|
||||
"""Load and enforce the preflight report for ``version``.
|
||||
|
||||
Raises :class:`PublishGateError` with every problem found, so one run reports
|
||||
all of them instead of stopping at the first.
|
||||
"""
|
||||
repo_root = Path(repo_root)
|
||||
report_path = repo_root / "build" / "release" / "reports" / version / "preflight.json"
|
||||
if not report_path.is_file():
|
||||
raise PublishGateError(
|
||||
f"no preflight report at {report_path}. Run:\n"
|
||||
f" python build/run.py release preflight\n"
|
||||
f"(the publish commands only ship what preflight certified)"
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise PublishGateError(f"cannot read {report_path}: {exc}") from exc
|
||||
|
||||
wheel_dir = repo_root / "SDK" / version
|
||||
wheels = {path.name: path for path in sorted(wheel_dir.glob("*.whl"))}
|
||||
if not wheels:
|
||||
raise PublishGateError(
|
||||
f"no wheels in {wheel_dir}; build first (`python build/run.py wheel-win` / "
|
||||
"`manylinux`) and re-run preflight"
|
||||
)
|
||||
|
||||
problems, warnings = evaluate_gate(payload, version, wheels)
|
||||
if problems:
|
||||
raise PublishGateError(
|
||||
f"the preflight report at {report_path} does not certify orisys {version}:\n "
|
||||
+ "\n ".join(problems)
|
||||
)
|
||||
|
||||
git_payload = payload.get("git") if isinstance(payload.get("git"), dict) else {}
|
||||
return GateResult(
|
||||
version=version,
|
||||
git_commit=git_payload.get("commit"),
|
||||
report_path=report_path,
|
||||
wheels=[
|
||||
WheelRecord(name=name, sha256=sha256_file(path), size=path.stat().st_size)
|
||||
for name, path in wheels.items()
|
||||
],
|
||||
warnings=warnings,
|
||||
)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Upload the certified wheels to TestPyPI, then (on confirmation) to PyPI.
|
||||
|
||||
``python build/run.py release pypi`` runs:
|
||||
|
||||
1. certified-wheels gate (same contract as ``release github``)
|
||||
2. ``twine check`` on every wheel - the wheel metadata is generated, so its
|
||||
long description and classifiers are worth validating before an upload that
|
||||
cannot be undone
|
||||
3. refuse if the version already exists on the target index (read-only probe)
|
||||
4. upload to TestPyPI, then verify by fetching it back from the index
|
||||
5. stop there unless ``--confirm-pypi`` is given; then repeat 3-4 against PyPI
|
||||
|
||||
PyPI uploads are permanent: a version number can never be reused, and a bad
|
||||
artifact cannot be replaced. Hence the TestPyPI stage, the read-only existence
|
||||
probe, and ``--apply`` being required even to reach TestPyPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
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
|
||||
|
||||
INDEX_JSON = {
|
||||
"testpypi": "https://test.pypi.org/pypi/orisys/json",
|
||||
"pypi": "https://pypi.org/pypi/orisys/json",
|
||||
}
|
||||
INDEX_SIMPLE = {
|
||||
"testpypi": "https://test.pypi.org/simple/",
|
||||
"pypi": "https://pypi.org/simple/",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Options:
|
||||
version: str
|
||||
apply: bool = False
|
||||
confirm_pypi: bool = False
|
||||
repository: str = "testpypi"
|
||||
verify_install: bool = True
|
||||
skip_check: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexState:
|
||||
versions: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def known(self) -> bool:
|
||||
return self.error is None
|
||||
|
||||
|
||||
def twine_check_argv(wheels: list[Path]) -> list[str]:
|
||||
return [sys.executable, "-m", "twine", "check", *[str(w) for w in wheels]]
|
||||
|
||||
|
||||
def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]:
|
||||
argv = [sys.executable, "-m", "twine", "upload"]
|
||||
if repository:
|
||||
argv += ["--repository", repository]
|
||||
argv += [str(wheel) for wheel in wheels]
|
||||
return argv
|
||||
|
||||
|
||||
def credentials_hint() -> str | None:
|
||||
"""Return guidance when twine has no obvious credentials, else ``None``.
|
||||
|
||||
twine itself reads ``~/.pypirc`` or ``TWINE_USERNAME``/``TWINE_PASSWORD``;
|
||||
failing here is much better than failing halfway through an upload.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("TWINE_PASSWORD") or os.environ.get("TWINE_USERNAME"):
|
||||
return None
|
||||
pypirc = Path.home() / ".pypirc"
|
||||
if pypirc.is_file():
|
||||
return None
|
||||
return (
|
||||
"no PyPI credentials found: create ~/.pypirc with [testpypi] and [pypi] "
|
||||
"sections, or set TWINE_USERNAME=__token__ and TWINE_PASSWORD=<token>. "
|
||||
"Never commit the token."
|
||||
)
|
||||
|
||||
|
||||
def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
|
||||
"""Read the published versions of orisys from an index (read-only)."""
|
||||
url = INDEX_JSON[index]
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
payload = json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return IndexState(versions=[])
|
||||
return IndexState(error=f"{index} returned HTTP {exc.code}")
|
||||
except Exception as exc: # network, DNS, TLS, JSON
|
||||
return IndexState(error=f"{index} lookup failed: {type(exc).__name__}: {exc}")
|
||||
|
||||
releases = payload.get("releases")
|
||||
if not isinstance(releases, dict):
|
||||
return IndexState(error=f"unexpected {index} response (no 'releases' mapping)")
|
||||
return IndexState(
|
||||
versions=sorted(name for name, files in releases.items() if files)
|
||||
)
|
||||
|
||||
|
||||
def index_files(index: str, version: str, *, timeout: float = 20.0) -> list[str] | None:
|
||||
"""Filenames published for ``version`` on ``index``, or ``None`` if absent."""
|
||||
url = INDEX_JSON[index]
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
payload = json.load(response)
|
||||
except Exception:
|
||||
return None
|
||||
files = (payload.get("releases") or {}).get(version)
|
||||
if not isinstance(files, list):
|
||||
return None
|
||||
return sorted(item.get("filename", "") for item in files)
|
||||
|
||||
|
||||
def _upload(wheels: list[Path], repository: str | None) -> int:
|
||||
argv = twine_upload_argv(wheels, repository)
|
||||
print(f"[run ] {' '.join(str(part) for part in argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def _fetch_back(index: str, version: str, wheel: Path) -> int:
|
||||
"""Download the freshly uploaded wheel back out of the index."""
|
||||
with tempfile.TemporaryDirectory(prefix="orisys-fetch-") as tmp:
|
||||
target = Path(tmp) / "out"
|
||||
argv = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
"--index-url",
|
||||
INDEX_SIMPLE[index],
|
||||
"--dest",
|
||||
str(target),
|
||||
f"orisys=={version}",
|
||||
]
|
||||
if index == "testpypi":
|
||||
# TestPyPI mirrors only what was uploaded there; deps live on PyPI.
|
||||
# --no-deps keeps this a pure "is the artifact fetchable" probe.
|
||||
argv += ["--extra-index-url", INDEX_SIMPLE["pypi"]]
|
||||
print(f"[run ] {' '.join(argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
if completed.returncode != 0:
|
||||
return completed.returncode
|
||||
|
||||
fetched = sorted(target.glob(f"orisys-{version}*"))
|
||||
if not fetched:
|
||||
print(f"[FAIL] pip download produced nothing for orisys=={version}")
|
||||
return 1
|
||||
print(f"[ok ] fetched {[p.name for p in fetched]} from {index}")
|
||||
if wheel.name not in {path.name for path in fetched}:
|
||||
print(
|
||||
f"[warn] {wheel.name} was not the file fetched "
|
||||
"(the index may serve a different platform first)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def publish(options: Options) -> int:
|
||||
version = options.version
|
||||
mode = "APPLY" if options.apply else "DRY RUN"
|
||||
print(f"=== release pypi: 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}")
|
||||
for wheel in gate.wheels:
|
||||
print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]}")
|
||||
for warning in gate.warnings:
|
||||
print(f"[warn] {warning}")
|
||||
|
||||
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
|
||||
|
||||
# --- 2. credentials + twine check (before anything irreversible) ----
|
||||
# Dry runs are previews, so missing credentials warn there and stop only when
|
||||
# an upload is actually about to happen.
|
||||
hint = credentials_hint()
|
||||
if hint:
|
||||
print(f"[{'FAIL' if options.apply else 'warn'}] {hint}")
|
||||
if options.apply:
|
||||
return 1
|
||||
else:
|
||||
print("[ok ] PyPI credentials are configured (twine will read them)")
|
||||
|
||||
if not options.skip_check:
|
||||
argv = twine_check_argv(wheels)
|
||||
print(f"[run ] {' '.join(str(part) for part in argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
if completed.returncode != 0:
|
||||
print("[FAIL] twine check reported problems; fix the wheel metadata first")
|
||||
return 1
|
||||
print("[ok ] twine check passed")
|
||||
|
||||
# --- 3. phase plan -------------------------------------------------
|
||||
phases = ["testpypi"]
|
||||
if options.confirm_pypi:
|
||||
phases.append("pypi")
|
||||
|
||||
print()
|
||||
for index in phases:
|
||||
state = index_state(index)
|
||||
if not state.known:
|
||||
print(f"[FAIL] {state.error}; cannot safely check for duplicate uploads")
|
||||
return 1
|
||||
if version in state.versions:
|
||||
print(
|
||||
f"[FAIL] orisys {version} already exists on {index} "
|
||||
f"(published: {', '.join(state.versions) or 'none'}) - "
|
||||
"an index version cannot be reused"
|
||||
)
|
||||
return 1
|
||||
print(f"[ok ] {index}: {version} not present (published: {', '.join(state.versions) or 'none'})")
|
||||
|
||||
print()
|
||||
for index in phases:
|
||||
print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
|
||||
if "pypi" not in phases:
|
||||
print(
|
||||
"[plan] stop after TestPyPI. Re-run with "
|
||||
"`--confirm-pypi --apply` to publish to the real PyPI."
|
||||
)
|
||||
print()
|
||||
|
||||
if not options.apply:
|
||||
print("dry run: nothing was uploaded. Re-run with --apply to execute.")
|
||||
return 0
|
||||
|
||||
# --- 4. upload ----------------------------------------------------
|
||||
for index in phases:
|
||||
if _upload(wheels, index) != 0:
|
||||
print(f"[FAIL] twine upload to {index} failed")
|
||||
return 1
|
||||
print(f"[ok ] uploaded to {index}")
|
||||
|
||||
published = index_files(index, version)
|
||||
if published is None:
|
||||
print(
|
||||
f"[FAIL] {index} does not serve orisys {version} yet; "
|
||||
"the upload may still be indexing, verify manually"
|
||||
)
|
||||
return 1
|
||||
expected = {wheel.name for wheel in wheels}
|
||||
if set(published) != expected:
|
||||
print(
|
||||
f"[FAIL] {index} file set mismatch:\n expected {sorted(expected)}\n"
|
||||
f" got {sorted(published)}"
|
||||
)
|
||||
return 1
|
||||
print(f"[ok ] {index} serves all {len(published)} file(s) for {version}")
|
||||
|
||||
if options.verify_install:
|
||||
if _fetch_back(index, version, wheels[0]) != 0:
|
||||
print(f"[FAIL] {index} artifact is not fetchable")
|
||||
return 1
|
||||
|
||||
if "pypi" not in phases:
|
||||
print()
|
||||
print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===")
|
||||
print("next: python build/run.py release pypi --confirm-pypi --apply")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||
return 0
|
||||
Reference in New Issue
Block a user