Files
iomgaa 72cfba5996 docs(guides): 补上验 sdist 那条命令的完整形状,以及漏掉 --no-build-isolation 的坑
第八步原来只说「sdist 要单独取,加 --no-binary :all:」,没给完整命令。照着上一条 pip
download 拼出来的那条**跑不通**,这是发 1.0.2 时实测撞上的。

原因是 --no-binary :all: 让 pip 取 sdist 之后会去构建它的元数据,而构建要先装
build-system.requires 里那个 setuptools——而 --index-url 已经把索引整个换成了这个 registry,
那儿只有 polyloop。报出来的是「Failed to build 'polyloop' when installing build
dependencies」,读起来像刚传上去的包坏了,实际上包好好的、坏的是命令的索引配置。这个失败
形态指向错误的方向,所以值得单独记一段。

--no-build-isolation 用当前 conda 环境里已经装着的 setuptools(dev 那组的 build 带着它),
不去索引找。跳过构建隔离不影响这一步的判据:这里验的是 registry 上那份 sdist 的内容,不是
「下游能不能从源码构建它」——真要验后者,索引那一项得写成 --extra-index-url。

补进去的命令逐字实测过,PKG-INFO 9566 字符,与文档里写的数字一致。
2026-08-27 05:44:54 -04:00

21 KiB
Raw Permalink Blame History

发布一个版本

更新触发点:第 2 档(同提交同改)——../README.md 把常青文档的更新触发点分成三档, 第 2 档的意思是「改到相关的东西时,在同一个提交里把这份文档改对」。三种事情触发它: 发布用的地址、工具或凭据位置变了;步骤增删或换序;某次发布踩到新坑并因此加了一步。

当前状态:1.0.1 已经发布。 Gitea 仓库 iomgaa/PolyLoop 已建、remote 已接,main 与 annotated tag v1.0.1 都推上去了;registry 上有 polyloop-1.0.1.tar.gzpolyloop-1.0.1-py3-none-any.whl,包页面渲染出了 README 正文,Release 也建好了。 唯一还欠着的是包页面上的 Link to a repository,那一步只能在网页上点(第九步)。

PolyGateway 的 registry 上,版本号是断的:0.1.0, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.0.5, 1.1.1, 1.1.2——中间缺了 1.0.6 和 1.1.0。这两个版本,版本号改了、CHANGELOG 写了、代码合进 main 了,就是从来没有上传过。当年发现之后的补救只是把流程写进文档,没有人回头把包补传上去, 所以到今天那个缺口还在。

这个缺口现在正在伤人:dissect 的 requirements 钉着 polygateway>=1.0.6,<1.1,而这个区间在 registry 上一个文件都没有。下游装不到任何修复,而且没有任何一处会报错——本地测试全绿, git 历史干干净净,只有真去 registry 上看的人才发现那里什么都没有。

所以发布这件事的判据不是「本地步骤都跑通了」,而是下游视角能看见的产物:registry 上有那个 版本的文件、包页面的正文不是空白、仓库的 Releases 页有对应条目、装下来解开之后新代码真的在 里面。CLAUDE.md §1.10 把「发布」定义成这一整串而不是版本号 bump,理由就是上面这段。


一次性准备

这些事只做一次。它们没做全的表现都是发布走到一半才卡住,而那时候 tag 可能已经打出去了。

为什么发到自建 Gitea,不发公网 PyPI

这是一个实验室内部共用的库,用它的是 dissect、GovDoc-SaaS 和 CHSAnalyzer,没有外部用户。 公网 PyPI 上占一个名字要跟着负维护责任——那个名字一旦发出去就收不回来,而且实验室还有另外 几个库要一起管,散在两个地方管不动。自建 registry 把这几个库收在同一个 owner 下面,权限和 清理都在自己手里。

包本身是公开的、匿名可装,这一点和「不上公网」不矛盾:不上公网是为了少一份对外承诺, 公开可装是为了让下游的 CI 不必配任何凭据就能装上——凭据只有上传的人需要。

建 Gitea 仓库并接上 remote

发布目标是实验室自建的 Gitea 实例 https://gitea.iomgaa.online。仓库这么建:

tea repos create --name PolyLoop --description "实验室共用的 Agent 执行内核"
git remote add origin git@gitea.iomgaa.online:iomgaa/PolyLoop.git

