From 57bef85e32f2e772701136c1ae11fccabaf2d9f6 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sat, 12 Sep 2026 16:00:40 +0800 Subject: [PATCH] fix preflight --- build/README.md | 16 ++++++++--- build/release/cli.py | 6 +++- build/release/preflight.py | 36 ++++++++++++++++++------ build/release/version.py | 40 +++++++++++++++++++++++++++ docs/publishing.md | 21 +++++++++++--- tests/test_release_version.py | 52 +++++++++++++++++++++++++++++++++-- 6 files changed, 151 insertions(+), 20 deletions(-) diff --git a/build/README.md b/build/README.md index 8a9668a..2f988a7 100644 --- a/build/README.md +++ b/build/README.md @@ -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 ``` -`preflight` runs: repo state → version consistency → duplicate guard → pytest → -bundle precheck → per-wheel static content/metadata assertions → isolated runtime -verification (bundled assets + real frames, no camera). Reports land in -`build/release/reports//`. +`preflight` runs: repo state → version consistency → duplicate guard (fails only +when the version is already released: a local git tag, or PyPI with +`--check-pypi`) → pytest → bundle precheck → per-wheel static content/metadata +assertions → isolated runtime verification (bundled assets + real frames, no +camera). A populated `SDK//` 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//`. ### GUI diff --git a/build/release/cli.py b/build/release/cli.py index c511521..553bc81 100644 --- a/build/release/cli.py +++ b/build/release/cli.py @@ -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("--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("--force", action="store_true", help="allow re-staging an existing SDK/") + 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( "--all-wheel-pythons", diff --git a/build/release/preflight.py b/build/release/preflight.py index 5917153..3228155 100644 --- a/build/release/preflight.py +++ b/build/release/preflight.py @@ -209,23 +209,43 @@ class Preflight: 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//`` 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( - f"SDK/{self.version}/ already holds {len(existing)} wheel(s); " - "overwriting because --force was passed" - ) + lines.append(message + " (accepted via --force)") 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." + message + "; pass --force to re-verify or intentionally re-release" ) 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: versions, error = version_mod.pypi_released_versions() @@ -240,7 +260,7 @@ class Preflight: ) else: 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: lines.append("PyPI existence check skipped (pass --check-pypi to enable)") diff --git a/build/release/version.py b/build/release/version.py index 6294baf..51a226e 100644 --- a/build/release/version.py +++ b/build/release/version.py @@ -5,12 +5,16 @@ from __future__ import annotations import json import os import re +import subprocess import urllib.error import urllib.request from pathlib import Path 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----.whl _WHEEL_NAME_RE = re.compile(r"^orisys-(?P[^-]+)-[^-]+-[^-]+-.+\.whl$") @@ -103,6 +107,42 @@ def _version_sort_key(name: str): 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: """Locate the conda envs directory the same way build/wheel/win.bat does.""" override = os.getenv("CONDA_ENVS_ROOT") diff --git a/docs/publishing.md b/docs/publishing.md index d169078..35d3d70 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -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//` 中 wheel 文件名里的版本 | -| `duplicate-guard` | `SDK//` 已有产物则拒绝(`--force` 豁免);`--check-pypi` 时只读查询 PyPI,版本已存在则拒绝 | +| `duplicate-guard` | 该版本是否**已经发布过**:本地 git tag(`v` / ``,含 sibling `orisys-sdk-release` 仓库)命中即拒绝;`--check-pypi` 时再查 PyPI。`SDK//` 里已有 wheel 只作信息展示——那是 preflight 的正常输入,不是错误 | | `tests` | `pytest -m "not slow and not hardware"`(`--longrun` 时包含 slow) | | `bundle-precheck` | 每个 `[[tool.orisys.bundle.items]]` 至少解析出 1 个文件;finger 必需资产存在 | | `wheel-inspection` | 逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 `orisys/**/*.py` 源码泄漏/与源码树逐文件字节一致 | | `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//preflight.json` — 机器可读,含每个 wheel 的 sha256,供后续发布步骤复用 @@ -115,7 +125,7 @@ python build/run.py verify --gui-dist --product ory_finger 1. 用 `release preflight` 拿到 PASS(并保留 `preflight.json`)。 2. 把 `SDK//` 同步到 `orisys-sdk-release` 仓库的 `release//`。 3. GitHub:打 tag **`v`** 并创建 Release,上传 6 个 wheel 作为附件。 - - 注意历史 tag 里有一个 typo:`0.50`(与 `v0.5.2` 指向同一 commit)。 + - 注意历史 tag 里有一个 typo:`0.50`(与 `v0.5.1` 指向同一 commit)。 4. PyPI:先 TestPyPI 再正式仓库。 ```powershell @@ -133,7 +143,10 @@ python -m twine upload SDK/0.5.2/*.whl | 0.4.3 | — | 有 | 有 | | 0.4.4 | 有 | **无** | **无** | | 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 替换成新版本号」的方式改本文件——历史事实不应该被改。 --- diff --git a/tests/test_release_version.py b/tests/test_release_version.py index b4456d7..66b0204 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -4,6 +4,7 @@ from __future__ import annotations import io import json +import subprocess import sys import urllib.error from pathlib import Path @@ -193,25 +194,47 @@ def test_version_consistency_passes_on_match(tmp_path): 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// is the normal preflight input, not a hazard.""" repo = _make_repo(tmp_path, version="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() assert not step.ok + assert "already mark 0.5.2 as released" 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") - _stage_wheel(repo, "0.5.2") + monkeypatch.setattr(version_mod, "git_tags", lambda root: ["0.5.2"]) step = Preflight( PreflightOptions(version="0.5.2", force=True), repo_root=repo )._duplicate_guard() 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): 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)) step = Preflight( 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 +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): repo = _make_repo(tmp_path, version="0.5.2") (repo / "pyproject.toml").write_text(