fix preflight

This commit is contained in:
ykcheungab
2026-09-12 16:00:40 +08:00
parent 9537cf91a1
commit 57bef85e32
6 changed files with 151 additions and 20 deletions
+12 -4
View File
@@ -53,10 +53,18 @@ python build/run.py release preflight --all-wheel-pythons --check-pypi
python build/run.py release audit SDK\0.5.2\orisys-0.5.2-cp312-cp312-win_amd64.whl python build/run.py release audit SDK\0.5.2\orisys-0.5.2-cp312-cp312-win_amd64.whl
``` ```
`preflight` runs: repo state → version consistency → duplicate guard → pytest → `preflight` runs: repo state → version consistency → duplicate guard (fails only
bundle precheck → per-wheel static content/metadata assertions → isolated runtime when the version is already released: a local git tag, or PyPI with
verification (bundled assets + real frames, no camera). Reports land in `--check-pypi`) → pytest → bundle precheck → per-wheel static content/metadata
`build/release/reports/<version>/`. assertions → isolated runtime verification (bundled assets + real frames, no
camera). A populated `SDK/<version>/` is the expected input, so build first:
```bash
python build/run.py wheel-win
python build/run.py release preflight --all-wheel-pythons --check-pypi
```
Reports land in `build/release/reports/<version>/`.
### GUI ### GUI
+5 -1
View File
@@ -34,7 +34,11 @@ def _cmd_preflight(argv: list[str]) -> int:
parser.add_argument("--skip-tests", action="store_true", help="skip pytest (recorded in the report)") parser.add_argument("--skip-tests", action="store_true", help="skip pytest (recorded in the report)")
parser.add_argument("--longrun", action="store_true", help="include the slow long-run stability test") parser.add_argument("--longrun", action="store_true", help="include the slow long-run stability test")
parser.add_argument("--allow-dirty", action="store_true", help="allow a dirty working tree") parser.add_argument("--allow-dirty", action="store_true", help="allow a dirty working tree")
parser.add_argument("--force", action="store_true", help="allow re-staging an existing SDK/<version>") parser.add_argument(
"--force",
action="store_true",
help="accept a version that a git tag already marks as released",
)
parser.add_argument("--check-pypi", action="store_true", help="read PyPI and fail if the version exists") parser.add_argument("--check-pypi", action="store_true", help="read PyPI and fail if the version exists")
parser.add_argument( parser.add_argument(
"--all-wheel-pythons", "--all-wheel-pythons",
+28 -8
View File
@@ -209,23 +209,43 @@ class Preflight:
return Step("version-consistency", not problems, detail, data=data) return Step("version-consistency", not problems, detail, data=data)
def _duplicate_guard(self) -> Step: 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] = [] problems: list[str] = []
lines: list[str] = [] lines: list[str] = []
existing = version_mod.sdk_wheels(self.repo_root, self.version) existing = version_mod.sdk_wheels(self.repo_root, self.version)
if existing: 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: if self.options.force:
lines.append( lines.append(message + " (accepted via --force)")
f"SDK/{self.version}/ already holds {len(existing)} wheel(s); "
"overwriting because --force was passed"
)
else: else:
problems.append( problems.append(
f"SDK/{self.version}/ already holds {len(existing)} wheel(s): " message + "; pass --force to re-verify or intentionally re-release"
f"{[w.name for w in existing]}. Pass --force to rebuild over them."
) )
else: else:
lines.append(f"SDK/{self.version}/ is empty (no wheel has been staged yet)") 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: if self.options.check_pypi:
versions, error = version_mod.pypi_released_versions() versions, error = version_mod.pypi_released_versions()
@@ -240,7 +260,7 @@ class Preflight:
) )
else: else:
lines.append( lines.append(
f"PyPI has no {self.version}; published versions: " + (", ".join(versions) or "none") f"PyPI has no {self.version}; published: " + (", ".join(versions) or "none")
) )
else: else:
lines.append("PyPI existence check skipped (pass --check-pypi to enable)") lines.append("PyPI existence check skipped (pass --check-pypi to enable)")
+40
View File
@@ -5,12 +5,16 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import subprocess
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
PYPI_JSON_URL = "https://pypi.org/pypi/orisys/json" PYPI_JSON_URL = "https://pypi.org/pypi/orisys/json"
# Sibling repository that carries the public GitHub releases and their tags.
RELEASE_REPO_DIRNAME = "orisys-sdk-release"
# orisys-<version>-<pytag>-<abitag>-<platformtag>.whl # orisys-<version>-<pytag>-<abitag>-<platformtag>.whl
_WHEEL_NAME_RE = re.compile(r"^orisys-(?P<version>[^-]+)-[^-]+-[^-]+-.+\.whl$") _WHEEL_NAME_RE = re.compile(r"^orisys-(?P<version>[^-]+)-[^-]+-[^-]+-.+\.whl$")
@@ -103,6 +107,42 @@ def _version_sort_key(name: str):
return ([int(part) for part in parts] or [0], name) return ([int(part) for part in parts] or [0], name)
def git_tags(repo_root: Path) -> list[str]:
"""Local git tags from this repo and the sibling release repo.
Tags are the only offline evidence that a version was already released
(``v0.5.1`` in ``orisys-sdk-release`` marks a published GitHub release), so
the duplicate guard works without network access.
"""
roots = [Path(repo_root)]
sibling = Path(repo_root).parent / RELEASE_REPO_DIRNAME
if sibling.is_dir():
roots.append(sibling)
tags: set[str] = set()
for root in roots:
try:
completed = subprocess.run(
["git", "-C", str(root), "tag", "--list"],
capture_output=True,
text=True,
check=False,
)
except OSError:
continue
if completed.returncode != 0:
continue
tags.update(
line.strip() for line in (completed.stdout or "").splitlines() if line.strip()
)
return sorted(tags)
def released_tags_for(version: str, tags: list[str]) -> list[str]:
"""Tag spellings that mark ``version`` as already released."""
return sorted({version, f"v{version}"} & set(tags))
def conda_envs_root() -> Path | None: def conda_envs_root() -> Path | None:
"""Locate the conda envs directory the same way build/wheel/win.bat does.""" """Locate the conda envs directory the same way build/wheel/win.bat does."""
override = os.getenv("CONDA_ENVS_ROOT") override = os.getenv("CONDA_ENVS_ROOT")
+17 -4
View File
@@ -53,14 +53,24 @@ python build/run.py release preflight --version 0.5.2
| 步骤 | 检查内容 | | 步骤 | 检查内容 |
| --- | --- | | --- | --- |
| `repo-state` | 是 git 仓库、工作树干净、记录 HEAD(`--allow-dirty` 可豁免) | | `repo-state` | 是 git 仓库、工作树干净、记录 HEAD(`--allow-dirty` 可豁免,用于提交前预检) |
| `version-consistency` | `src/orisys/__init__.py::__version__` == pyproject 的 dynamic attr == `SDK/<version>/` 中 wheel 文件名里的版本 | | `version-consistency` | `src/orisys/__init__.py::__version__` == pyproject 的 dynamic attr == `SDK/<version>/` 中 wheel 文件名里的版本 |
| `duplicate-guard` | `SDK/<version>/` 已有产物则拒绝(`--force` 豁免);`--check-pypi` 时只读查询 PyPI,版本已存在则拒绝 | | `duplicate-guard` | 该版本是否**已经发布过**:本地 git tag(`v<version>` / `<version>`,含 sibling `orisys-sdk-release` 仓库)命中即拒绝;`--check-pypi` 时再查 PyPI。`SDK/<version>/` 里已有 wheel 只作信息展示——那是 preflight 的正常输入,不是错误 |
| `tests` | `pytest -m "not slow and not hardware"`(`--longrun` 时包含 slow) | | `tests` | `pytest -m "not slow and not hardware"`(`--longrun` 时包含 slow) |
| `bundle-precheck` | 每个 `[[tool.orisys.bundle.items]]` 至少解析出 1 个文件;finger 必需资产存在 | | `bundle-precheck` | 每个 `[[tool.orisys.bundle.items]]` 至少解析出 1 个文件;finger 必需资产存在 |
| `wheel-inspection` | 逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 `orisys/**/*.py` 源码泄漏/与源码树逐文件字节一致 | | `wheel-inspection` | 逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 `orisys/**/*.py` 源码泄漏/与源码树逐文件字节一致 |
| `wheel-runtime` | 在一次性 venv 中安装该 wheel,断言内置 configs/sensors 资产可加载,并对真实视频跑 40 帧公开流水线(无需相机、无 GUI) | | `wheel-runtime` | 在一次性 venv 中安装该 wheel,断言内置 configs/sensors 资产可加载,并对真实视频跑 40 帧公开流水线(无需相机、无 GUI) |
正常顺序是**先构建、再 preflight**,不需要额外开关:
```powershell
python build/run.py wheel-win
python build/run.py manylinux
python build/run.py release preflight --all-wheel-pythons --check-pypi
```
提交前想先看结果(此时工作树通常还是脏的):加 `--allow-dirty --keep-going`。
产物报告: 产物报告:
- `build/release/reports/<version>/preflight.json` — 机器可读,含每个 wheel 的 sha256,供后续发布步骤复用 - `build/release/reports/<version>/preflight.json` — 机器可读,含每个 wheel 的 sha256,供后续发布步骤复用
@@ -115,7 +125,7 @@ python build/run.py verify --gui-dist --product ory_finger
1. 用 `release preflight` 拿到 PASS(并保留 `preflight.json`)。 1. 用 `release preflight` 拿到 PASS(并保留 `preflight.json`)。
2. 把 `SDK/<version>/` 同步到 `orisys-sdk-release` 仓库的 `release/<version>/`。 2. 把 `SDK/<version>/` 同步到 `orisys-sdk-release` 仓库的 `release/<version>/`。
3. GitHub:打 tag **`v<version>`** 并创建 Release,上传 6 个 wheel 作为附件。 3. GitHub:打 tag **`v<version>`** 并创建 Release,上传 6 个 wheel 作为附件。
- 注意历史 tag 里有一个 typo:`0.50`(与 `v0.5.2` 指向同一 commit)。 - 注意历史 tag 里有一个 typo:`0.50`(与 `v0.5.1` 指向同一 commit)。
4. PyPI:先 TestPyPI 再正式仓库。 4. PyPI:先 TestPyPI 再正式仓库。
```powershell ```powershell
@@ -133,7 +143,10 @@ python -m twine upload SDK/0.5.2/*.whl
| 0.4.3 | — | 有 | 有 | | 0.4.3 | — | 有 | 有 |
| 0.4.4 | 有 | **无** | **无** | | 0.4.4 | 有 | **无** | **无** |
| 0.5.0 | 有 | **无** | **无** | | 0.5.0 | 有 | **无** | **无** |
| 0.5.2 | 有 | 有 | 有(tag `v0.5.2` + typo tag `0.50`) | | 0.5.1 | 有 | 有 | 有(tag `v0.5.1` + typo tag `0.50`) |
| 0.5.2 | 有 | **待发布** | **待发布**(`release preflight --check-pypi` 确认 PyPI 尚无 0.5.2) |
> 本表按 2026 年 0.5.2 构建时的实际状态填写。`0.5.1` 这一行曾被一次全局替换误改成 `0.5.2` 并丢失,请勿再用「全局把 0.5.1 替换成新版本号」的方式改本文件——历史事实不应该被改。
--- ---
+49 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import io import io
import json import json
import subprocess
import sys import sys
import urllib.error import urllib.error
from pathlib import Path from pathlib import Path
@@ -193,25 +194,47 @@ def test_version_consistency_passes_on_match(tmp_path):
assert step.ok, step.detail assert step.ok, step.detail
def test_duplicate_guard_blocks_existing_sdk_dir(tmp_path): def test_duplicate_guard_allows_staged_sdk_dir(tmp_path, monkeypatch):
"""A populated SDK/<version>/ is the normal preflight input, not a hazard."""
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="0.5.2")
_stage_wheel(repo, "0.5.2") _stage_wheel(repo, "0.5.2")
monkeypatch.setattr(version_mod, "git_tags", lambda root: [])
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard()
assert step.ok, step.detail
assert "staged wheel(s) to verify" in step.detail
def test_duplicate_guard_blocks_released_tag(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2")
_stage_wheel(repo, "0.5.2")
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v0.5.2", "v0.5.1"])
step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard() step = Preflight(PreflightOptions(version="0.5.2"), repo_root=repo)._duplicate_guard()
assert not step.ok assert not step.ok
assert "already mark 0.5.2 as released" in step.detail
assert "--force" in step.detail assert "--force" in step.detail
def test_duplicate_guard_allows_force(tmp_path): def test_duplicate_guard_force_accepts_released_tag(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="0.5.2")
_stage_wheel(repo, "0.5.2") monkeypatch.setattr(version_mod, "git_tags", lambda root: ["0.5.2"])
step = Preflight( step = Preflight(
PreflightOptions(version="0.5.2", force=True), repo_root=repo PreflightOptions(version="0.5.2", force=True), repo_root=repo
)._duplicate_guard() )._duplicate_guard()
assert step.ok, step.detail assert step.ok, step.detail
assert "accepted via --force" in step.detail
def test_duplicate_guard_reports_unreleased_version(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.3")
monkeypatch.setattr(version_mod, "git_tags", lambda root: ["v0.5.2"])
step = Preflight(PreflightOptions(version="0.5.3"), repo_root=repo)._duplicate_guard()
assert step.ok, step.detail
assert "no local git tag marks 0.5.3" in step.detail
def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch): def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="0.5.2")
monkeypatch.setattr(version_mod, "git_tags", lambda root: [])
monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["0.5.2"], None)) monkeypatch.setattr(version_mod, "pypi_released_versions", lambda *a, **k: (["0.5.2"], None))
step = Preflight( step = Preflight(
PreflightOptions(version="0.5.2", check_pypi=True), repo_root=repo PreflightOptions(version="0.5.2", check_pypi=True), repo_root=repo
@@ -220,6 +243,29 @@ def test_duplicate_guard_flags_published_version(tmp_path, monkeypatch):
assert "already published on PyPI" in step.detail assert "already published on PyPI" in step.detail
def test_released_tags_for_matches_version_spellings():
tags = ["v0.5.1", "0.5.1", "v0.4.3", "0.50"]
assert version_mod.released_tags_for("0.5.1", tags) == ["0.5.1", "v0.5.1"]
assert version_mod.released_tags_for("0.5.2", tags) == []
def test_git_tags_reads_repo_tags(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
for command in (
["git", "init", "-q"],
["git", "config", "user.email", "test@example.invalid"],
["git", "config", "user.name", "test"],
):
subprocess.run(command, cwd=repo, check=True, capture_output=True)
(repo / "f.txt").write_text("x", encoding="utf-8")
subprocess.run(["git", "add", "f.txt"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "tag", "v0.5.2"], cwd=repo, check=True, capture_output=True)
assert "v0.5.2" in version_mod.git_tags(repo)
def test_bundle_precheck_fails_when_pattern_resolves_nothing(tmp_path): def test_bundle_precheck_fails_when_pattern_resolves_nothing(tmp_path):
repo = _make_repo(tmp_path, version="0.5.2") repo = _make_repo(tmp_path, version="0.5.2")
(repo / "pyproject.toml").write_text( (repo / "pyproject.toml").write_text(