origin 已经存在的话(比如仓库是从别处克隆来的),git remote add 会报 error: remote origin already exists,这时候先 git remote -v 看一眼它指向哪里,确认要改就用 git remote set-url origin <地址>

仓库和包是两件事:仓库放代码、tag 和 Releases,包放在同一个 Gitea 实例的 PyPI registry 上。 这个 registry 是 owner 级的,不是仓库级的——它挂在用户 iomgaa 名下,iomgaa 名下所有 项目的包都堆在同一个索引里。两个地址:

用途 地址
上传 https://gitea.iomgaa.online/api/packages/iomgaa/pypi
索引(下游装包、验证用) https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/

owner 级的直接后果是:包传上去之后不会自动和任何仓库产生关联,要手动挂(第九步)。

三个工具:tea、build、twine

tea 是 Gitea 的官方命令行。这台机器上 tea 0.15.0 已经装好,而且已经 tea login 过这个 实例,所以下面要用到的那个凭据文件是现成的。换一台机器的话,先装 tea 并 tea login 否则凭据那一节和第九步都无从下手。

buildtwine 已经在 pyproject.toml 的 dev extra 里,所以装过开发环境就有:

make install

「extra」是 Python 打包里的可选依赖组,pip install -e ".[dev]" 装的就是 dev 这一组, make install 跑的正是它。把这两个放进 extra 是刻意的PolyGateway 那边它们不在任何 extra 里,于是它的发布流程必须多写一条「记得先装这两个」,而那种一年用几次的准备步骤迟早 有人漏,漏了的表现是发布走到第六步才报 No module named build。代价是每个协作者的环境 多两个包,换来发布这条路上少一个人工前提。

上传工具是 twine,不是别的。 uv publish 这条路不走,因为本仓库的环境是 conda, 多引一个包管理器会让「现在装的到底是哪份依赖」多一个答案。tea 也传不了包:tea 0.15.0 根本没有 packages 子命令,它在整套流程里只用来建 Release。

命令的两种形状

Makefile 里每个 target 内部已经包好了 PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop,所以 make cimake test 在裸 shell 里直接敲就对,再套一层 conda run 是错的

python -m buildtwinepip download 这类直接调 Python 的命令没有这层包装,要自己 写全那个前缀。前缀的两段缺一不可:conda 和 Python 各缓冲一层输出,只拆一层的话长跑命令 全程无输出,直到进程结束才一次性吐出(CLAUDE.md §4)。

凭据:token 在哪、怎么传

凭据是一个 Gitea access token,它在 ~/.config/tea/config.ymllogins 段里,字段名是 token——那是 tea login 时存下来的。它不在 ~/.pypirc:那个文件里只有公网 PyPI 的段, 照着 .pypirc 找会一无所获。手上没有 token 的人在 Gitea 的用户设置里新签一个,勾上 package 的读写权限,上传要的就是这一项。

token 只经环境变量传给 twine,不写进命令行,也不写进任何文件:命令行会进 shell 历史,也会 出现在同一台机器上任何人的 ps 输出里,而这台机器是和别人共用的。具体的三个 export 在第七步,不在这里——read -rs 读进来的值只活在当前那个 shell 里,换一个终端窗口、 或者中间 exit 过一次,就得重设一遍。

让包元数据齐全,特别是 readme

pyproject.toml[project] 里必须有 readme = "README.md"。缺了它,包能构建、能通过 twine check、能上传成功、能被 pip download 下下来,而 registry 的包页面正文是一片空白。 PolyGateway 的 1.1.2 就是这样发出去的:三步全绿,页面什么都没有。这是唯一一个三道机器检查 全绿还能出错的坑,所以第九步必须用眼睛看一次页面。

twine check 对这种情况只给警告,不给非零退出码,所以它拦不住。发布前直接看一眼:

grep -n '^readme' pyproject.toml

没有输出就是缺了,补上再走后面的步骤。


每次发布的九步

前两步(README 与 CHANGELOG)互相依赖,一起做:改 README 要用到版本号,而版本号由这一版 改了什么决定,那是 CHANGELOG 那一步的事。除此之外顺序是死的,每一步都压着后面某一步的 前提——理由写在各步自己那里。

X.Y.Z 代表这次要发的版本号,vX.Y.Z 是它对应的 tag。前三步在一个分支上做(例如 release/vX.Y.Z),第四步才合进 main

1. 改 README

