fix preflight
This commit is contained in:
+12
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
if self.options.force:
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f"SDK/{self.version}/ already holds {len(existing)} wheel(s); "
|
f"SDK/{self.version}/ holds {len(existing)} staged wheel(s) to verify: "
|
||||||
"overwriting because --force was passed"
|
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:
|
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)")
|
||||||
|
|||||||
@@ -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
@@ -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 替换成新版本号」的方式改本文件——历史事实不应该被改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user