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>
This commit is contained in:
+173
-130
@@ -1,17 +1,20 @@
|
|||||||
# 发布一个版本
|
# 发布一个版本
|
||||||
|
|
||||||
> **更新触发点:第 2 档(同提交同改)。** 三种事情发生时,本文在同一个提交里改:发布用的
|
> **更新触发点:第 2 档(同提交同改)**——`../README.md` 把常青文档的更新触发点分成三档,
|
||||||
> 地址、工具或凭据位置变了;步骤增删或换序;某次发布踩到新坑并因此加了一步。
|
> 第 2 档的意思是「改到相关的东西时,在同一个提交里把这份文档改对」。三种事情触发它:
|
||||||
|
> 发布用的地址、工具或凭据位置变了;步骤增删或换序;某次发布踩到新坑并因此加了一步。
|
||||||
>
|
>
|
||||||
> **当前状态:本仓库一次都还没发布过。** 一个 tag 都没有,registry 上没有 polyloop,
|
> **当前状态:1.0.1 已经发布。** Gitea 仓库 `iomgaa/PolyLoop` 已建、remote 已接,`main` 与
|
||||||
> Gitea 上也还没有对应的仓库,所以下面那套一次性准备一件都还没做。步骤本身不是凭空设计的,
|
> annotated tag `v1.0.1` 都推上去了;registry 上有 `polyloop-1.0.1.tar.gz` 与
|
||||||
> 是从 PolyGateway 已经发生过的几次发布(和几次没发出去)里抄回来的。
|
> `polyloop-1.0.1-py3-none-any.whl`,包页面渲染出了 README 正文,Release 也建好了。
|
||||||
|
> **唯一还欠着的是包页面上的 Link to a repository**,那一步只能在网页上点(第九步)。
|
||||||
|
|
||||||
PolyGateway 的 registry 长期停在 1.0.5,而 1.0.6 和 1.1.0 这两个版本,版本号改了、
|
PolyGateway 的 registry 上,版本号是断的:`0.1.0, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.0.5,
|
||||||
`CHANGELOG` 写了、代码合进 main 了,就是从来没有上传过。到今天它们仍然不在 registry 上——
|
1.1.1, 1.1.2`——中间缺了 1.0.6 和 1.1.0。这两个版本,版本号改了、CHANGELOG 写了、代码合进
|
||||||
当年发现之后的补救只是把流程写进文档,没有人回头把包补传上去。
|
main 了,就是从来没有上传过。当年发现之后的补救只是把流程写进文档,没有人回头把包补传上去,
|
||||||
|
所以到今天那个缺口还在。
|
||||||
|
|
||||||
这件事现在正在伤人:dissect 的 requirements 钉着 `polygateway>=1.0.6,<1.1`,而这个区间在
|
这个缺口现在正在伤人:dissect 的 requirements 钉着 `polygateway>=1.0.6,<1.1`,而这个区间在
|
||||||
registry 上一个文件都没有。下游装不到任何修复,而且**没有任何一处会报错**——本地测试全绿,
|
registry 上一个文件都没有。下游装不到任何修复,而且**没有任何一处会报错**——本地测试全绿,
|
||||||
git 历史干干净净,只有真去 registry 上看的人才发现那里什么都没有。
|
git 历史干干净净,只有真去 registry 上看的人才发现那里什么都没有。
|
||||||
|
|
||||||
@@ -23,18 +26,31 @@ git 历史干干净净,只有真去 registry 上看的人才发现那里什么
|
|||||||
|
|
||||||
## 一次性准备
|
## 一次性准备
|
||||||
|
|
||||||
这四件事只做一次,做完之后每次发布不用再管。它们没做全的表现都是发布走到一半才卡住,
|
这些事只做一次。它们没做全的表现都是发布走到一半才卡住,而那时候 tag 可能已经打出去了。
|
||||||
而那时候 tag 可能已经打出去了。
|
|
||||||
|
### 为什么发到自建 Gitea,不发公网 PyPI
|
||||||
|
|
||||||
|
这是一个实验室内部共用的库,用它的是 dissect、GovDoc-SaaS 和 CHSAnalyzer,没有外部用户。
|
||||||
|
公网 PyPI 上占一个名字要跟着负维护责任——那个名字一旦发出去就收不回来,而且实验室还有另外
|
||||||
|
几个库要一起管,散在两个地方管不动。自建 registry 把这几个库收在同一个 owner 下面,权限和
|
||||||
|
清理都在自己手里。
|
||||||
|
|
||||||
|
包本身是**公开的、匿名可装**,这一点和「不上公网」不矛盾:不上公网是为了少一份对外承诺,
|
||||||
|
公开可装是为了让下游的 CI 不必配任何凭据就能装上——凭据只有上传的人需要。
|
||||||
|
|
||||||
### 建 Gitea 仓库并接上 remote
|
### 建 Gitea 仓库并接上 remote
|
||||||
|
|
||||||
发布目标是实验室自建的 Gitea 实例 `https://gitea.iomgaa.online`。在它上面建
|
发布目标是实验室自建的 Gitea 实例 `https://gitea.iomgaa.online`。仓库这么建:
|
||||||
`iomgaa/PolyLoop`,然后在本仓库接上:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
git remote add origin https://gitea.iomgaa.online/iomgaa/PolyLoop.git
|
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 上。
|
仓库和包是两件事:仓库放代码、tag 和 Releases,包放在同一个 Gitea 实例的 PyPI registry 上。
|
||||||
**这个 registry 是 owner 级的,不是仓库级的**——它挂在用户 `iomgaa` 名下,`iomgaa` 名下所有
|
**这个 registry 是 owner 级的,不是仓库级的**——它挂在用户 `iomgaa` 名下,`iomgaa` 名下所有
|
||||||
项目的包都堆在同一个索引里。两个地址:
|
项目的包都堆在同一个索引里。两个地址:
|
||||||
@@ -46,46 +62,55 @@ git remote add origin https://gitea.iomgaa.online/iomgaa/PolyLoop.git
|
|||||||
|
|
||||||
owner 级的直接后果是:包传上去之后不会自动和任何仓库产生关联,要手动挂(第九步)。
|
owner 级的直接后果是:包传上去之后不会自动和任何仓库产生关联,要手动挂(第九步)。
|
||||||
|
|
||||||
包是公开的,匿名就能装,所以下游不需要任何凭据。凭据只有上传的人需要。
|
### 三个工具:tea、build、twine
|
||||||
|
|
||||||
### 装 build 与 twine
|
`tea` 是 Gitea 的官方命令行。**这台机器上 `tea 0.15.0` 已经装好,而且已经 `tea login` 过这个
|
||||||
|
实例**,所以下面要用到的那个凭据文件是现成的。换一台机器的话,先装 tea 并 `tea login`,
|
||||||
|
否则凭据那一节和第九步都无从下手。
|
||||||
|
|
||||||
|
`build` 和 `twine` 已经在 `pyproject.toml` 的 dev extra 里,所以装过开发环境就有:
|
||||||
|
|
||||||
```
|
```
|
||||||
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop pip install build twine
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
这两个工具不在 `pyproject.toml` 的 dev extra 里,要手工装进 conda 环境。dev extra 里的东西
|
「extra」是 Python 打包里的可选依赖组,`pip install -e ".[dev]"` 装的就是 dev 这一组,
|
||||||
每个协作者都得装,而这两个只有做发布的那个人用得上,且它们不参与任何测试结果——不进 extra
|
`make install` 跑的正是它。**把这两个放进 extra 是刻意的**:PolyGateway 那边它们不在任何
|
||||||
就不必为它们钉版本、也不必让每个人的环境跟着变大。
|
extra 里,于是它的发布流程必须多写一条「记得先装这两个」,而那种一年用几次的准备步骤迟早
|
||||||
|
有人漏,漏了的表现是发布走到第六步才报 `No module named build`。代价是每个协作者的环境
|
||||||
|
多两个包,换来发布这条路上少一个人工前提。
|
||||||
|
|
||||||
**工具是 `twine`,不是别的。** `uv publish` 这条路不走,因为本仓库的环境是 conda,多引一个
|
**上传工具是 `twine`,不是别的。** `uv publish` 这条路不走,因为本仓库的环境是 conda,
|
||||||
包管理器会让「现在装的到底是哪份依赖」多一个答案。`tea`(Gitea 的官方命令行)也不行:
|
多引一个包管理器会让「现在装的到底是哪份依赖」多一个答案。`tea` 也传不了包:`tea 0.15.0`
|
||||||
`tea 0.15.0` 根本没有 packages 子命令,它能做的只是建 Release,那是第九步的事。
|
根本没有 packages 子命令,它在整套流程里只用来建 Release。
|
||||||
|
|
||||||
### 把 token 准备好,并且只经环境变量传
|
### 命令的两种形状
|
||||||
|
|
||||||
|
`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` 段里,字段名是
|
凭据是一个 Gitea access token,它在 `~/.config/tea/config.yml` 的 `logins` 段里,字段名是
|
||||||
`token`——那是 tea 登录这个实例时存下来的。**它不在 `~/.pypirc`**:那个文件里只有公网 PyPI
|
`token`——那是 `tea login` 时存下来的。**它不在 `~/.pypirc`**:那个文件里只有公网 PyPI 的段,
|
||||||
的段,照着 `.pypirc` 找会一无所获。手上没有 token 的人在 Gitea 的用户设置里新签一个,
|
照着 `.pypirc` 找会一无所获。手上没有 token 的人在 Gitea 的用户设置里新签一个,勾上 package
|
||||||
勾上 package 的读写权限,上传要的就是这一项。
|
的读写权限,上传要的就是这一项。
|
||||||
|
|
||||||
上传前把三个变量摆好:
|
token 只经环境变量传给 twine,不写进命令行,也不写进任何文件:命令行会进 shell 历史,也会
|
||||||
|
出现在同一台机器上任何人的 `ps` 输出里,而**这台机器是和别人共用的**。具体的三个 export
|
||||||
```
|
在第七步,不在这里——`read -rs` 读进来的值只活在当前那个 shell 里,换一个终端窗口、
|
||||||
export TWINE_USERNAME=iomgaa
|
或者中间 `exit` 过一次,就得重设一遍。
|
||||||
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`
|
### 让包元数据齐全,特别是 `readme`
|
||||||
|
|
||||||
`pyproject.toml` 的 `[project]` 里必须有 `readme = "README.md"`。缺了它,包能构建、能通过
|
`pyproject.toml` 的 `[project]` 里必须有 `readme = "README.md"`。缺了它,包能构建、能通过
|
||||||
`twine check`、能上传成功、能被 `pip download` 下下来,**而 registry 的包页面正文是一片空白**。
|
`twine check`、能上传成功、能被 `pip download` 下下来,**而 registry 的包页面正文是一片空白**。
|
||||||
PolyGateway 的 1.1.2 就是这样发出去的:三步全绿,页面什么都没有。
|
PolyGateway 的 1.1.2 就是这样发出去的:三步全绿,页面什么都没有。这是唯一一个三道机器检查
|
||||||
|
全绿还能出错的坑,所以第九步必须用眼睛看一次页面。
|
||||||
|
|
||||||
`twine check` 对这种情况只给警告,不给非零退出码,所以它拦不住。发布前直接看一眼:
|
`twine check` 对这种情况只给警告,不给非零退出码,所以它拦不住。发布前直接看一眼:
|
||||||
|
|
||||||
@@ -99,28 +124,31 @@ grep -n '^readme' pyproject.toml
|
|||||||
|
|
||||||
## 每次发布的九步
|
## 每次发布的九步
|
||||||
|
|
||||||
顺序不能改:每一步都在防一件具体的事,换序会让防的那件事漏过去。
|
前两步(README 与 CHANGELOG)互相依赖,一起做:改 README 要用到版本号,而版本号由这一版
|
||||||
|
改了什么决定,那是 CHANGELOG 那一步的事。除此之外顺序是死的,每一步都压着后面某一步的
|
||||||
|
前提——理由写在各步自己那里。
|
||||||
|
|
||||||
`X.Y.Z` 代表这次要发的版本号,`vX.Y.Z` 是它对应的 tag。
|
`X.Y.Z` 代表这次要发的版本号,`vX.Y.Z` 是它对应的 tag。前三步在一个分支上做(例如
|
||||||
|
`release/vX.Y.Z`),第四步才合进 `main`。
|
||||||
|
|
||||||
### 1. 改 README
|
### 1. 改 README
|
||||||
|
|
||||||
先把 `README.md` 里所有会随版本变的东西改对,尤其是**安装命令里的版本约束**。
|
`README.md` 的「安装」一节里,安装命令带着版本约束(现在是 `polyloop==1.0.*` 这个形状,
|
||||||
|
`gateway` extra 那条命令里还有一份)。**发新版时这几行要跟着改**——那是 README 里唯一会随
|
||||||
|
版本失效的东西。
|
||||||
|
|
||||||
这一步排在最前面,是因为 `python -m build` 会把当时的 README 整个固化进 sdist 与 wheel 的元
|
这一步排在最前面,是因为 `python -m build` 会把当时的 README 整个固化进 sdist 与 wheel 的元
|
||||||
数据里,registry 包页面显示的正文就是那一份。发布之后再改仓库里的 README,包页面纹丝不动——
|
数据里,registry 包页面显示的正文就是那一份。发布之后再改仓库里的 README,包页面纹丝不动——
|
||||||
要改只能重发一个版本。PolyGateway 的 1.1.1 就是带着一份过期 README 发出去的。
|
要改只能重发一个版本。PolyGateway 的 1.1.1 就是带着一份过期 README 发出去的。
|
||||||
|
|
||||||
最容易漏的就是版本约束那一行:漏改的话,下游照着 README 装,会被锁在旧版本上,而他不会
|
版本约束那一行漏改的后果是:下游照着 README 装,会被锁在旧版本上,而他不会怀疑一份刚发布
|
||||||
怀疑一份刚发布的文档。
|
的文档。
|
||||||
|
|
||||||
改 README 要用到版本号,而版本号是下一步定出来的,所以这两步实际上一起做——先扫一眼
|
|
||||||
CHANGELOG 的「未发布」段落把 X.Y.Z 定下来,再回头改 README。**硬约束只有一条:两步都必须
|
|
||||||
在第六步构建之前完成**,构建那一刻仓库里是什么样,包里就固化成什么样。
|
|
||||||
|
|
||||||
### 2. 把 CHANGELOG 定版
|
### 2. 把 CHANGELOG 定版
|
||||||
|
|
||||||
`CHANGELOG.md` 里那个「未发布」段落改成 `X.Y.Z` 和今天的日期。
|
`CHANGELOG.md` 里那个「未发布」段落改成这一版的标题,形状是 `## X.Y.Z(YYYY-MM-DD)`,
|
||||||
|
日期就是发布当天,例如 `## 1.0.1(2026-08-11)`。改完在它上面留一个空的「未发布」段落,
|
||||||
|
给下一版接着攒。
|
||||||
|
|
||||||
这一步排在改版本号之前,是因为**它才是决定版本号该是多少的地方**:这一版改了什么,决定了
|
这一步排在改版本号之前,是因为**它才是决定版本号该是多少的地方**:这一版改了什么,决定了
|
||||||
它是 patch、minor 还是 major。倒过来做的话,版本号是先拍出来的,CHANGELOG 只是去凑它。
|
它是 patch、minor 还是 major。倒过来做的话,版本号是先拍出来的,CHANGELOG 只是去凑它。
|
||||||
@@ -132,22 +160,39 @@ CHANGELOG 的「未发布」段落把 X.Y.Z 定下来,再回头改 README。**
|
|||||||
- `pyproject.toml` 的 `project.version`
|
- `pyproject.toml` 的 `project.version`
|
||||||
- `src/polyloop/__init__.py` 的 `__version__`
|
- `src/polyloop/__init__.py` 的 `__version__`
|
||||||
|
|
||||||
双写是刻意的(理由在 `__init__.py` 里那条注释),代价就是会漂移。`tests/unit/test_package.py`
|
双写是刻意的:运行时读不到构建元数据(未安装的源码树里 `importlib.metadata` 查不到这个包),
|
||||||
有一条断言钉住它,PolyGateway 那边同一条测试逮住过两次漏改。改完立刻跑一次:
|
而下游报 bug 时第一件事就是问版本号。代价就是两处会漂移,所以
|
||||||
|
`tests/unit/test_package.py` 有一条断言钉住它,PolyGateway 那边同一条测试逮住过两次漏改。
|
||||||
|
改完立刻跑一次:
|
||||||
|
|
||||||
```
|
```
|
||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
漂移的后果不会当场出现:下游报 bug 时说的版本号和实际装的不是同一个,而这种错查起来要绕很远。
|
**在这一步就跑,而不是等第四步那次 `make ci`**:这时候改动还没合进 main,改起来不牵扯任何
|
||||||
|
别人的东西。漂移的后果不会当场出现——下游报 bug 时说的版本号和实际装的不是同一个,
|
||||||
|
而这种错查起来要绕很远。
|
||||||
|
|
||||||
**版本号定高了想往回改,要动三个文件**——两处版本号加 CHANGELOG。PolyGateway 有一次把 1.1.0
|
**版本号定高了想往回改,要动三个文件**:两处版本号加 CHANGELOG。PolyGateway 有一次把 1.1.0
|
||||||
改回 1.0.4,就是这三处一起改的。往回改只在 tag 还没打出去之前有效,打了 tag 之后见第五步。
|
改回 1.0.4,就是这三处一起改的。**那次是在 1.0.4 还没发布之前**——发布前的版本号只是文件里
|
||||||
|
的三个字符串,随便改;一旦第七步传上去,那个版本号就永久占住了,只能往前发新的
|
||||||
|
(第七步那个 409)。
|
||||||
|
|
||||||
### 4. 合并进 main、push,并在 main 上重跑全套件
|
### 4. 合并进 main、push,并在 main 上重跑全套件
|
||||||
|
|
||||||
前三步的改动合进 main 并 push(push 是外部可见动作,按 `CLAUDE.md` §2 要先得到批准)。
|
```
|
||||||
然后**在 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
|
make ci
|
||||||
@@ -166,7 +211,7 @@ git tag -a vX.Y.Z -m "vX.Y.Z"
|
|||||||
git push origin vX.Y.Z
|
git push origin vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
**annotated,不是轻量 tag,挂在 main 上刚刚验过的那个 release commit 上。** 这条写死是因为
|
**annotated,不是轻量 tag,挂在 main 上刚刚验过的那个 commit 上。** 这条写死是因为
|
||||||
PolyGateway 那边没写死:它的 tag 有的挂在 merge commit、有的挂在某个 feat commit,其中
|
PolyGateway 那边没写死:它的 tag 有的挂在 merge commit、有的挂在某个 feat commit,其中
|
||||||
`v1.0.0` 还是个轻量 tag。轻量 tag 只是一个指向 commit 的指针,没有作者、日期和消息,
|
`v1.0.0` 还是个轻量 tag。轻量 tag 只是一个指向 commit 的指针,没有作者、日期和消息,
|
||||||
`git describe` 默认也不认它,于是「这个版本是谁在什么时候发的」这个问题在那一版上没有答案。
|
`git describe` 默认也不认它,于是「这个版本是谁在什么时候发的」这个问题在那一版上没有答案。
|
||||||
@@ -175,6 +220,8 @@ tag 打在构建之前,是为了让 registry 上的那个包一定对应得上
|
|||||||
后打 tag)的失败形态是漏打:PolyGateway 的 1.0.1 与 1.0.2 有 CHANGELOG、registry 上也有包,
|
后打 tag)的失败形态是漏打:PolyGateway 的 1.0.1 与 1.0.2 有 CHANGELOG、registry 上也有包,
|
||||||
但至今没有 tag,没人说得清那两个包是从哪个 commit 构建出来的。
|
但至今没有 tag,没人说得清那两个包是从哪个 commit 构建出来的。
|
||||||
|
|
||||||
|
tag 是 git 里的东西,Release 是 Gitea 里的东西,打了前者不会长出后者,所以还有第九步。
|
||||||
|
|
||||||
### 6. 构建,并 `twine check`
|
### 6. 构建,并 `twine check`
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -183,15 +230,35 @@ PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop python -m build
|
|||||||
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop twine check dist/*
|
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop twine check dist/*
|
||||||
```
|
```
|
||||||
|
|
||||||
先清 `dist/`,因为第七步是 `twine upload dist/*`:留着上一个版本的产物,那一批会被一起重传,
|
`python -m build` 在 `dist/` 下产出两个文件,两个都要传:
|
||||||
而重传必定撞第七步那个 409。
|
|
||||||
|
- **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 拒绝任何一个已经存在的版本号,于是整条上传命令失败。
|
||||||
|
|
||||||
`python -m build` 产出两个文件,sdist(`.tar.gz`)和 wheel(`.whl`),两个都要传。
|
|
||||||
`twine check` 验的是元数据能不能被 registry 渲染。它**只警告不拦**(`readme` 缺失就是警告的
|
`twine check` 验的是元数据能不能被 registry 渲染。它**只警告不拦**(`readme` 缺失就是警告的
|
||||||
一种),所以它绿不代表包页面正常,那件事要等第九步用眼睛确认。
|
一种),所以它绿不代表包页面正常,那件事要等第九步用眼睛确认。
|
||||||
|
|
||||||
### 7. 上传到 registry
|
### 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 \
|
NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \
|
||||||
twine upload dist/*
|
twine upload dist/*
|
||||||
@@ -199,9 +266,7 @@ NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyL
|
|||||||
|
|
||||||
`NO_PROXY` 不能省。这台机器设了 `http_proxy` / `https_proxy`,指向一个到不了外面的本地代理,
|
`NO_PROXY` 不能省。这台机器设了 `http_proxy` / `https_proxy`,指向一个到不了外面的本地代理,
|
||||||
而 Gitea 在内网——不排除代理的话这一步会卡住或直接连接失败,且报出来的错和凭据错误长得很像。
|
而 Gitea 在内网——不排除代理的话这一步会卡住或直接连接失败,且报出来的错和凭据错误长得很像。
|
||||||
所有访问 `gitea.iomgaa.online` 的命令都要带它,第八步同样。
|
所有访问 `gitea.iomgaa.online` 的命令都要带它,第八、第九步同样。
|
||||||
|
|
||||||
上传地址来自准备阶段设好的 `TWINE_REPOSITORY_URL`,凭据来自 `TWINE_USERNAME` / `TWINE_PASSWORD`。
|
|
||||||
|
|
||||||
**同一个版本号重传会被 registry 用 409 拒绝,而且传上去的东西改不了。** 发现内容有问题的唯一
|
**同一个版本号重传会被 registry 用 409 拒绝,而且传上去的东西改不了。** 发现内容有问题的唯一
|
||||||
出路是 bump 一个新版本号重走一遍九步。PolyGateway 有一次是先在 Gitea 侧手工把包删掉才重传的,
|
出路是 bump 一个新版本号重走一遍九步。PolyGateway 有一次是先在 Gitea 侧手工把包删掉才重传的,
|
||||||
@@ -210,9 +275,7 @@ NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyL
|
|||||||
|
|
||||||
### 8. 验证已发布
|
### 8. 验证已发布
|
||||||
|
|
||||||
两条命令,用途不同。
|
**轻量的一条**,纯 GET,看某个版本在不在:
|
||||||
|
|
||||||
**轻量的**,纯 GET,看某个版本在不在:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
NO_PROXY=gitea.iomgaa.online curl -s \
|
NO_PROXY=gitea.iomgaa.online curl -s \
|
||||||
@@ -222,7 +285,7 @@ NO_PROXY=gitea.iomgaa.online curl -s \
|
|||||||
它返回一页链接,列出这个包在 registry 上的所有文件名。刚传的那个版本的 sdist 和 wheel 都要在
|
它返回一页链接,列出这个包在 registry 上的所有文件名。刚传的那个版本的 sdist 和 wheel 都要在
|
||||||
里面。这条命令不装任何东西、不写任何状态,随时可以重跑。
|
里面。这条命令不装任何东西、不写任何状态,随时可以重跑。
|
||||||
|
|
||||||
**最终的**,走下游真正会走的那条路:
|
**然后走一遍下游真正会走的路**。`pip download` 默认只取 wheel,所以这条命令拿到的是 `.whl`:
|
||||||
|
|
||||||
```
|
```
|
||||||
cd "$(mktemp -d)"
|
cd "$(mktemp -d)"
|
||||||
@@ -230,20 +293,39 @@ NO_PROXY=gitea.iomgaa.online PYTHONUNBUFFERED=1 conda run --live-stream -n PolyL
|
|||||||
pip download --no-deps \
|
pip download --no-deps \
|
||||||
--index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
--index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||||
"polyloop==X.Y.Z"
|
"polyloop==X.Y.Z"
|
||||||
tar -tzf polyloop-X.Y.Z.tar.gz
|
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 这一个包能不能取到,不是它的依赖树装不装得起来。
|
`--no-deps` 是因为这里验的是 polyloop 这一个包能不能取到,不是它的依赖树装不装得起来。
|
||||||
|
|
||||||
**「下载成功」不算数,必须解开看里面的东西**。上一段那个 `tar -tzf` 列出 sdist 的内容;要确认
|
**「下载成功」不算数,必须解开看里面的东西。** 通过的判据是三条:`unzip -l` 列出的文件里
|
||||||
这次改的代码真的在里面,就把对应文件解出来读一眼。原因是包和 git 之间没有任何机器约束:
|
十个模块的 `.py` 和 `polyloop/py.typed` 都在;`__version__` 是这一版而不是上一版;这次新增或
|
||||||
构建时工作区不干净、`dist/` 没清、传错了文件,都会让一个「下载成功」的包里装着旧代码。
|
改动的公共名字 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,并以下游视角核对包页面
|
### 9. 建 Release,并以下游视角核对包页面
|
||||||
|
|
||||||
在网页上建,或者用 `tea` 的 releases 子命令(这是 `tea 0.15.0` 在整套流程里唯一派得上用场的
|
先把 CHANGELOG 里这一版那一段抽出来存成一个文件(例如 `/tmp/release-notes.md`),它就是
|
||||||
地方,具体参数 `tea releases create --help` 现查)。Release 挂在第五步那个 tag 上,正文就是
|
Release 的正文。然后:
|
||||||
这个版本的 CHANGELOG 段落。
|
|
||||||
|
```
|
||||||
|
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 不建 Release,Releases 页会长期为空。** PolyGateway 的 tag 里只有最后一个有对应的
|
**只打 tag 不建 Release,Releases 页会长期为空。** PolyGateway 的 tag 里只有最后一个有对应的
|
||||||
Release,于是那个仓库的 Releases 页看起来像一个从没发布过的项目——而 Releases 页是不熟悉这个
|
Release,于是那个仓库的 Releases 页看起来像一个从没发布过的项目——而 Releases 页是不熟悉这个
|
||||||
@@ -253,71 +335,32 @@ Release,于是那个仓库的 Releases 页看起来像一个从没发布过的
|
|||||||
|
|
||||||
- **手动点 Link to a repository,把包挂到 `iomgaa/PolyLoop` 上。** 包不会自动挂——PyPI 元数据
|
- **手动点 Link to a repository,把包挂到 `iomgaa/PolyLoop` 上。** 包不会自动挂——PyPI 元数据
|
||||||
里没有仓库这个字段,而这个 registry 是 owner 级的,它没有办法猜出这个包属于哪个仓库。
|
里没有仓库这个字段,而这个 registry 是 owner 级的,它没有办法猜出这个包属于哪个仓库。
|
||||||
**这个实例的 link API 返 404,脚本化不了**,只能在网页上点。
|
**这个实例的 link API 返 404,脚本化不了**,整个流程里只有这一件事必须用鼠标点。
|
||||||
- **看一眼正文不是空白**。空白说明 `readme` 那件事又漏了,见准备阶段最后一节。
|
- **看一眼正文不是空白**。空白说明 `readme` 那件事漏了,见准备阶段最后一节。
|
||||||
|
|
||||||
最后逐一打开这三样,全部对得上才算发完:registry 包页面(正文 + 仓库链接)、仓库的 Releases
|
最后逐一打开这三样,全部对得上才算发完:registry 包页面(正文 + 仓库链接)、仓库的 Releases
|
||||||
页、第八步解包出来的文件。前八步全绿而发布其实没成,正是开头 PolyGateway 那两个版本的形态——
|
页、第八步解出来的文件。前八步全绿而发布其实没成,正是开头 PolyGateway 那个缺口的形态——
|
||||||
**判据必须是外部可见的结果,不是本地跑通了几条命令。**
|
**判据必须是外部可见的结果,不是本地跑通了几条命令。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 下游怎么装
|
## 下游怎么装
|
||||||
|
|
||||||
这台机器上没有任何全局 pip 配置(`pip config list` 是空的,三个可能位置的 `pip.conf` 都不存在),
|
装法的权威是 `README.md` 的「安装」一节,那里有当前该用的完整命令和版本约束。发布的人要
|
||||||
所以 pip 默认只认公网 PyPI,而 polyloop 不在那上面。**下游必须自己带索引地址。**
|
知道的是它为什么长那样,以及下游最容易在哪两处摔跤。
|
||||||
|
|
||||||
写在 `requirements.txt` 里的话,加在 polyloop 那一行**之前**:
|
**索引地址必须由下游自己带。** polyloop 不在公网 PyPI 上,而 pip 默认只认公网 PyPI;这台
|
||||||
|
开发机上也没有任何全局 pip 配置兜底(`pip config list` 是空的,三个可能位置的 `pip.conf`
|
||||||
|
都不存在)。全局配置本来就不该指望:它是每台机器各自的状态,下游的开发机和 CI 容器多半
|
||||||
|
干脆没有,所以「自己带索引地址」是普遍成立的要求,不是这台机器的特殊情况。
|
||||||
|
|
||||||
```
|
**用 `--extra-index-url`,不是 `--index-url`。** 后者会把默认源整个换掉,于是除了 polyloop
|
||||||
--extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/
|
之外的依赖都装不到。写进 `requirements.txt` 的话,`--extra-index-url` 那一行必须排在
|
||||||
polyloop>=X.Y.Z,<2
|
`polyloop` 那一行之前——pip 是按顺序读这个文件的,写在后面等于对上面那行不生效。
|
||||||
```
|
|
||||||
|
|
||||||
或者写在安装命令里:
|
**代理是本机特有的那一条。** `NO_PROXY=gitea.iomgaa.online` 只在设了指向内网代理的机器上
|
||||||
|
需要(这台开发机就是),别的机器不必带。带上也不会有副作用,所以 README 里直接写了它。
|
||||||
|
|
||||||
```
|
**版本约束一定要带上界。** 破坏性变更会发生在 major 上(`CLAUDE.md` §1.3),没有上界的
|
||||||
NO_PROXY=gitea.iomgaa.online pip install \
|
`polyloop` 一行会在某天自动装上一个签名已经变了的版本,而下游那边表现成一次莫名其妙的
|
||||||
--extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
`ImportError`。上界具体钉在哪一档以 README 那一节为准,发新版时跟着它改(第一步)。
|
||||||
"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,改起来不牵扯任何别人的东西。
|
|
||||||
|
|||||||
Reference in New Issue
Block a user