added preflight check

This commit is contained in:
ykcheungab
2026-09-12 15:49:28 +08:00
parent 9c23ba3d50
commit 9537cf91a1
31 changed files with 3346 additions and 43 deletions
+515
View File
@@ -0,0 +1,515 @@
"""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. ``duplicate-guard`` - SDK/<version>/ already populated; optionally PyPI
4. ``tests`` - pytest (slow/hardware markers excluded)
5. ``bundle-precheck`` - every bundle item resolves; required assets present
6. ``wheel-inspection`` - static metadata/content assertions per wheel
7. ``wheel-runtime`` - isolated venv: 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_assets,
verify_wheel_runtime,
)
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:
self.version = "0.0.0"
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:
problems: list[str] = []
lines: list[str] = []
existing = version_mod.sdk_wheels(self.repo_root, self.version)
if existing:
if self.options.force:
lines.append(
f"SDK/{self.version}/ already holds {len(existing)} wheel(s); "
"overwriting because --force was passed"
)
else:
problems.append(
f"SDK/{self.version}/ already holds {len(existing)} wheel(s): "
f"{[w.name for w in existing]}. Pass --force to rebuild over them."
)
else:
lines.append(f"SDK/{self.version}/ is empty (no wheel has been staged yet)")
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 versions: " + (", ".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 _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:
if self.options.no_assets:
assets_out = "asset check skipped (--no-assets)"
else:
assets_out = verify_wheel_assets(wheel, python_executable=executable)
runtime_out = verify_wheel_runtime(
wheel,
video=video,
config_name=self.options.config_name,
frames=self.options.frames,
python_executable=executable,
offline=self.options.offline,
)
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}: {assets_out} | frames={runtime_out['frames']} flow={runtime_out['flow_shape']}")
self.runtime_records.append({"tag": tag, "wheel": wheel.name, **runtime_out})
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._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()