README.md 的「安装」一节里,安装命令带着版本约束(现在是 polyloop==1.0.* 这个形状, gateway extra 那条命令里还有一份)。发新版时这几行要跟着改——那是 README 里唯一会随 版本失效的东西。

这一步排在最前面,是因为 python -m build 会把当时的 README 整个固化进 sdist 与 wheel 的元 数据里,registry 包页面显示的正文就是那一份。发布之后再改仓库里的 README,包页面纹丝不动—— 要改只能重发一个版本。PolyGateway 的 1.1.1 就是带着一份过期 README 发出去的。

版本约束那一行漏改的后果是:下游照着 README 装,会被锁在旧版本上,而他不会怀疑一份刚发布 的文档。

2. 把 CHANGELOG 定版

CHANGELOG.md 里那个「未发布」段落改成这一版的标题,形状是 ## X.Y.ZYYYY-MM-DD 日期就是发布当天,例如 ## 1.0.12026-08-11。改完在它上面留一个空的「未发布」段落, 给下一版接着攒。

这一步排在改版本号之前,是因为它才是决定版本号该是多少的地方:这一版改了什么,决定了 它是 patch、minor 还是 major。倒过来做的话,版本号是先拍出来的,CHANGELOG 只是去凑它。

3. 两处版本号同步

版本号写在两处,必须一致:

  • pyproject.tomlproject.version
  • src/polyloop/__init__.py__version__

双写是刻意的:运行时读不到构建元数据(未安装的源码树里 importlib.metadata 查不到这个包), 而下游报 bug 时第一件事就是问版本号。代价就是两处会漂移,所以 tests/unit/test_package.py 有一条断言钉住它,PolyGateway 那边同一条测试逮住过两次漏改。 改完立刻跑一次:

make test

在这一步就跑,而不是等第四步那次 make ci:这时候改动还没合进 main,改起来不牵扯任何 别人的东西。漂移的后果不会当场出现——下游报 bug 时说的版本号和实际装的不是同一个, 而这种错查起来要绕很远。

版本号定高了想往回改,要动三个文件:两处版本号加 CHANGELOG。PolyGateway 有一次把 1.1.0 改回 1.0.4,就是这三处一起改的。那次是在 1.0.4 还没发布之前——发布前的版本号只是文件里 的三个字符串,随便改;一旦第七步传上去,那个版本号就永久占住了,只能往前发新的 (第七步那个 409)。

4. 合并进 main、push,并在 main 上重跑全套件

git checkout main
git merge --no-ff release/vX.Y.Z
git push origin main

--no-ff 让这次发布在历史上留下一个可辨认的合并点。前三步本来就在 main 上做的话,跳过 merge 直接 push。

push 是外部可见动作,按 CLAUDE.md §2 要先得到批准。 批准的对象是「发这个版本」这件事 整体,不是每一条 git 命令——批过之后第五步那次 tag push 不用再问一遍。

推完在 main 上跑一次完整检查:

make ci

在 main 上重跑而不是信任分支上那次结果,是因为要发布的是 main 这个 commit 的内容,而它是 合并的产物——分支上绿不代表合完还绿。

这条命令超过一分钟,按 CLAUDE.md §4 放进 tmux 跑,并且末尾不许接管道| tail 会让退出 码变成管道最后一节的,于是失败的测试跑报成 exit 0,看起来一切正常。

5. 打 annotated tag 并 push

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

annotated,不是轻量 tag,挂在 main 上刚刚验过的那个 commit 上。 这条写死是因为 PolyGateway 那边没写死:它的 tag 有的挂在 merge commit、有的挂在某个 feat commit,其中 v1.0.0 还是个轻量 tag。轻量 tag 只是一个指向 commit 的指针,没有作者、日期和消息, git describe 默认也不认它,于是「这个版本是谁在什么时候发的」这个问题在那一版上没有答案。

tag 打在构建之前,是为了让 registry 上的那个包一定对应得上一个 git commit。反过来(先传包 后打 tag)的失败形态是漏打:PolyGateway 的 1.0.1 与 1.0.2 有 CHANGELOG、registry 上也有包, 但至今没有 tag,没人说得清那两个包是从哪个 commit 构建出来的。

tag 是 git 里的东西,Release 是 Gitea 里的东西,打了前者不会长出后者,所以还有第九步。

6. 构建,并 twine check

