159 lines
5.5 KiB
Python
159 lines
5.5 KiB
Python
"""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,
|
|
)
|