Files
PolyLoop/research-wiki/guides/releasing.md
T
iomgaa 855d9c376a docs(guides): 写发布指南,guides/ 的第一份
CLAUDE.md §1.10 一直指着一份「还没写」的文档,现在它在了。九个步骤逐条写清为什么是这一步、
为什么在这个位置,末节按「哪一步防哪个坑」把 PolyGateway 踩过的十来个坑映射回步骤,读者
不用自己对应。

素材来自对 PolyGateway 的调研并逐条核实过:registry 是 owner 级、工具只能是 twine
(tea 0.15.0 没有 packages 子命令)、token 在 tea 的配置里不在 .pypirc、这台机器的代理
到不了外面所以上传与验证都要绕开、同版本重传会被 409 拒绝、缺 readme 会让包页面空白而
twine check 只警告不拦。

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

324 lines
17 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 档(同提交同改)。** 三种事情发生时,本文在同一个提交里改:发布用的
> 地址、工具或凭据位置变了;步骤增删或换序;某次发布踩到新坑并因此加了一步。
>
> **当前状态:本仓库一次都还没发布过。** 一个 tag 都没有,registry 上没有 polyloop
> Gitea 上也还没有对应的仓库,所以下面那套一次性准备一件都还没做。步骤本身不是凭空设计的,
> 是从 PolyGateway 已经发生过的几次发布(和几次没发出去)里抄回来的。
PolyGateway 的 registry 长期停在 1.0.5,而 1.0.6 和 1.1.0 这两个版本,版本号改了、
`CHANGELOG` 写了、代码合进 main 了,就是从来没有上传过。到今天它们仍然不在 registry 上——
当年发现之后的补救只是把流程写进文档,没有人回头把包补传上去。
这件事现在正在伤人:dissect 的 requirements 钉着 `polygateway>=1.0.6,<1.1`,而这个区间在
registry 上一个文件都没有。下游装不到任何修复,而且**没有任何一处会报错**——本地测试全绿,
git 历史干干净净,只有真去 registry 上看的人才发现那里什么都没有。
所以发布这件事的判据不是「本地步骤都跑通了」,而是**下游视角能看见的产物**:registry 上有那个
版本的文件、包页面的正文不是空白、仓库的 Releases 页有对应条目、装下来解开之后新代码真的在
里面。`CLAUDE.md` §1.10 把「发布」定义成这一整串而不是版本号 bump,理由就是上面这段。
---
## 一次性准备
这四件事只做一次,做完之后每次发布不用再管。它们没做全的表现都是发布走到一半才卡住,
而那时候 tag 可能已经打出去了。
### 建 Gitea 仓库并接上 remote
发布目标是实验室自建的 Gitea 实例 `https://gitea.iomgaa.online`。在它上面建
`iomgaa/PolyLoop`,然后在本仓库接上:
```
git remote add origin https://gitea.iomgaa.online/iomgaa/PolyLoop.git
```
仓库和包是两件事:仓库放代码、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 级的直接后果是:包传上去之后不会自动和任何仓库产生关联,要手动挂(第九步)。
包是公开的,匿名就能装,所以下游不需要任何凭据。凭据只有上传的人需要。
### 装 build 与 twine
```
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop pip install build twine
```
这两个工具不在 `pyproject.toml` 的 dev extra 里,要手工装进 conda 环境。dev extra 里的东西
每个协作者都得装,而这两个只有做发布的那个人用得上,且它们不参与任何测试结果——不进 extra
就不必为它们钉版本、也不必让每个人的环境跟着变大。
**工具是 `twine`,不是别的。** `uv publish` 这条路不走,因为本仓库的环境是 conda,多引一个
包管理器会让「现在装的到底是哪份依赖」多一个答案。`tea`Gitea 的官方命令行)也不行:
`tea 0.15.0` 根本没有 packages 子命令,它能做的只是建 Release,那是第九步的事。
### 把 token 准备好,并且只经环境变量传
凭据是一个 Gitea access token,它在 `~/.config/tea/config.yml``logins` 段里,字段名是
`token`——那是 tea 登录这个实例时存下来的。**它不在 `~/.pypirc`**:那个文件里只有公网 PyPI
的段,照着 `.pypirc` 找会一无所获。手上没有 token 的人在 Gitea 的用户设置里新签一个,
勾上 package 的读写权限,上传要的就是这一项。
上传前把三个变量摆好:
```
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 走 `TWINE_PASSWORD` 而不是写在命令行里,因为命令行会进 shell 历史,也会出现在同一台
机器上任何人的 `ps` 输出里——**这台机器是和别人共用的**。`read -rs` 不回显、不落盘、不进历史,
输完之后 token 只活在当前这个 shell 里。
### 让包元数据齐全,特别是 `readme`
`pyproject.toml``[project]` 里必须有 `readme = "README.md"`。缺了它,包能构建、能通过
`twine check`、能上传成功、能被 `pip download` 下下来,**而 registry 的包页面正文是一片空白**。
PolyGateway 的 1.1.2 就是这样发出去的:三步全绿,页面什么都没有。
`twine check` 对这种情况只给警告,不给非零退出码,所以它拦不住。发布前直接看一眼:
```
grep -n '^readme' pyproject.toml
```
没有输出就是缺了,补上再走后面的步骤。
---
## 每次发布的九步
顺序不能改:每一步都在防一件具体的事,换序会让防的那件事漏过去。
`X.Y.Z` 代表这次要发的版本号,`vX.Y.Z` 是它对应的 tag。
### 1. 改 README
先把 `README.md` 里所有会随版本变的东西改对,尤其是**安装命令里的版本约束**。
这一步排在最前面,是因为 `python -m build` 会把当时的 README 整个固化进 sdist 与 wheel 的元
数据里,registry 包页面显示的正文就是那一份。发布之后再改仓库里的 README,包页面纹丝不动——
要改只能重发一个版本。PolyGateway 的 1.1.1 就是带着一份过期 README 发出去的。
最容易漏的就是版本约束那一行:漏改的话,下游照着 README 装,会被锁在旧版本上,而他不会
怀疑一份刚发布的文档。
改 README 要用到版本号,而版本号是下一步定出来的,所以这两步实际上一起做——先扫一眼
CHANGELOG 的「未发布」段落把 X.Y.Z 定下来,再回头改 README。**硬约束只有一条:两步都必须
在第六步构建之前完成**,构建那一刻仓库里是什么样,包里就固化成什么样。
### 2. 把 CHANGELOG 定版
`CHANGELOG.md` 里那个「未发布」段落改成 `X.Y.Z` 和今天的日期。
这一步排在改版本号之前,是因为**它才是决定版本号该是多少的地方**:这一版改了什么,决定了
它是 patch、minor 还是 major。倒过来做的话,版本号是先拍出来的,CHANGELOG 只是去凑它。
### 3. 两处版本号同步
版本号写在两处,必须一致:
- `pyproject.toml``project.version`
- `src/polyloop/__init__.py``__version__`
双写是刻意的(理由在 `__init__.py` 里那条注释),代价就是会漂移。`tests/unit/test_package.py`
有一条断言钉住它,PolyGateway 那边同一条测试逮住过两次漏改。改完立刻跑一次:
```
make test
```
漂移的后果不会当场出现:下游报 bug 时说的版本号和实际装的不是同一个,而这种错查起来要绕很远。
**版本号定高了想往回改,要动三个文件**——两处版本号加 CHANGELOG。PolyGateway 有一次把 1.1.0
改回 1.0.4,就是这三处一起改的。往回改只在 tag 还没打出去之前有效,打了 tag 之后见第五步。
### 4. 合并进 main、push,并在 main 上重跑全套件
前三步的改动合进 main 并 push(push 是外部可见动作,按 `CLAUDE.md` §2 要先得到批准)。
然后**在 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 上刚刚验过的那个 release 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 构建出来的。
### 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/*
```
先清 `dist/`,因为第七步是 `twine upload dist/*`:留着上一个版本的产物,那一批会被一起重传,
而重传必定撞第七步那个 409。
`python -m build` 产出两个文件,sdist`.tar.gz`)和 wheel`.whl`),两个都要传。
`twine check` 验的是元数据能不能被 registry 渲染。它**只警告不拦**(`readme` 缺失就是警告的
一种),所以它绿不代表包页面正常,那件事要等第九步用眼睛确认。
### 7. 上传到 registry
```
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` 的命令都要带它,第八步同样。
上传地址来自准备阶段设好的 `TWINE_REPOSITORY_URL`,凭据来自 `TWINE_USERNAME` / `TWINE_PASSWORD`
**同一个版本号重传会被 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 都要在
里面。这条命令不装任何东西、不写任何状态,随时可以重跑。
**最终的**,走下游真正会走的那条路:
```
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"
tar -tzf polyloop-X.Y.Z.tar.gz
```
`--no-deps` 是因为这里验的是 polyloop 这一个包能不能取到,不是它的依赖树装不装得起来。
**「下载成功」不算数,必须解开看里面的东西**。上一段那个 `tar -tzf` 列出 sdist 的内容;要确认
这次改的代码真的在里面,就把对应文件解出来读一眼。原因是包和 git 之间没有任何机器约束:
构建时工作区不干净、`dist/` 没清、传错了文件,都会让一个「下载成功」的包里装着旧代码。
### 9. 建 Release,并以下游视角核对包页面
在网页上建,或者用 `tea` 的 releases 子命令(这是 `tea 0.15.0` 在整套流程里唯一派得上用场的
地方,具体参数 `tea releases create --help` 现查)。Release 挂在第五步那个 tag 上,正文就是
这个版本的 CHANGELOG 段落。
**只打 tag 不建 ReleaseReleases 页会长期为空。** PolyGateway 的 tag 里只有最后一个有对应的
Release,于是那个仓库的 Releases 页看起来像一个从没发布过的项目——而 Releases 页是不熟悉这个
项目的人第一个会去看的地方。
然后打开包页面,做两件事:
- **手动点 Link to a repository,把包挂到 `iomgaa/PolyLoop` 上。** 包不会自动挂——PyPI 元数据
里没有仓库这个字段,而这个 registry 是 owner 级的,它没有办法猜出这个包属于哪个仓库。
**这个实例的 link API 返 404,脚本化不了**,只能在网页上点。
- **看一眼正文不是空白**。空白说明 `readme` 那件事又漏了,见准备阶段最后一节。
最后逐一打开这三样,全部对得上才算发完:registry 包页面(正文 + 仓库链接)、仓库的 Releases
页、第八步解包出来的文件。前八步全绿而发布其实没成,正是开头 PolyGateway 那两个版本的形态——
**判据必须是外部可见的结果,不是本地跑通了几条命令。**
---
## 下游怎么装
这台机器上没有任何全局 pip 配置(`pip config list` 是空的,三个可能位置的 `pip.conf` 都不存在),
所以 pip 默认只认公网 PyPI,而 polyloop 不在那上面。**下游必须自己带索引地址。**
写在 `requirements.txt` 里的话,加在 polyloop 那一行**之前**
```
--extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/
polyloop>=X.Y.Z,<2
```
或者写在安装命令里:
```
NO_PROXY=gitea.iomgaa.online pip install \
--extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polyloop>=X.Y.Z,<2"
```
上界钉在下一个 major:破坏性变更只发生在 major 上(`CLAUDE.md` §1.3),所以 `<2` 这一档
挡住的正是那类会让下游静默失败的改动,而 minor 和 patch 可以自由跟进。上面这个 `2` 是当前
major 加一,发 2.x 的那天下游要跟着改成 `<3`
`--extra-index-url` 而不是 `--index-url`,是因为下游还要从公网 PyPI 装别的依赖——
`--index-url` 会把默认源整个换掉,于是除了 polyloop 之外什么都装不到。
`NO_PROXY=gitea.iomgaa.online` 在下游这边同样要带,只要还是这台机器,理由和第七步一样。
---
## 这些坑为什么在这里
上面九步里有几步看起来是多余的,它们各自对应 PolyGateway 上一次真实的失败。共同点是
**失败的时候没有任何东西报错**——所以只能靠步骤挡住,靠事后检查发现不了。
**版本号 bump 被当成了发布**(第七、八步防它)。1.0.6 与 1.1.0 停在「改完版本号、写完
CHANGELOG、合进 main」这个状态,看起来和发布完成一模一样:git 历史齐全、CHANGELOG 有条目、
测试全绿。唯一的区别在 registry 上,而没有人会主动去看那里。上传和验证各占独立的一步、
而不是并成一句「发布一下」,就是为了让「传了没有」这件事有一个必须走到的位置。
**打包会固化 README**(第一步防它)。这个坑的特别之处是它发生在正确的操作之后:改 README
是对的,只是改晚了。而包页面上的过期文档比没有文档更坏——它会指导下游装错版本。
**`readme` 字段缺失让包页面空白**(准备阶段 + 第九步防它)。这是唯一一个三道机器检查全绿
还能出错的坑:`twine check` 只警告、上传返回成功、`pip download` 下得下来。所以第九步必须
用眼睛看一次页面,没有别的办法。
**tag 与 Release 是两件事**(第五、九步防它)。tag 是 git 里的东西,Release 是 Gitea 里的东西,
打了前者不会长出后者。PolyGateway 那边两件事都出过问题:有的版本有包没 tag,有的有 tag 没
Release。这两个方向的漏都不影响任何人当天的工作,所以能拖很久没人发现。
**包不会自己挂到仓库**(第九步防它)。owner 级 registry 加上 PyPI 元数据里没有仓库字段,
两件事叠起来的结果是包和仓库之间没有任何自动的联系,而这个实例的 link API 返 404
连脚本都写不了。整个流程里只有这一件事必须用鼠标点。
**两处版本号会漂移**(第三步防它)。这一条已经有机器兜底(`tests/unit/test_package.py`),
所以它不靠自觉——第三步真正要做的只是**在改完之后立刻跑一次测试**,而不是等到第四步那次
`make ci`。早跑一次的收益是:这时候改动还没合进 main,改起来不牵扯任何别人的东西。