diff --git a/research-wiki/guides/releasing.md b/research-wiki/guides/releasing.md new file mode 100644 index 0000000..b54f52f --- /dev/null +++ b/research-wiki/guides/releasing.md @@ -0,0 +1,323 @@ +# 发布一个版本 + +> **更新触发点:第 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 不建 Release,Releases 页会长期为空。** 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,改起来不牵扯任何别人的东西。