569 lines
22 KiB
Python
569 lines
22 KiB
Python
"""Local release gate: everything that must hold before publishing a wheel.
|
|
|
|
``release preflight`` answers one question with an exit code: is this checkout,
|
|
this version and these wheels safe to publish? It never publishes anything, and
|
|
by default it makes no network calls.
|
|
|
|
Steps, in order:
|
|
|
|
1. ``repo-state`` - git repository, clean working tree, recorded commit
|
|
2. ``version-consistency`` - __version__ == pyproject dynamic attr == wheel names
|
|
3. ``version-strings`` - no stale version literal left in tracked prose
|
|
4. ``duplicate-guard`` - already-released version (git tag, optionally PyPI)
|
|
5. ``tests`` - pytest (slow/hardware markers excluded)
|
|
6. ``bundle-precheck`` - every bundle item resolves; required assets present
|
|
7. ``wheel-inspection`` - static metadata/content assertions per wheel
|
|
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}``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
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 common.pyproject_utils import bundle_items, get_version # noqa: E402
|
|
from common.verify.wheel import ( # noqa: E402
|
|
DEFAULT_RUNTIME_CONFIG,
|
|
default_verification_video,
|
|
verify_wheel,
|
|
)
|
|
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
|
from release import report as report_mod # noqa: E402
|
|
from release import version as version_mod # noqa: E402
|
|
from wheel.layout import resolve_bundle_source_files, verify_required_assets # noqa: E402
|
|
|
|
|
|
@dataclass
|
|
class Step:
|
|
name: str
|
|
ok: bool
|
|
detail: str = ""
|
|
duration_s: float = 0.0
|
|
data: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class PreflightOptions:
|
|
version: str | None = None
|
|
wheel_dir: Path | None = None
|
|
video: Path | None = None
|
|
config_name: str = DEFAULT_RUNTIME_CONFIG
|
|
frames: int = 40
|
|
skip_tests: bool = False
|
|
longrun: bool = False
|
|
allow_dirty: bool = False
|
|
force: bool = False
|
|
check_pypi: bool = False
|
|
all_wheel_pythons: bool = False
|
|
offline: bool = False
|
|
keep_going: bool = False
|
|
report_dir: Path | None = None
|
|
no_runtime: bool = False
|
|
no_assets: bool = False
|
|
|
|
|
|
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 _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
class Preflight:
|
|
def __init__(self, options: PreflightOptions, repo_root: Path | None = None) -> None:
|
|
self.options = options
|
|
self.repo_root = Path(repo_root or REPO_ROOT)
|
|
self.steps: list[Step] = []
|
|
self.git: dict = {"commit": None, "branch": None, "dirty": None}
|
|
self.wheels: list[Path] = []
|
|
self.wheel_dir: Path | None = None
|
|
self.runtime_records: list[dict] = []
|
|
self.aborted = False
|
|
self.ok = False
|
|
|
|
if options.version:
|
|
self.version = options.version
|
|
self.version_source = "explicit --version"
|
|
elif (self.repo_root / "pyproject.toml").is_file():
|
|
self.version = get_version(self.repo_root)
|
|
self.version_source = "pyproject.toml dynamic attr"
|
|
else:
|
|
# Not version-shaped on purpose: this is a sentinel for "unresolved",
|
|
# and it must not look like a release version to the version-string scan.
|
|
self.version = "unknown"
|
|
self.version_source = "unresolved"
|
|
|
|
self.report_dir = report_mod.reports_root(self.repo_root, options.report_dir) / self.version
|
|
|
|
# -- helpers ---------------------------------------------------------
|
|
|
|
def _git(self, *args: str) -> str:
|
|
try:
|
|
completed = subprocess.run(
|
|
["git", *args],
|
|
cwd=self.repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except OSError:
|
|
return ""
|
|
return (completed.stdout or "").strip()
|
|
|
|
def _execute(self, step_fn) -> None:
|
|
started = time.perf_counter()
|
|
try:
|
|
step = step_fn()
|
|
except Exception as exc: # a crashed step is a failed step, never a silent pass
|
|
step = Step(name=getattr(step_fn, "__name__", "step").lstrip("_").replace("_", "-"),
|
|
ok=False, detail=f"crashed: {type(exc).__name__}: {exc}")
|
|
step.duration_s = round(time.perf_counter() - started, 3)
|
|
self.steps.append(step)
|
|
|
|
flag = "ok " if step.ok else "FAIL"
|
|
print(f"[{flag}] {step.name} ({step.duration_s:.2f}s)")
|
|
for line in str(step.detail).splitlines():
|
|
print(f" {line}")
|
|
|
|
if not step.ok and not self.options.keep_going:
|
|
self.aborted = True
|
|
print(" stopping: pass --keep-going to collect every failure")
|
|
|
|
# -- steps -----------------------------------------------------------
|
|
|
|
def _repo_state(self) -> Step:
|
|
if not (self.repo_root / ".git").exists():
|
|
return Step("repo-state", False, f"{self.repo_root} is not a git repository")
|
|
|
|
commit = self._git("rev-parse", "HEAD")
|
|
branch = self._git("rev-parse", "--abbrev-ref", "HEAD")
|
|
porcelain = self._git("status", "--porcelain")
|
|
dirty = bool(porcelain)
|
|
self.git = {"commit": commit, "branch": branch, "dirty": dirty}
|
|
|
|
if dirty and not self.options.allow_dirty:
|
|
preview = "\n".join(porcelain.splitlines()[:20])
|
|
return Step(
|
|
"repo-state",
|
|
False,
|
|
f"working tree is dirty; commit, stash or pass --allow-dirty:\n{preview}",
|
|
data=self.git,
|
|
)
|
|
|
|
detail = f"{branch} @ {commit[:12]} dirty={dirty} version={self.version} ({self.version_source})"
|
|
if dirty:
|
|
detail += " (accepted via --allow-dirty)"
|
|
return Step("repo-state", True, detail, data=self.git)
|
|
|
|
def _version_consistency(self) -> Step:
|
|
problems: list[str] = []
|
|
data: dict = {"version": self.version, "source": self.version_source}
|
|
|
|
attr = version_mod.dynamic_version_attr(self.repo_root)
|
|
data["dynamic_attr"] = attr
|
|
if attr is None:
|
|
problems.append(
|
|
"pyproject.toml no longer declares tool.setuptools.dynamic.version.attr"
|
|
)
|
|
|
|
if (self.repo_root / "pyproject.toml").is_file():
|
|
resolved = get_version(self.repo_root)
|
|
data["resolved_version"] = resolved
|
|
if resolved != self.version:
|
|
problems.append(
|
|
f"src/orisys/__init__.py __version__ is {resolved!r} but the release "
|
|
f"version is {self.version!r}"
|
|
)
|
|
|
|
sdk_dirs = version_mod.sdk_versions(self.repo_root)
|
|
data["sdk_versions"] = sdk_dirs
|
|
for wheel in version_mod.sdk_wheels(self.repo_root, self.version):
|
|
found = version_mod.wheel_version(wheel)
|
|
if found != self.version:
|
|
problems.append(f"{wheel.name} carries version {found!r}, expected {self.version!r}")
|
|
|
|
detail = (
|
|
f"version={self.version} ({self.version_source}) dynamic_attr={attr}\n"
|
|
f"SDK/ versions present: {sdk_dirs or 'none'}"
|
|
)
|
|
if problems:
|
|
detail += "\n" + "\n".join(problems)
|
|
return Step("version-consistency", not problems, detail, data=data)
|
|
|
|
def _duplicate_guard(self) -> Step:
|
|
"""Fail only when this version has already been *released*.
|
|
|
|
A populated ``SDK/<version>/`` is the normal input to preflight (it is
|
|
what gets verified), so it is reported, not blocked. What must not happen
|
|
silently is re-releasing a version that is already out: that is detected
|
|
offline through git tags and, opt-in, against PyPI.
|
|
"""
|
|
problems: list[str] = []
|
|
lines: list[str] = []
|
|
|
|
existing = version_mod.sdk_wheels(self.repo_root, self.version)
|
|
if existing:
|
|
lines.append(
|
|
f"SDK/{self.version}/ holds {len(existing)} staged wheel(s) to verify: "
|
|
f"{[w.name for w in existing]}"
|
|
)
|
|
else:
|
|
lines.append(
|
|
f"SDK/{self.version}/ is empty; nothing is staged yet "
|
|
"(build with `python build/run.py wheel-win` / `manylinux`)"
|
|
)
|
|
|
|
tags = version_mod.git_tags(self.repo_root)
|
|
tag_hits = version_mod.released_tags_for(self.version, tags)
|
|
if tag_hits:
|
|
message = f"git tag(s) {tag_hits} already mark {self.version} as released"
|
|
if self.options.force:
|
|
lines.append(message + " (accepted via --force)")
|
|
else:
|
|
problems.append(
|
|
message + "; pass --force to re-verify or intentionally re-release"
|
|
)
|
|
else:
|
|
lines.append(
|
|
f"no local git tag marks {self.version} as released "
|
|
f"({len(tags)} tag(s) checked in this repo and {version_mod.RELEASE_REPO_DIRNAME})"
|
|
)
|
|
|
|
if self.options.check_pypi:
|
|
versions, error = version_mod.pypi_released_versions()
|
|
if error:
|
|
problems.append(f"PyPI lookup failed: {error}")
|
|
elif versions is None:
|
|
problems.append("PyPI lookup returned no version list")
|
|
elif self.version in versions:
|
|
problems.append(
|
|
f"orisys {self.version} is already published on PyPI; a PyPI upload "
|
|
"cannot be undone. Published: " + ", ".join(versions)
|
|
)
|
|
else:
|
|
lines.append(
|
|
f"PyPI has no {self.version}; published: " + (", ".join(versions) or "none")
|
|
)
|
|
else:
|
|
lines.append("PyPI existence check skipped (pass --check-pypi to enable)")
|
|
|
|
detail = "\n".join(lines)
|
|
if problems:
|
|
detail += "\n" + "\n".join(problems)
|
|
return Step("duplicate-guard", not problems, detail)
|
|
|
|
def _version_strings(self) -> Step:
|
|
"""Keep the version out of prose: one source, everything else derived.
|
|
|
|
Scans tracked text for version-looking literals that disagree with the
|
|
release version. Dependency constraints are ignored by design; deliberate
|
|
exceptions live in ``version.VERSION_STRING_ALLOWLIST``.
|
|
"""
|
|
findings = version_mod.find_version_literals(self.repo_root, self.version)
|
|
if not findings:
|
|
scanned = len(version_mod.tracked_text_files(self.repo_root))
|
|
return Step(
|
|
"version-strings",
|
|
True,
|
|
f"no stale version literal in {scanned} scanned file(s); "
|
|
f"version {self.version} lives only where it is derived from",
|
|
)
|
|
|
|
by_file: dict[str, list[str]] = {}
|
|
for path, lineno, literal in findings:
|
|
by_file.setdefault(path, []).append(f"{path}:{lineno} -> {literal}")
|
|
detail = (
|
|
f"{len(findings)} version literal(s) disagree with {self.version}. "
|
|
"Update them, or make the file version-agnostic, or add a documented "
|
|
"entry to build/release/version.py::VERSION_STRING_ALLOWLIST:\n"
|
|
+ "\n".join(line for lines in by_file.values() for line in lines)
|
|
)
|
|
return Step("version-strings", False, detail, data={"count": len(findings)})
|
|
|
|
def _tests(self) -> Step:
|
|
if self.options.skip_tests:
|
|
return Step(
|
|
"tests",
|
|
True,
|
|
"WARNING: test gate SKIPPED via --skip-tests; this run cannot certify a release",
|
|
data={"status": "skipped"},
|
|
)
|
|
|
|
marker = "not hardware" if self.options.longrun else "not slow and not hardware"
|
|
self.report_dir.mkdir(parents=True, exist_ok=True)
|
|
junit = self.report_dir / "junit.xml"
|
|
command = [
|
|
sys.executable,
|
|
"-m",
|
|
"pytest",
|
|
"-q",
|
|
"-m",
|
|
marker,
|
|
f"--junitxml={junit}",
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
command, cwd=self.repo_root, capture_output=True, text=True, check=False
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return Step("tests", False, f"cannot run pytest: {exc}")
|
|
|
|
ok = completed.returncode == 0
|
|
tail = ((completed.stdout or "") + (completed.stderr or "")).strip().splitlines()[-12:]
|
|
detail = f"pytest -m {marker!r} in {sys.executable}\nexit code {completed.returncode}"
|
|
if tail:
|
|
detail += "\n" + "\n".join(tail)
|
|
return Step("tests", ok, detail, data={"marker": marker, "junit": str(junit)})
|
|
|
|
def _bundle_precheck(self) -> Step:
|
|
if not (self.repo_root / "pyproject.toml").is_file():
|
|
return Step("bundle-precheck", False, "pyproject.toml not found")
|
|
|
|
items = bundle_items(self.repo_root)
|
|
problems: list[str] = []
|
|
counts: dict[str, int] = {}
|
|
for item in items:
|
|
files = resolve_bundle_source_files(self.repo_root, item)
|
|
counts[str(item.get("name"))] = len(files)
|
|
if not files:
|
|
problems.append(
|
|
f"bundle item {item.get('name')!r} resolved 0 files; its patterns match nothing"
|
|
)
|
|
|
|
try:
|
|
verify_required_assets(self.repo_root, items)
|
|
except FileNotFoundError as exc:
|
|
problems.append(str(exc))
|
|
|
|
detail = "resolved files per bundle item: " + json.dumps(counts)
|
|
if problems:
|
|
detail += "\n" + "\n".join(problems)
|
|
return Step("bundle-precheck", not problems, detail, data={"counts": counts})
|
|
|
|
def _discover_wheels(self) -> str | None:
|
|
"""Populate self.wheels; return an error message when nothing was found."""
|
|
directory = (
|
|
Path(self.options.wheel_dir)
|
|
if self.options.wheel_dir
|
|
else self.repo_root / "SDK" / self.version
|
|
)
|
|
self.wheel_dir = directory
|
|
if not directory.is_dir():
|
|
return (
|
|
f"{directory} does not exist. Build first "
|
|
"(`python build/run.py wheel`) or pass --wheel-dir."
|
|
)
|
|
|
|
self.wheels = sorted(directory.glob("*.whl"))
|
|
if not self.wheels:
|
|
return f"no *.whl found in {directory}. Build first or pass --wheel-dir."
|
|
return None
|
|
|
|
def _wheel_inspection(self) -> Step:
|
|
problem = self._discover_wheels()
|
|
if problem:
|
|
return Step("wheel-inspection", False, problem)
|
|
|
|
problems: list[str] = []
|
|
blocks: list[str] = []
|
|
for wheel in self.wheels:
|
|
result = inspect_wheel(
|
|
wheel,
|
|
expected_version=self.version,
|
|
repo_root=self.repo_root,
|
|
strict_bundle=True,
|
|
)
|
|
blocks.append(result.render())
|
|
if not result.ok:
|
|
problems.append(
|
|
f"{wheel.name}: " + ", ".join(check.name for check in result.failures)
|
|
)
|
|
|
|
detail = f"{len(self.wheels)} wheel(s) in {self.wheel_dir}\n" + "\n".join(blocks)
|
|
if problems:
|
|
detail += "\nfailed checks:\n" + "\n".join(problems)
|
|
return Step("wheel-inspection", not problems, detail, data={"count": len(self.wheels)})
|
|
|
|
def _platform_token(self) -> str:
|
|
return "win_amd64" if sys.platform == "win32" else "manylinux"
|
|
|
|
def _runtime_targets(self) -> list[tuple[str, Path, Path | None]]:
|
|
token = self._platform_token()
|
|
if self.options.all_wheel_pythons:
|
|
targets: list[tuple[str, Path, Path | None]] = []
|
|
for tag in ("cp310", "cp311", "cp312"):
|
|
match = next(
|
|
(w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None
|
|
)
|
|
if match is None:
|
|
raise RuntimeError(
|
|
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}"
|
|
)
|
|
executable = version_mod.python_for_tag(tag)
|
|
if executable is None:
|
|
raise RuntimeError(
|
|
f"cannot resolve an interpreter for {tag}; create the conda env "
|
|
f"py{tag[2:]} or set CONDA_ENVS_ROOT"
|
|
)
|
|
targets.append((tag, match, executable))
|
|
return targets
|
|
|
|
tag = f"cp{sys.version_info.major}{sys.version_info.minor}"
|
|
match = next((w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None)
|
|
if match is None:
|
|
raise RuntimeError(
|
|
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}; pass "
|
|
"--all-wheel-pythons to verify every ABI in its own environment, or run from "
|
|
"a matching interpreter"
|
|
)
|
|
return [(tag, match, None)]
|
|
|
|
def _wheel_runtime(self) -> Step:
|
|
if self.options.no_runtime:
|
|
return Step(
|
|
"wheel-runtime",
|
|
True,
|
|
"WARNING: runtime verification SKIPPED via --no-runtime",
|
|
data={"status": "skipped"},
|
|
)
|
|
|
|
video = self.options.video or default_verification_video(self.repo_root)
|
|
if video is None:
|
|
return Step(
|
|
"wheel-runtime",
|
|
False,
|
|
"no verification clip found. Pass --video, set ORISYS_VERIFY_VIDEO, or commit a "
|
|
"clip under data/sample/. The runtime check is never silently skipped.",
|
|
)
|
|
video = Path(video)
|
|
if not video.is_file():
|
|
return Step("wheel-runtime", False, f"verification clip not found: {video}")
|
|
|
|
try:
|
|
targets = self._runtime_targets()
|
|
except RuntimeError as exc:
|
|
return Step("wheel-runtime", False, str(exc))
|
|
|
|
problems: list[str] = []
|
|
lines: list[str] = [f"clip: {video}", f"config: {self.options.config_name}"]
|
|
for tag, wheel, executable in targets:
|
|
label = f"{tag} {wheel.name}"
|
|
try:
|
|
# One venv per wheel: it runs the import smoke test, the bundled
|
|
# asset check and the frame pipeline together.
|
|
record = verify_wheel(
|
|
wheel,
|
|
video=video,
|
|
config_name=self.options.config_name,
|
|
frames=self.options.frames,
|
|
python_executable=executable,
|
|
offline=self.options.offline,
|
|
check_assets=not self.options.no_assets,
|
|
)
|
|
except subprocess.CalledProcessError as exc:
|
|
problems.append(f"{label}: command failed ({exc})")
|
|
continue
|
|
except Exception as exc:
|
|
problems.append(f"{label}: {type(exc).__name__}: {exc}")
|
|
continue
|
|
|
|
lines.append(
|
|
f"{label}: {record['assets']} | frames={record['frames']} "
|
|
f"flow={record['flow_shape']}"
|
|
)
|
|
self.runtime_records.append({"tag": tag, **record})
|
|
|
|
detail = "\n".join(lines)
|
|
if problems:
|
|
detail += "\nfailures:\n" + "\n".join(problems)
|
|
return Step("wheel-runtime", not problems, detail, data={"records": self.runtime_records})
|
|
|
|
# -- driver ----------------------------------------------------------
|
|
|
|
def run(self) -> int:
|
|
started_at = _now()
|
|
started = time.perf_counter()
|
|
print(f"=== orisys release preflight: version {self.version} ({self.version_source}) ===")
|
|
print(f"repo: {self.repo_root}")
|
|
print(f"report: {self.report_dir}")
|
|
print()
|
|
|
|
try:
|
|
for step_fn in (
|
|
self._repo_state,
|
|
self._version_consistency,
|
|
self._version_strings,
|
|
self._duplicate_guard,
|
|
self._tests,
|
|
self._bundle_precheck,
|
|
self._wheel_inspection,
|
|
self._wheel_runtime,
|
|
):
|
|
self._execute(step_fn)
|
|
if self.aborted:
|
|
break
|
|
finally:
|
|
self.ok = bool(self.steps) and all(step.ok for step in self.steps) and not self.aborted
|
|
duration = round(time.perf_counter() - started, 3)
|
|
payload = {
|
|
"version": self.version,
|
|
"version_source": self.version_source,
|
|
"ok": self.ok,
|
|
"aborted": self.aborted,
|
|
"started_at": started_at,
|
|
"finished_at": _now(),
|
|
"duration_s": duration,
|
|
"git": self.git,
|
|
"interpreter": {
|
|
"executable": sys.executable,
|
|
"version": platform.python_version(),
|
|
"platform": sys.platform,
|
|
},
|
|
"options": {key: value for key, value in asdict(self.options).items()
|
|
if not isinstance(value, Path)},
|
|
"steps": [asdict(step) for step in self.steps],
|
|
"wheels": [
|
|
{
|
|
"name": wheel.name,
|
|
"size": wheel.stat().st_size,
|
|
"sha256": _sha256_file(wheel),
|
|
}
|
|
for wheel in self.wheels
|
|
],
|
|
}
|
|
payload["options"]["wheel_dir"] = str(self.options.wheel_dir) if self.options.wheel_dir else None
|
|
payload["options"]["video"] = str(self.options.video) if self.options.video else None
|
|
|
|
json_path, text_path = report_mod.write_report(payload, self.report_dir)
|
|
print()
|
|
print(f"report: {json_path}")
|
|
print(f"report: {text_path}")
|
|
|
|
print()
|
|
print(f"=== preflight {'PASSED' if self.ok else 'FAILED'} ({duration}s) ===")
|
|
return 0 if self.ok else 1
|
|
|
|
|
|
def run_preflight(options: PreflightOptions, repo_root: Path | None = None) -> int:
|
|
return Preflight(options, repo_root=repo_root).run()
|