背景
NeuralBridge SDK 是一个带 Cython 扩展的 Python 包,支持 Python 3.11-3.13,目标平台是 Linux/macOS/Windows。v4.5.2 版本发布时,10 个构建 Job 全部通过,但 publish 阶段卡了整整 6 轮。
每轮都是一个不同的问题。这篇文章把 6 个坑全部拆开,顺便给出 CI 的正确配置终态。
坑 1:download-artifact@v4 的隐形 path 参数
症状:
InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'
根因: actions/download-artifact@v4 如果不指定 path: 参数,文件直接解压到工作目录根,不会自动放进 dist/ 目录。
修复:
- uses: actions/download-artifact@v4
with:
pattern: dist-*
path: dist # ← 必须加
merge-multiple: true
V3 到 V4 的 breaking change 文档里写了,但 CI 出错时谁会第一时间想到是这个问题?
教训: 每次用 download-artifact@v4,path: 参数必写。不管你多确信"这怎么可能错"。
坑 2:Cython 编译未触发(build isolation 陷阱)
症状: 生成的 wheel 文件是 neuralbridge_sdk-4.5.2-py3-none-any.whl,文件名带 none-any,大小只有 160KB(正常应该是 6-20MB)。上传到 PyPI 后 pip install 下来的是一堆 .py 文件,没有编译好的 .so/.pyd 扩展。
根因: python -m build 默认启用构建隔离(build isolation)。它在临时虚拟环境里只安装 build-system.requires 里声明的包。
之前的 pyproject.toml 是这样写的:
[build-system]
requires = ["setuptools>=64"] # ← 没有 Cython
build isolation 临时环境里只有 setuptools,没有 Cython。setup.py 里的 from Cython.Build import cythonize 导入失败,回退到了 .c 文件编译。但 .c 文件只存在于 sdist 里,wheel 构建目录里根本没有。
更坑的是,CI 里 pre-install Cython 也没用:
- run: pip install build cython>=3.0 # ← build isolation 看不到这些
- run: python -m build --wheel # ← 隔离环境只有 setuptools
修复:
[build-system]
requires = ["setuptools>=64", "cython>=3.0"] # ← Cython 必须在这里
同时 CI 简化成一行:
- run: pip install build
- run: python -m build --wheel # ← 隔离环境会自动装 Cython
核心原则: Cython(以及其他编译依赖)必须放在 build-system.requires 里。setup.py 里的 try: from Cython.Build import cythonize 回退逻辑是给 sdist 用户从 .c 编译用的,不是给 CI 用的。
坑 3:python -m build 参数名错误
在排查坑 2 的过程中,犯了一个低级错误。
症状:
python -m build: error: unrecognized arguments: --no-build-isolation
根因: 记混了参数名。
| 工具 | 正确参数 |
|---|---|
python -m build |
--no-isolation |
pip install |
--no-build-isolation |
修复: 删掉 --no-isolation,恢复默认 build isolation(配合坑 2 的 Cython fix)。
教训: 怀疑 build isolation 出问题时,先在 build-system.requires 加依赖,不要关 isolation。关了之后要手动管理所有构建依赖的环境。
坑 4:setuptools 版本与 PyPI 元数据兼容性
症状:
InvalidDistribution: Metadata is missing required fields: Name, Version.
根因: setuptools ≥ 75 生成的 PKG-INFO 使用 Metadata-Version 2.4+(PEP 639),引入了 License-File 等新字段。老版本的 pkginfo / twine 不认识这些字段,直接报"元数据格式错误"。
兼容性矩阵:
| setuptools | Metadata-Version | twine 5.x | twine 6.x |
|---|---|---|---|
| <75 | 2.2 / 2.3 | ✅ | ✅ |
| ≥75 | 2.4+ (PEP 639) | ❌ | ❌ |
修复:
requires = ["setuptools>=64,<75", "cython>=3.0"] # ← pin 上界
setuptools 会自动升级到最新 patch 版本,所以必须明确限制 <75。
教训: build-system.requires 里 setuptools 不能只设下界。上界也要 pin,否则新版本的元数据格式可能和 PyPI 上传工具打架。
坑 5:Linux wheel 平台标签被 PyPI 拒绝
症状:
400 Binary wheel ... has an unsupported platform tag 'linux_x86_64'.
根因: Ubuntu 24.04(glibc 2.39)上直接编译的 wheel,平台标签是 linux_x86_64。PyPI 只接受 manylinux* 标签,不认裸的 linux_ 前缀。
修复前:
- run: python -m build --wheel
# 生成:{name}-{ver}-cp311-cp311-linux_x86_64.whl ← PyPI 拒收
修复后:
- run: python -m build --wheel
- name: Repair wheel (Linux → manylinux tag)
if: runner.os == 'Linux'
run: |
pip install auditwheel
auditwheel repair dist/*.whl --plat manylinux_2_35_x86_64 -w dist/
rm -f dist/*linux_x86_64.whl
# 生成:{name}-{ver}-cp311-cp311-manylinux_2_35_x86_64.whl ← 上传成功
auditwheel repair 会把 wheel 依赖的外部共享库嵌入到 wheel 内部,保证目标系统兼容性,同时重写平台标签。
注意: 不同 Ubuntu 版本的 glibc 版本不同,选择 manylinux_2_35_x86_64 可以与 ubuntu-22.04 和 24.04 都兼容。
| 基础镜像 | glibc | 原生 tag | auditwheel 后 |
|---|---|---|---|
| ubuntu-22.04 | 2.35 | linux_x86_64 |
manylinux_2_35_x86_64 |
| ubuntu-24.04 | 2.39 | linux_x86_64 |
manylinux_2_39_x86_64 |
教训: Linux wheel 永远需要 auditwheel 修复才能上传 PyPI。不要试图用换 base image 绕过,PyPI 只收 manylinux。
坑 6:Git push HTTPS 443 被屏蔽
症状:
fatal: unable to access 'https://github.com/...': Connection was reset
根因: 中国网络环境下,github.com 的 HTTPS 443 端口不稳定(原因众所周知)。但 api.github.com(Git Data API)通常可以正常访问。
解决方案: 使用 GitHub Git Data API 替代 git push。
原理很简单——Git Data API 允许你通过 REST API 直接操作 Git 对象:
-
创建 blob(
POST /repos/{owner}/{repo}/git/blobs)— 上传文件内容 -
创建 tree(
POST /repos/{owner}/{repo}/git/trees)— 用base_tree引用上一个 commit 的 tree,只替换变更文件 -
创建 commit(
POST /repos/{owner}/{repo}/git/commits)— 指向新的 tree -
更新 ref(
PATCH /repos/{owner}/{repo}/git/refs/heads/main)— 把分支指针移到新 commit
核心代码片段:
import requests
GH = "https://api.github.com/repos/owner/repo"
# 1. 获取当前 HEAD commit
r = requests.get(f"{GH}/git/ref/heads/main", headers=headers)
head_sha = r.json()["object"]["sha"]
# 2. 获取当前 tree SHA
r = requests.get(f"{GH}/git/commits/{head_sha}", headers=headers)
base_tree_sha = r.json()["tree"]["sha"]
# 3. 创建 blob(文件内容)
r = requests.post(f"{GH}/git/blobs", headers=headers,
json={"content": content, "encoding": "utf-8"})
blob_sha = r.json()["sha"]
# 4. 创建 tree(基于 base_tree,只替换变更文件)
r = requests.post(f"{GH}/git/trees", headers=headers,
json={"base_tree": base_tree_sha, "tree": [
{"path": path, "mode": "100644", "type": "blob", "sha": blob_sha}
]})
new_tree_sha = r.json()["sha"]
# 5. 创建 commit
r = requests.post(f"{GH}/git/commits", headers=headers,
json={"message": msg, "tree": new_tree_sha, "parents": [head_sha]})
new_commit_sha = r.json()["sha"]
# 6. 更新分支 ref
requests.patch(f"{GH}/git/refs/heads/main", headers=headers,
json={"sha": new_commit_sha})
这个脚本使用白名单模式:只推送 pyproject.toml、src/、tests/ 等已在 ALLOWED 集合中的文件,自动跳过 .claude/、临时文件等。
教训: 网络环境不可靠时,优先用 Git Data API。api.github.com 比 github.com 更稳定。
CI 正确配置终态
经过 6 轮修复,这是最终工作的 CI 配置:
# pyproject.toml
[build-system]
requires = ["setuptools>=64,<75", "cython>=3.0"] # ← 两条铁律
# build workflow
jobs:
build-sdist:
steps:
- run: pip install build
- run: python -m build --sdist
build-wheels:
strategy:
matrix:
os: [ubuntu-22.04, macos-latest, windows-latest]
python: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- run: pip install build
- run: python -m build --wheel
- if: runner.os == 'Linux'
run: |
pip install auditwheel
auditwheel repair dist/*.whl --plat manylinux_2_35_x86_64 -w dist/
rm -f dist/*linux_x86_64.whl
- uses: actions/upload-artifact@v4
with:
path: dist/*.whl
publish:
needs: [build-sdist, build-wheels]
steps:
- uses: actions/download-artifact@v4
with:
pattern: dist-*
path: dist # ← 必加
merge-multiple: true
- run: |
pip install twine
twine upload --skip-existing dist/*
排障速查表
CI publish 失败时,按顺序检查:
-
dist/ 目录有文件吗? → 检查
download-artifact的path: dist -
wheel 文件名是
none-any吗? → 文件只有 160KB → Cython 没编译 → 检查build-system.requires有没有cython -
twine upload 报 400? → 看错误:
-
linux_x86_64→ 缺 auditwheel -
Metadata missing Name, Version→ setuptools 太新,pin<75 -
license-file→ 同上,pin setuptools
-
-
twine upload 报 403/401? → 检查
PYPI_API_TOKEN是否过期 - git push 连不上? → 用 Git Data API 替代
附:发布的 11 个文件
最终 v4.5.2 发布到 PyPI 的文件(3 个 OS × 3 个 Python 版本 = 9 个平台 wheel + 1 个纯 Python wheel + 1 个 sdist):
| 平台 | Python 3.11 | Python 3.12 | Python 3.13 |
|---|---|---|---|
| Linux (manylinux) | ✅ | ✅ | ✅ |
| macOS | ✅ | ✅ | ✅ |
| Windows | ✅ | ✅ | ✅ |
希望这篇文章能帮你在 CI 发布时少踩几个坑。如果你也遇到过类似的 CI 问题,欢迎在评论区分享你的经历。
Top comments (0)