DEV Community

hhhfs9s7y9-code
hhhfs9s7y9-code

Posted on • Originally published at github.com

从 Cython 编译到 PyPI 发布 — 一个 Python SDK 的 CI 连环坑

背景

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/*'
Enter fullscreen mode Exit fullscreen mode

根因: actions/download-artifact@v4 如果不指定 path: 参数,文件直接解压到工作目录根,不会自动放进 dist/ 目录。

修复:

- uses: actions/download-artifact@v4
  with:
    pattern: dist-*
    path: dist          # ← 必须加
    merge-multiple: true
Enter fullscreen mode Exit fullscreen mode

V3 到 V4 的 breaking change 文档里写了,但 CI 出错时谁会第一时间想到是这个问题?

教训: 每次用 download-artifact@v4path: 参数必写。不管你多确信"这怎么可能错"。


坑 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

修复:

[build-system]
requires = ["setuptools>=64", "cython>=3.0"]  # ← Cython 必须在这里
Enter fullscreen mode Exit fullscreen mode

同时 CI 简化成一行:

- run: pip install build
- run: python -m build --wheel  # ← 隔离环境会自动装 Cython
Enter fullscreen mode Exit fullscreen mode

核心原则: 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
Enter fullscreen mode Exit fullscreen mode

根因: 记混了参数名。

工具 正确参数
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.
Enter fullscreen mode Exit fullscreen mode

根因: 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 上界
Enter fullscreen mode Exit fullscreen mode

setuptools 会自动升级到最新 patch 版本,所以必须明确限制 <75

教训: build-system.requires 里 setuptools 不能只设下界。上界也要 pin,否则新版本的元数据格式可能和 PyPI 上传工具打架。


坑 5:Linux wheel 平台标签被 PyPI 拒绝

症状:

400 Binary wheel ... has an unsupported platform tag 'linux_x86_64'.
Enter fullscreen mode Exit fullscreen mode

根因: 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 拒收
Enter fullscreen mode Exit fullscreen mode

修复后:

- 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  ← 上传成功
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

根因: 中国网络环境下,github.com 的 HTTPS 443 端口不稳定(原因众所周知)。但 api.github.com(Git Data API)通常可以正常访问。

解决方案: 使用 GitHub Git Data API 替代 git push

原理很简单——Git Data API 允许你通过 REST API 直接操作 Git 对象:

  1. 创建 blobPOST /repos/{owner}/{repo}/git/blobs)— 上传文件内容
  2. 创建 treePOST /repos/{owner}/{repo}/git/trees)— 用 base_tree 引用上一个 commit 的 tree,只替换变更文件
  3. 创建 commitPOST /repos/{owner}/{repo}/git/commits)— 指向新的 tree
  4. 更新 refPATCH /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})
Enter fullscreen mode Exit fullscreen mode

这个脚本使用白名单模式:只推送 pyproject.tomlsrc/tests/ 等已在 ALLOWED 集合中的文件,自动跳过 .claude/、临时文件等。

教训: 网络环境不可靠时,优先用 Git Data API。api.github.comgithub.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/*
Enter fullscreen mode Exit fullscreen mode

排障速查表

CI publish 失败时,按顺序检查:

  1. dist/ 目录有文件吗? → 检查 download-artifactpath: dist
  2. wheel 文件名是 none-any 吗? → 文件只有 160KB → Cython 没编译 → 检查 build-system.requires 有没有 cython
  3. twine upload 报 400? → 看错误:
    • linux_x86_64 → 缺 auditwheel
    • Metadata missing Name, Version → setuptools 太新,pin <75
    • license-file → 同上,pin setuptools
  4. twine upload 报 403/401? → 检查 PYPI_API_TOKEN 是否过期
  5. 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)