Files
PolyLoop/research-wiki/guides/releasing.md
T
iomgaa 8c63367d3a docs(guides): 按硕士生冷读改发布指南,并对齐 build/twine 已进 dev extra
冷读抓到三条确定性矛盾:registry 说「停在 1.0.5」而同一份文档又说 1.1.1/1.1.2 发出去了
(真实情况是版本号中间断了一截,缺的恰好是 1.0.6 与 1.1.0);「1.1.0 改回 1.0.4」与「版本
只能往前走」打架(那次发生在 1.0.4 发布之前,发布前版本号只是文件里的字符串);跑 Python
的命令带不带 conda 前缀两种形状并存(Makefile 的 target 内部已经包好了,裸敲就对)。

作为操作指南,「照着做会卡住」就是缺陷,补了八处:建仓与接 remote 的命令、tea 要不要装、
第四步合并与 push 的命令、token 那三个 export 只活在当前 shell、第八步解包看什么算通过、
README 里哪几行会随版本变、CHANGELOG 的日期形状、人类门批的是哪一次。

末节「这些坑为什么在这里」整节删掉——冷读是扫读跳过的,因为五条里四条正文已经讲过,而且
那一节的主语是「这份文档的九个步骤」不是发布这件事,正是 §6 禁止的自述。其中正文没有的
三句就地并进对应步骤。

另修一处它和代码的漂移:那一节写 build/twine 不在 dev extra、要手工装,而同一批改动正是
把它们钉了进去。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:25:03 -04:00

367 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 发布一个版本
> **更新触发点:第 2 档(同提交同改)**——`../README.md` 把常青文档的更新触发点分成三档,
> 第 2 档的意思是「改到相关的东西时,在同一个提交里把这份文档改对」。三种事情触发它:
> 发布用的地址、工具或凭据位置变了;步骤增删或换序;某次发布踩到新坑并因此加了一步。
>
> **当前状态:1.0.1 已经发布。** Gitea 仓库 `iomgaa/PolyLoop` 已建、remote 已接,`main` 与
> annotated tag `v1.0.1` 都推上去了;registry 上有 `polyloop-1.0.1.tar.gz` 与
> `polyloop-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`
否则凭据那一节和第九步都无从下手。
`build``twine` 已经在 `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 ci``make test` 在裸 shell 里直接敲就对,**再套一层 conda run 是错的**。
`python -m build``twine``pip download` 这类直接调 Python 的命令没有这层包装,要自己
写全那个前缀。前缀的两段缺一不可:conda 和 Python 各缓冲一层输出,只拆一层的话长跑命令
全程无输出,直到进程结束才一次性吐出(`CLAUDE.md` §4)。
### 凭据:token 在哪、怎么传
凭据是一个 Gitea access token,它在 `~/.config/tea/config.yml``logins` 段里,字段名是
`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.toml``project.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 build``dist/` 下产出两个文件,两个都要传:
- **sdist**`polyloop-X.Y.Z.tar.gz`source distribution)是源码包,里面是构建这个版本所需的
源文件加一份 `PKG-INFO` 元数据。装它的人本地跑一次构建。
- **wheel**`polyloop-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` 列出的文件里
十个模块的 `.py``polyloop/py.typed` 都在;`__version__` 是这一版而不是上一版;这次新增或
改动的公共名字 grep 得到(`unzip -p ... polyloop/<模块>.py | grep <名字>`)。理由是包和 git
之间没有任何机器约束——构建时工作区不干净、`dist/` 没清、传错了文件,都会让一个「下载成功」
的包里装着旧代码。
sdist 要单独取,加 `--no-binary :all:`。它里面那份 `PKG-INFO` 的正文就是包页面要渲染的
README,可以在这里先看一眼(1.0.1 那次是 6462 个字符):
```
tar -xzf polyloop-X.Y.Z.tar.gz -O polyloop-X.Y.Z/PKG-INFO | head -40
```
这一步单独占一个位置,而不是并进第七步说一句「传完了」,是因为 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 那一节为准,发新版时跟着它改(第一步)。