# 发布流程(构建 → 校验 → 发布) 本文档说明如何构建 orisys 的 Nuitka 二进制 wheel、如何在发布前用一条命令做完整门禁,以及当前仍需人工执行的发布步骤。 > 历史说明:`README.md` 与 `build/wheel/docker/run.ps1` 早就引用了本文件,但它此前并不存在。本文件补齐该引用。 --- ## 0. 环境准备 推荐用 `py310` conda 环境执行构建与校验(它同时具备 pytest / twine / numba / Nuitka / open3d / PyQt5): ```powershell 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 ```powershell # 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//` — 交付用 wheel(git 忽略) - `dist/repaired/` — 上传候选(git 忽略) --- ## 2. 发布前门禁:`release preflight` ```powershell # --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//` 中 wheel 文件名里的版本 | | `version-strings` | 追踪的文本文件里没有与当前版本不符的版本字面量(见 §5.3) | | `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` | 每个 wheel **一个** venv,在里面依次做:导入冒烟 → 内置 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,供后续发布步骤复用 - `build/release/reports//preflight.txt` — 人工阅读 - `build/release/reports//junit.xml` — pytest 结果 (`build/release/reports/` 已加入 `.gitignore`。) ### 常用参数 | 参数 | 作用 | | --- | --- | | `--wheel-dir DIR` | 校验指定目录下的 wheel(默认 `SDK/`) | | `--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 做静态断言(+ 可选运行时校验),不需要版本上下文。适合体检历史产物: ```powershell python build/run.py release audit SDK\\orisys--cp312-cp312-win_amd64.whl python build/run.py release audit dist\repaired\orisys--cp310-cp310-win_amd64.whl --strict-bundle # 追加运行时校验(--assets 与 --runtime 同时给时共用一个 venv,只装一次依赖) python build/run.py release audit --assets --runtime --video data/sample/s1_480p.mp4 ``` 默认对「内置文件与当前源码树不一致」只告警(历史 wheel 与当前分支不一致是正常的);加 `--strict-bundle` 改成失败(刚构建完的 wheel 应当一致)。 旧的导入冒烟测试仍然可用,行为不变: ```powershell python build/run.py verify python build/run.py verify --contents # 静态断言 python build/run.py verify --assets --runtime # 资产 + 真实帧 python build/run.py verify --gui-dist --product ory_finger ``` --- ## 3. 发布(两条命令) 先决条件:`release preflight` PASS,**而且是在干净提交上跑的**。两个发布命令都只发布「preflight 认证过的东西」——它们会读取 `build/release/reports//preflight.json`,逐个核对 wheel 的 sha256,并拒绝使用带豁免项(`--skip-tests` / `--allow-dirty` / `--no-runtime` / `--force`)的报告。所以正确顺序是: ```powershell 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 ```powershell python build/run.py release github --version 0.5.2 # 预览 python build/run.py release github --version 0.5.2 --apply # 执行 ``` 执行顺序: 1. 认证书校验(上面那份报告 + 逐 wheel sha256) 2. `git fetch --tags`,若 `v0.5.2` 或 `0.5.2` 已存在则拒绝;`gh release view` 已存在也拒绝 3. 按 `tools/downstream_sync_manifest.json` 同步公开内容到 `../orisys-sdk-release`,并运行 manifest 里的 `checks` 4. 把 `SDK/0.5.2/*.whl` 放到 `orisys-sdk-release/release/0.5.2/`(该目录在 release 仓库里是 gitignore 的,只用于上传附件) 5. 提交 → 打 tag `v0.5.2` → push 分支与 tag 6. `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`(注意历史里那个 typo tag `0.50` 指向 `v0.5.1`)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。**如果 tag 已经 push 但 Release 上传失败(例如 `gh` 没配好),直接重跑同一条 `--apply` 命令即可续跑**:它会跳过创建 tag,只补做剩下的步骤;只有「GitHub Release 已存在」才拒绝。 ### 3.2 PyPI:TestPyPI 先行,确认后再上正式仓库 ```powershell 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`、上传前查重、上传后回读索引核对文件、以及把产物重新拉回来验证。 执行顺序: 1. 认证书校验(与 `release github` 同一份报告) 2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题) 3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的) 4. 上传 → **轮询**索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取 5. 未加 `--confirm-pypi` 时只发 TestPyPI,然后停在这里并打印后续命令 两个针对「索引不即时一致」的设计(TestPyPI 尤其明显,上传成功后 JSON API 可能滞后若干秒): - **上传后的确认会重试**(默认 6 次 × 5 秒)。确认不了也**不算失败**:twine 上传成功就说明产物已发布,命令会打印警告、给出项目页链接并以 0 退出,不会把一个成功的上传报成失败。真正的失败只有两种:twine 上传本身失败,或索引上的文件集与本地不一致。 - **重复运行是安全的**:若索引上已经存在该版本且文件集完全一致,该阶段会被视为「已完成」而跳过(`[ok] testpypi already serves orisys 0.5.2 with all 6 file(s)`),不会重传、也不报错。这让你在确认不明确时可以直接重跑。如果文件集**不一致**(索引上是另一次构建),才会失败并停下。 所以「上传后确认失败」不等于要重新上传——索引版本号不可覆盖,重跑只会做一次只读核对。 ### 3.3 凭证:`release auth` 与 `~/.pypirc` ```powershell 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 `;也可显式加 `--prompt-credentials`。dry run 只告警并继续展示计划,不会卡在输入上。 #### twine 直接读取的格式 默认路径 `~/.pypirc`(Windows 上即 `C:\Users\<你>\.pypirc`,由 `USERPROFILE` 展开)。**没有**环境变量可以改这个路径;要用别的文件就传 `--config-file`。 ```ini [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 是**独立服务、独立账号**,注册在 ,你的 PyPI 账号在那里不存在 - 正式 token 建议限定到 `orisys` 项目(项目已存在,可以建 project-scoped token) - 该文件必须在**任何 git 仓库之外**(放在用户目录里);token 一旦提交进仓库,即使后来删文件也还在历史里——那属于安全事故 - 临时替代方案:会话内设 `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=`,命令会识别这种情形 - 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`。于是: ```python >>> 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")` 定位内置数据**——它们会指向不存在的目录,静默返回「文件不存在」。请改用发行包定位: ```python 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//` 目录名 | 同上 | | `importlib.metadata.version("orisys")` | 从 wheel METADATA 读出 | 因此**不要**再往文档、示例、测试里写具体版本号: - 用法示例写 `SDK//...`、`--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 下预置了两类内容: 1. **构建工具**:`pip`/`setuptools`/`wheel`/`nuitka`/`ordered-set`/`zstandard`/`auditwheel`。 因此 `entrypoint.sh` 默认**不再**在每个 ABI 上重跑 `pip install --upgrade pip setuptools wheel auditwheel` —— 那是每次构建 3 次多余的 PyPI 往返,还会让容器里的工具版本与镜像不一致。需要用最新工具时显式开启: ```powershell docker run ... -e ORISYS_UPDATE_BUILD_TOOLS=1 ... ``` 2. **每个 ABI 一个 wheelhouse**(`/opt/orisys-wheelhouse/`),内含 `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=` 把 pip 指向本地 wheelhouse:**venv 仍然是隔离的**(没有 `--system-site-packages`),依赖解析与安装依然真实发生,只是不走网络。 找不到 wheelhouse 时会打印警告并回退到联网,不会静默失败。镜像因此增大约 400–500 MB(一次性代价,换每次构建省三次下载)。 ### 6.2 不要为了省事直接重建镜像 只改了源码(`src/`、`config/`、`sensors/`、`examples/`、`pyproject.toml`)时加 `-SkipImageBuild`: ```powershell .\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//compile`)。输出路径不用动:`SDK//`、`dist/repaired/` 的文件名本来就按 ABI 区分,下游 `release preflight` 不受影响。另外 3 个 Nuitka 并行很吃内存,先把 Docker Desktop 内存调到 16 GB 以上再试。