automate publishing: release github + release pypi
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user