23 KiB
发布流程(构建 → 校验 → 发布)
本文档说明如何构建 orisys 的 Nuitka 二进制 wheel、如何在发布前用一条命令做完整门禁,以及当前仍需人工执行的发布步骤。
历史说明:
README.md与build/wheel/docker/run.ps1早就引用了本文件,但它此前并不存在。本文件补齐该引用。
0. 环境准备
推荐用 py310 conda 环境执行构建与校验(它同时具备 pytest / twine / numba / Nuitka / open3d / PyQt5):
conda activate py310
python -m pytest -m "not slow and not hardware" # 开发期快速回归
py311、py312 仅作为 Nuitka 各 ABI 的编译环境按需调用(build/wheel/win.bat 会依次使用 py310/py311/py312)。
不要在同一个环境里既装旧版 orisys 又从源码目录跑测试:site-packages 里的旧包会静默遮蔽 src/orisys。
tests/test_harness_guards.py 会显式报错提示执行 pip uninstall -y orisys。
1. 构建 wheel
# Windows:py310 / py311 / py312 三个 ABI
python build/run.py wheel-win
# 单个解释器(当前环境)
python build/run.py wheel
# Linux manylinux(Docker Desktop,Linux 容器模式)
python build/run.py manylinux
python build/run.py wheel-manylinux -PythonVersions 3.10
产物落点:
SDK/<version>/— 交付用 wheel(git 忽略)dist/repaired/— 上传候选(git 忽略)
2. 发布前门禁:release preflight
# --version 可省略:默认取 pyproject 的 dynamic version(即 src/orisys/__init__.py 里的 __version__)
python build/run.py release preflight
按顺序执行 8 个步骤,任一步失败即整体失败(--keep-going 可改为收集全部失败):
| 步骤 | 检查内容 |
|---|---|
repo-state |
是 git 仓库、工作树干净、记录 HEAD(--allow-dirty 可豁免,用于提交前预检) |
version-consistency |
src/orisys/__init__.py::__version__ == pyproject 的 dynamic attr == SDK/<version>/ 中 wheel 文件名里的版本 |
version-strings |
追踪的文本文件里没有与当前版本不符的版本字面量(见 §5.3) |
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) |
bundle-precheck |
每个 [[tool.orisys.bundle.items]] 至少解析出 1 个文件;finger 必需资产存在 |
wheel-inspection |
逐个 wheel 做静态断言:文件名/版本/METADATA 字段/RECORD 哈希/压缩包路径/体积上限/无 pyc 与缓存/无 orisys/**/*.py 源码泄漏/与源码树逐文件字节一致 |
wheel-runtime |
每个 wheel 一个 venv,在里面依次做:导入冒烟 → 内置 configs/sensors 资产可加载 → 对真实视频跑 40 帧公开流水线(无需相机、无 GUI) |
正常顺序是先构建、再 preflight,不需要额外开关:
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.txt— 人工阅读build/release/reports/<version>/junit.xml— pytest 结果
(build/release/reports/ 已加入 .gitignore。)
常用参数
| 参数 | 作用 |
|---|---|
--wheel-dir DIR |
校验指定目录下的 wheel(默认 SDK/<version>) |
--all-wheel-pythons |
cp310/cp311/cp312 各自在对应 conda 环境里独立校验 |
--video PATH / --frames N / --config NAME |
覆盖运行时校验输入(默认 data/sample/s1_480p.mp4 / 40 帧 / box) |
--offline |
复用父环境的 numpy/opencv/numba,不去 PyPI 装依赖 |
--check-pypi |
唯一一处网络调用:只读 PyPI,防止重复发布 |
--skip-tests / --no-runtime / --no-assets |
显式跳过并在报告中留痕;跳过的运行不能作为发布依据 |
--report-dir DIR |
覆盖报告根目录 |
wheel-runtime 不会静默跳过:找不到视频时直接失败,提示 --video / ORISYS_VERIFY_VIDEO。
单体体检:release audit
对单个 wheel 做静态断言(+ 可选运行时校验),不需要版本上下文。适合体检历史产物:
python build/run.py release audit SDK\<version>\orisys-<version>-cp312-cp312-win_amd64.whl
python build/run.py release audit dist\repaired\orisys-<version>-cp310-cp310-win_amd64.whl --strict-bundle
# 追加运行时校验(--assets 与 --runtime 同时给时共用一个 venv,只装一次依赖)
python build/run.py release audit <wheel> --assets --runtime --video data/sample/s1_480p.mp4
默认对「内置文件与当前源码树不一致」只告警(历史 wheel 与当前分支不一致是正常的);加 --strict-bundle 改成失败(刚构建完的 wheel 应当一致)。
旧的导入冒烟测试仍然可用,行为不变:
python build/run.py verify <wheel>
python build/run.py verify <wheel> --contents # 静态断言
python build/run.py verify <wheel> --assets --runtime # 资产 + 真实帧
python build/run.py verify --gui-dist --product ory_finger
3. 发布(两条命令)
先决条件:release preflight PASS,而且是在干净提交上跑的。两个发布命令都只发布「preflight 认证过的东西」——它们会读取 build/release/reports/<version>/preflight.json,逐个核对 wheel 的 sha256,并拒绝使用带豁免项(--skip-tests / --allow-dirty / --no-runtime / --force)的报告。所以正确顺序是:
git add -A; git commit -m "..."
python build/run.py wheel-win && python build/run.py manylinux
python build/run.py release preflight --all-wheel-pythons --check-pypi # 干净树 + 无豁免
两个命令默认都是 dry run,只打印将要执行的动作;必须显式加 --apply 才会真正动手。GitHub 的 gh release create、git push,以及 PyPI 上传(版本号永久占用、不可覆盖)都属于不可撤销操作。
3.1 GitHub:同步公开仓库 + 上传 Release
python build/run.py release github --version 0.5.2 # 预览
python build/run.py release github --version 0.5.2 --apply # 执行
执行顺序:
- 认证书校验(上面那份报告 + 逐 wheel sha256)
git fetch --tags,若v0.5.2或0.5.2已存在则拒绝;gh release view已存在也拒绝- 按
tools/downstream_sync_manifest.json同步公开内容到../orisys-sdk-release,并运行 manifest 里的checks - 把
SDK/0.5.2/*.whl放到orisys-sdk-release/release/0.5.2/(该目录在 release 仓库里是 gitignore 的,只用于上传附件) - 提交 → 打 tag
v0.5.2→ push 分支与 tag gh release create v0.5.2 --verify-tag --latest <6 个 wheel>,然后回读gh release view --json assets核对附件齐全
需要:gh 已安装并登录(winget install --id GitHub.cli 然后 gh auth login)。没装 gh 时:
- dry run 仍会完整打印计划(只告警)
--apply会失败,或改用--skip-release-upload:同步、提交、打 tag、push 都自动完成,然后打印出你自己要跑的gh release create
其他开关:--notes-file(自定义 release notes)、--attach-preflight(把 preflight.json 也作为附件上传,便于审计;注意报告里有本机绝对路径)、--allow-dirty-release、--skip-fetch、--skip-checks。
tag 拼写沿用历史风格
v<version>(注意历史里那个 typo tag0.50指向v0.5.1)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。如果 tag 已经 push 但 Release 上传失败(例如gh没配好),直接重跑同一条--apply命令即可续跑:它会跳过创建 tag,只补做剩下的步骤;只有「GitHub Release 已存在」才拒绝。
3.2 PyPI:TestPyPI 先行,确认后再上正式仓库
python build/run.py release pypi --version 0.5.2 # 预览
python build/run.py release pypi --version 0.5.2 --apply # 只发 TestPyPI
python build/run.py release pypi --version 0.5.2 --confirm-pypi --apply # TestPyPI + 正式 PyPI
# 只有正式 token、没有 TestPyPI 账号时:跳过彩排直接发正式库
python build/run.py release pypi --version 0.5.2 --pypi-only --apply
--pypi-only 与 --confirm-pypi 互斥。注意它的代价:没有 TestPyPI 彩排,而正式库的版本号一旦占用就无法复用、无法替换。它仍然会做完整门禁、twine check、上传前查重、上传后回读索引核对文件、以及把产物重新拉回来验证。
执行顺序:
- 认证书校验(与
release github同一份报告) twine check全部 wheel(校验 METADATA/long description,上传前就能发现问题)- 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的)
- 上传 → 回读索引确认 6 个文件都在 →
pip download把刚上传的 wheel 拉回来,证明产物可获取 - 未加
--confirm-pypi时只发 TestPyPI,然后停在这里并打印后续命令
3.3 凭证:release auth 与 ~/.pypirc
python build/run.py release auth # 查看两个索引的凭证状态
python build/run.py release auth --set-token pypi # 隐藏输入,写入 ~/.pypirc
python build/run.py release auth --set-token testpypi
python build/run.py release auth --repair # 修「有段但没列进 index-servers」
--set-token 的 token 只从隐藏输入或 --token-stdin(供脚本/CI)读取,不接受命令行参数——避免落进 shell 历史和进程列表。写入采用定点文本编辑,文件里的说明注释会保留;同时会把该索引补进 [distutils] index-servers(这一步很关键,见下)。
release auth 只有在你真的运行 --set-token 时才写文件;不带参数时只读并报告,状态判断比「文件是否存在」严格:
config file: C:\Users\cheun\.pypirc (exists)
[-- ] [pypi] not usable: [pypi] still contains a placeholder token
[-- ] [testpypi] not usable: C:\Users\cheun\.pypirc has no [testpypi] section
占位符、空密码、username 不是 __token__,都会被判定为「不可用」——否则 twine 只会在真正上传时才发现,而那时已经动过索引了。
release pypi 在 --apply 时若凭证缺失:有终端就地提示输入(隐藏),无终端则直接失败并打印 release auth --set-token <index>;也可显式加 --prompt-credentials。dry run 只告警并继续展示计划,不会卡在输入上。
twine 直接读取的格式
默认路径 ~/.pypirc(Windows 上即 C:\Users\<你>\.pypirc,由 USERPROFILE 展开)。没有环境变量可以改这个路径;要用别的文件就传 --config-file。
[distutils]
index-servers =
pypi
testpypi
[pypi]
repository = https://upload.pypi.org/legacy/
username = __token__
password = pypi-你的正式token
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-你的TestPyPI token
容易搞错的点:
[distutils] index-servers必须列出该索引,否则 twine 完全忽略那一节 —— 这是最常见的坑:文件里明明有[testpypi]段,twine 却报InvalidConfiguration: Missing 'testpypi' section。用release auth --repair修(把已存在但未列出的段补进index-servers)。- 更细的语义差别:
index-servers =值为空表示「一个都不认」;只有整行缺失时 twine 才会回退到默认的pypi testpypi。所以手工编辑时别把名字删空。 username就是字面量__token__,不是账号名或邮箱- 两类 token 都以
pypi-开头,但不能互换:拿正式 token 打test.pypi.org会被 403 拒绝 - TestPyPI 是独立服务、独立账号,注册在 https://test.pypi.org/account/register/,你的 PyPI 账号在那里不存在
- 正式 token 建议限定到
orisys项目(项目已存在,可以建 project-scoped token) - 该文件必须在任何 git 仓库之外(放在用户目录里);token 一旦提交进仓库,即使后来删文件也还在历史里——那属于安全事故
- 临时替代方案:会话内设
TWINE_USERNAME=__token__+TWINE_PASSWORD=<token>,命令会识别这种情形 - PyPI token 没有只读的校验方式,有效性只能在真正上传时才会被索引确认;所以所有本地检查、查重都排在写操作之前
release auth 的「可用」判定直接问 twine 自己(twine.utils.get_config 解析出的 username/password),而不是自己解析 ini,这样报告结果与 twine 的行为不会分叉。twine 未安装时才回退到本地复刻同一套规则。
只需要 TestPyPI 时才把 testpypi 写进 index-servers 并填对应 token;只发正式库时 --pypi-only 用不到 [testpypi] 段。
3.4 已知的历史发布空洞
| 版本 | 本地 SDK/ |
PyPI | GitHub Release |
|---|---|---|---|
| 0.4.2 | — | 有 | 有 |
| 0.4.3 | — | 有 | 有 |
| 0.4.4 | 有 | 无 | 无 |
| 0.5.0 | 有 | 无 | 无 |
| 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. 环境变量
| 变量 | 用途 |
|---|---|
ORISYS_TEST_VIDEO |
pytest 用的视频;默认自动取 data/sample/s1_480p.mp4 |
ORISYS_TEST_CONFIG |
pytest 用的配置名;默认自动挑一个含 stitcher.box_size 的配置 |
ORISYS_VERIFY_VIDEO |
运行时校验用的视频 |
ORISYS_VERIFY_CONFIG |
运行时校验用的配置名(默认 box) |
ORISYS_LONGRUN_BACKEND / ORISYS_LONGRUN_FRAMES |
slow 长稳测试的参数 |
CONDA_ENVS_ROOT |
conda envs 根目录(--all-wheel-pythons 用它解析 py310/py311/py312) |
5. 三个必须记住的坑
5.1 Nuitka 编译包的 __file__ 是假的
Nuitka 把整个 orisys 编译成单个扩展模块,其 __init__ 会安装 nuitka_module_loader。于是:
>>> import orisys
>>> orisys.__file__ # 合成的、磁盘上并不存在的路径
'...\\site-packages\\orisys\\orisys\\__init__.py'
>>> orisys.__path__ # 第二项是 Nuitka 的哨兵值
['...\\site-packages\\orisys\\orisys', '\\not_existing']
>>> orisys.__loader__.__class__.__name__
'nuitka_module_loader'
后果:在 Nuitka wheel 里不能靠 orisys.__file__ 或 importlib.resources.files("orisys") 定位内置数据——它们会指向不存在的目录,静默返回「文件不存在」。请改用发行包定位:
from importlib.metadata import distribution
root = distribution("orisys").locate_file("orisys") # site-packages/orisys
build/common/verify/wheel.py 的内置资产校验就是这么做的,并且会显式断言该目录位于校验用 venv 内,避免被父环境里的旧 orisys 顶替。
5.2 --offline 会被父环境的脏安装污染
--offline 通过 venv 的 --system-site-packages 复用父环境的 numpy/numba 等依赖,这也顺带暴露了父环境里的 orisys。若父环境残留了半卸载的 editable 安装(典型症状是 pip 提示 Ignoring invalid distribution -risys,且 site-packages/orisys/__init__.py 是一个指向死路径的 shim),校验就会读到它。
处置:在构建环境里执行 pip uninstall -y orisys,或改用不带 --offline 的完全隔离 venv(会联网安装依赖)。
5.3 版本号只有一个地方,其他都是推导出来的
版本号的唯一来源是 src/orisys/__init__.py::__version__。以下全部由它推导,不需要手改:
| 产物 | 推导方式 |
|---|---|
pyproject 的 [project] version |
[tool.setuptools.dynamic] version = {attr = "orisys.__version__"} |
| wheel 文件名里的版本 | build/wheel/build.py 调 get_version() |
wheel METADATA 的 Version: |
生成的 setup.py 里写入同一个 version |
SDK/<version>/ 目录名 |
同上 |
importlib.metadata.version("orisys") |
从 wheel METADATA 读出 |
因此不要再往文档、示例、测试里写具体版本号:
- 用法示例写
SDK/<version>/...、--version <version>,不要写具体版本 - 文档里不要写「版本:x.y.z」;需要时让用户用
python -c "import orisys; print(orisys.__version__)"自己看 - 示例脚本的 docstring 不要再写
SDK version: x.y.z - 测试里需要「一个版本」时用明显是假的版本号(如
1.x.y这种不会与真实版本混淆的值),不要用当前版本 —— 否则每次发版都要改测试,而且一次全局替换就会悄悄改坏断言
release preflight 的 version-strings 步骤强制这条规则:扫描所有被追踪的文本文件,任何与当前版本不符的版本字面量都会导致失败(tests/ 与生成物除外;依赖约束如 numpy>=1.24.4 由正则排除)。
历史记录属于例外,需显式登记在 build/release/version.py::VERSION_STRING_ALLOWLIST 里——登记本身就是「该字面量故意不是包版本」的文档。不要用全局替换修改含历史版本的文件:本文件的发布记录表就曾被一次全局替换改错(0.50 typo tag 被挂到错误的版本上,0.5.1 那一行被覆盖丢失)。
5.4 wheel 里有什么、没有什么
打包内容由 pyproject.toml 的 [[tool.orisys.bundle.items]] 单点定义:
| 内容 | 是否进 wheel | 说明 |
|---|---|---|
config/*.json |
是 | orisys/configs/,运行时配置解析要用 |
sensors/** |
是 | orisys/sensors/,含 finger 的 npz/npy 资产,运行时与 GUI 都要用 |
examples/** |
是 | orisys/examples/,用户装完就能跑示例 |
docs/** |
否 | 只随 GitHub 仓库与 PyPI 项目页提供。docs/publishing.md 是内部流程文档;docs/pypi_readme.md 仅在构建时被读取作为 long description |
因为 docs 不进 wheel,改文档不会让已构建的 wheel 失效,不需要重新构建。(改 examples/、config/、sensors/ 会——它们确实在 wheel 里,wheel-inspection 的 bundle-fidelity 会报出来。)
如果以后需要离线 API 参考,加一个只包含 docs/api.md 的窄条目即可,不要整个 docs/ 都塞进去。
6. 缩短 manylinux 构建时间
python build/run.py manylinux 走 build/wheel/docker/run.ps1 → 容器内 entrypoint.sh,对 cp310/cp311/cp312 串行各做一次 Nuitka 编译 + 打 wheel + auditwheel repair + 隔离 venv 校验。
6.1 镜像里已经烘好的东西(所以每次构建不必再拉)
Dockerfile.manylinux 在每个 ABI 下预置了两类内容:
-
构建工具:
pip/setuptools/wheel/nuitka/ordered-set/zstandard/auditwheel。 因此entrypoint.sh默认不再在每个 ABI 上重跑pip install --upgrade pip setuptools wheel auditwheel—— 那是每次构建 3 次多余的 PyPI 往返,还会让容器里的工具版本与镜像不一致。需要用最新工具时显式开启:docker run ... -e ORISYS_UPDATE_BUILD_TOOLS=1 ... -
每个 ABI 一个 wheelhouse(
/opt/orisys-wheelhouse/<tag>),内含numpy/scipy/opencv-python/numba及其依赖闭包,外加pip/setuptools/wheel/auditwheel的 wheel。--target pypi-linux会强制校验:为每个 ABI 建一个一次性 venv,再pip install刚构建的 wheel 和它声明的运行期依赖。镜像故意不把运行期依赖装进解释器(用户是凭 wheel 元数据装的),所以以前每次构建都要现下约 200 MB × 3。entrypoint.sh现在用PIP_NO_INDEX=1+PIP_FIND_LINKS=<wheelhouse>把 pip 指向本地 wheelhouse:venv 仍然是隔离的(没有--system-site-packages),依赖解析与安装依然真实发生,只是不走网络。找不到 wheelhouse 时会打印警告并回退到联网,不会静默失败。镜像因此增大约 400–500 MB(一次性代价,换每次构建省三次下载)。
6.2 不要为了省事直接重建镜像
只改了源码(src/、config/、sensors/、examples/、pyproject.toml)时加 -SkipImageBuild:
.\build\wheel\docker\run.ps1 -SkipImageBuild
Dockerfile 的 RUN 层要装 3 份工具链并下载 3 个 wheelhouse,缓存未命中就是数分钟。改了 Dockerfile.manylinux 或需要更新 wheelhouse 时才重建镜像。
6.3 Nuitka 的排除列表必须完整
build/wheel/compile.py::NUITKA_NOFOLLOW_IMPORTS 列出所有不打进 wheel、运行期从用户环境解析的第三方包。漏一个意味着 Nuitka 会尝试把它跟进来:编译时间暴涨,而且 wheel 内容开始取决于构建机上恰好装了什么 —— torch、torchvision(src/orisys/tracker.py 的 RAFT 追踪器)和 pyvkfft(backend_availability.py)就是这样漏掉的,之前只是靠「构建机上没装」才没出事。
tests/test_nuitka_nofollow.py 会扫描 src/orisys 的全部第三方导入,任何不在排除列表里的都会让测试失败。新增可选依赖时同步更新列表。
6.4 想要并行?先读这一段
--jobs 的默认值是全部 CPU 数,单次编译的 C 阶段已经吃满所有核心。真正能靠并行省下的是 Nuitka 的单线程前端(分析/优化/代码生成)、scons 启动、auditwheel 与 venv 校验 —— 做法是 3 路并行 + 每个 build 限 --jobs=<核数/3>。
但现在不能直接并行:build/common/paths.py 里的工作目录是写死的共享路径
build/nuitka/compile
build/nuitka/wheel
build/nuitka/wheel/repaired
而 build/wheel/build.py 每次开头都 _reset_dir()(rmtree)这两个目录,auditwheel repair 又会再 rmtree 一次 repaired。仓库是挂载进容器的(-v ${RepoRoot}:/src),所以无论「一个容器 3 路并行」还是「3 个容器并行」,只要共享同一个挂载点,三个 build 就会互相清空暂存目录 —— 表现为随机失败或 wheel 内容错乱。
要并行必须先让工作目录带上 ABI 标签(如 build/nuitka/<tag>/compile)。输出路径不用动:SDK/<version>/、dist/repaired/ 的文件名本来就按 ABI 区分,下游 release preflight 不受影响。另外 3 个 Nuitka 并行很吃内存,先把 Docker Desktop 内存调到 16 GB 以上再试。