rm -rf dist/
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop python -m build
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop twine check dist/*

python -m builddist/ 下产出两个文件,两个都要传:

  • sdistpolyloop-X.Y.Z.tar.gzsource distribution)是源码包,里面是构建这个版本所需的 源文件加一份 PKG-INFO 元数据。装它的人本地跑一次构建。
  • wheelpolyloop-X.Y.Z-py3-none-any.whl,二进制分发包)是装好即用的形态,pip 优先取它, 因为不用在下游机器上跑构建。py3-none-any 表示它对 Python 3 通用、不挑平台。

一个包传两份,是为了让「pip 直接装」和「有人要从源码构建、或者在 pip 取不到 wheel 的环境里 装」两种情况都能满足。

先清 dist/,因为第七步是 twine upload dist/*:留着上一个版本的产物,那一批会被一起重传, 而 registry 会用 409 拒绝任何一个已经存在的版本号,于是整条上传命令失败。

twine check 验的是元数据能不能被 registry 渲染。它只警告不拦readme 缺失就是警告的 一种),所以它绿不代表包页面正常,那件事要等第九步用眼睛确认。

7. 上传到 registry

三个环境变量先摆好。每开一个新 shell 都要重设 TWINE_PASSWORD——read -rs 的值只活在 当前这个 shell 里,换个窗口就没了,而它没设的时候 twine 会去交互式地问,或者直接报认证失败:

export TWINE_USERNAME=iomgaa
read -rs -p 'Gitea token: ' TWINE_PASSWORD && export TWINE_PASSWORD
export TWINE_REPOSITORY_URL=https://gitea.iomgaa.online/api/packages/iomgaa/pypi

token 从哪来、为什么必须走环境变量,见准备阶段那一节。然后传:

NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \
    twine upload dist/*

NO_PROXY 不能省。这台机器设了 http_proxy / https_proxy,指向一个到不了外面的本地代理, 而 Gitea 在内网——不排除代理的话这一步会卡住或直接连接失败,且报出来的错和凭据错误长得很像。 所有访问 gitea.iomgaa.online 的命令都要带它,第八、第九步同样。

同一个版本号重传会被 registry 用 409 拒绝,而且传上去的东西改不了。 发现内容有问题的唯一 出路是 bump 一个新版本号重走一遍九步。PolyGateway 有一次是先在 Gitea 侧手工把包删掉才重传的, 那是在删自己刚传的东西且确认没有下游装过的前提下——一旦有人装过,删包就等于把别人的构建打断, 那时候只能往前发新版。

8. 验证已发布

轻量的一条,纯 GET,看某个版本在不在:

NO_PROXY=gitea.iomgaa.online curl -s \
    https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/polyloop/

它返回一页链接,列出这个包在 registry 上的所有文件名。刚传的那个版本的 sdist 和 wheel 都要在 里面。这条命令不装任何东西、不写任何状态,随时可以重跑。

然后走一遍下游真正会走的路pip download 默认只取 wheel,所以这条命令拿到的是 .whl

cd "$(mktemp -d)"
NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \
    pip download --no-deps \
    --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
    "polyloop==X.Y.Z"
unzip -l polyloop-X.Y.Z-py3-none-any.whl
unzip -p polyloop-X.Y.Z-py3-none-any.whl polyloop/__init__.py | grep __version__

--no-deps 是因为这里验的是 polyloop 这一个包能不能取到,不是它的依赖树装不装得起来。

「下载成功」不算数,必须解开看里面的东西。 通过的判据是三条:unzip -l 列出的文件里 十个模块的 .pypolyloop/py.typed 都在;__version__ 是这一版而不是上一版;这次新增或 改动的公共名字 grep 得到(unzip -p ... polyloop/<模块>.py | grep <名字>)。理由是包和 git 之间没有任何机器约束——构建时工作区不干净、dist/ 没清、传错了文件,都会让一个「下载成功」 的包里装着旧代码。

sdist 要单独取。它里面那份 PKG-INFO 的正文就是包页面要渲染的 README,可以在这里先看一眼 (1.0.1 那次是 6462 个字符,1.0.2 是 9566 个):

NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \
    pip download --no-deps --no-binary :all: --no-build-isolation \
    --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
    "polyloop==X.Y.Z"
tar -xzf polyloop-X.Y.Z.tar.gz -O polyloop-X.Y.Z/PKG-INFO | head -40

--no-build-isolation 不能省,而漏了它的失败形态会指向错误的方向。 --no-binary :all: 让 pip 取 sdist 而不是 wheel,而 pip 拿到 sdist 之后会去构建它的元数据,构建要先装 pyproject.tomlbuild-system.requires 那个 setuptools——--index-url 已经把索引整个换成 了这个 registry,那儿只有 polyloop,没有 setuptools。报出来的是:

ERROR: Failed to build 'polyloop' when installing build dependencies for polyloop

这句话读起来像刚传上去的那个包坏了,而实际上包好好的,坏的是这条命令的索引配置。 --no-build-isolation 让 pip 用当前 conda 环境里已经装着的 setuptoolsdev 那组的 build 带着它),不去索引找。

跳过构建隔离不影响这一步的判据:这里验的是 registry 上那份 sdist 的内容对不对,不是 「下游能不能从源码把它构建出来」。真要验后者,索引那一项得写成 --extra-index-url,让 PyPI 仍然在链上。

这一步单独占一个位置,而不是并进第七步说一句「传完了」,是因为 PolyGateway 那两个缺失版本 的形态就是「本地看起来全做完了」——只有一条真的去 registry 取一次的命令能区分开。

9. 建 Release,并以下游视角核对包页面

先把 CHANGELOG 里这一版那一段抽出来存成一个文件(例如 /tmp/release-notes.md),它就是 Release 的正文。然后:

NO_PROXY=gitea.iomgaa.online tea releases create --repo iomgaa/PolyLoop \
    --tag vX.Y.Z --title "vX.Y.Z" --note-file /tmp/release-notes.md

也可以在网页上建,内容一样。Release 挂在第五步那个 tag 上。

只打 tag 不建 ReleaseReleases 页会长期为空。 PolyGateway 的 tag 里只有最后一个有对应的 Release,于是那个仓库的 Releases 页看起来像一个从没发布过的项目——而 Releases 页是不熟悉这个 项目的人第一个会去看的地方。

然后打开包页面,做两件事:

  • 手动点 Link to a repository,把包挂到 iomgaa/PolyLoop 上。 包不会自动挂——PyPI 元数据 里没有仓库这个字段,而这个 registry 是 owner 级的,它没有办法猜出这个包属于哪个仓库。 这个实例的 link API 返 404,脚本化不了,整个流程里只有这一件事必须用鼠标点。
  • 看一眼正文不是空白。空白说明 readme 那件事漏了,见准备阶段最后一节。

最后逐一打开这三样,全部对得上才算发完:registry 包页面(正文 + 仓库链接)、仓库的 Releases 页、第八步解出来的文件。前八步全绿而发布其实没成,正是开头 PolyGateway 那个缺口的形态—— 判据必须是外部可见的结果,不是本地跑通了几条命令。


下游怎么装

装法的权威是 README.md 的「安装」一节,那里有当前该用的完整命令和版本约束。发布的人要 知道的是它为什么长那样,以及下游最容易在哪两处摔跤。

索引地址必须由下游自己带。 polyloop 不在公网 PyPI 上,而 pip 默认只认公网 PyPI;这台 开发机上也没有任何全局 pip 配置兜底(pip config list 是空的,三个可能位置的 pip.conf 都不存在)。全局配置本来就不该指望:它是每台机器各自的状态,下游的开发机和 CI 容器多半 干脆没有,所以「自己带索引地址」是普遍成立的要求,不是这台机器的特殊情况。

--extra-index-url,不是 --index-url 后者会把默认源整个换掉,于是除了 polyloop 之外的依赖都装不到。写进 requirements.txt 的话,--extra-index-url 那一行必须排在 polyloop 那一行之前——pip 是按顺序读这个文件的,写在后面等于对上面那行不生效。

代理是本机特有的那一条。 NO_PROXY=gitea.iomgaa.online 只在设了指向内网代理的机器上 需要(这台开发机就是),别的机器不必带。带上也不会有副作用,所以 README 里直接写了它。

版本约束一定要带上界。 破坏性变更会发生在 major 上(CLAUDE.md §1.3),没有上界的 polyloop 一行会在某天自动装上一个签名已经变了的版本,而下游那边表现成一次莫名其妙的 ImportError。上界具体钉在哪一档以 README 那一节为准,发新版时跟着它改(第一步)。