我们团队已经好几个月没有任何人挑过版本号了。包自己从 2.4.0 涨到 2.4.1 或者 2.5.0,changelog 自己生成,release 页面自己出现,构建产物自己挂上去。整个决定——这次 merge 要不要发版、发多大——由一个机器人做出,依据是我们敲在 commit message 里的那几个词。
前阵子我把这台机器的配置和 workflow 逐行读了一遍(顺手挖出一个 bug,那是另一个故事)。这篇是说明书:一个 PR 从合并到变成包仓库里一个可安装的版本,中间零个人类决定,到底是怎么做到的——以及三条守则,不遵守的话整套系统会悄无声息地散架。
版本号到底承诺了什么
语义化版本就是 major.minor.patch——但格式是最不重要的一半。重要的是每一位数字对用你这个包的人意味着什么。
把你的包想成一套用户住在里面的公寓:
- patch(
2.4.0 → 2.4.1)——你修好了一扇吱呀作响的合页。住户的生活没有任何变化。 - minor(
2.4.0 → 2.5.0)——你加盖了一个房间。原来的东西都在原地,只是多了新的。 - major(
2.4.0 → 3.0.0)——你把门锁换了。旧钥匙开不了门,住户必须先配新钥匙才能进屋。
这就是为什么下游项目会把依赖写成 >=2.4,<3:意思是*“你随便装修,但别换我的锁”*。你的 patch 和 minor 它们看都不看就自动升级,全凭信任你标对了。
所以标错版本不是格式问题。把一个 breaking change 当 patch 发出去,等于没打招呼就换了别人家的锁——而对方的 CI 会在半夜自动装上它。
快递面单:conventional commits
决定版本号的机器人不读你的代码。它只读一样东西:每条 commit message 的前缀。这套约定叫 Conventional Commits,心智模型就是快递分拣机:只认箱子上的面单,从来不开箱验货。
| commit 上的面单 | 含义 | 分拣去向 |
|---|---|---|
fix: / perf: | 修合页 | patch |
feat: | 加房间 | minor |
feat!: 或 footer 里写 BREAKING CHANGE: | 换锁 | major |
docs: / chore: / test: / ci: | 压根不算装修 | 不动版本号 |
真正值得害怕的是这一点:贴错面单的箱子不会被拒收——它会被静默地分错。把 breaking change 标成 fix: 不会有任何报错,机器高高兴兴地把它当 patch 发出去,下游自动升级,某个陌生人的周二就这么毁了。面单不是 commit 的装饰品,它就是一个陌生人会依赖的决定的输入。
流水线:跟着一条 commit 走完全程
我们跟踪一条 fix: handle empty payload,从 merge 一路走到包上架。我们的技术栈是 Python 配 python-semantic-release,但流水线的形状在任何生态里都一样(原版 semantic-release 出自 JS 世界)。
第 0 站——触发。 PR 合并,commit 落到 main,release workflow 被唤醒。注意:每一次 push main 都会走这条线,包括那些最后什么都不发的——这点后面有用。
第 1 站——两道安检。 第一道:*这条 commit 是不是发版机器人自己推的?*流水线的最后几站会往 main 回推一个 commit(后面你会看到为什么),那会再次触发这个 workflow,无限循环——所以机器人署名的 push 一律拦在门口。第二道:*排队这会儿 main 有没有动过?*如果这期间有别人合了代码,现在发版发出去的东西就和刚才扫描的对不上号。流水线把自己签出的 commit 和 main 当前的最新点比对一下,不一致就宁可中止,绝不发过期的代码。
第 2 站——大脑。 工具从上一个 release tag 走到现在,把沿途每条 commit 的面单过一遍规则。这就是全部的决策过程,而且你能在日志里围观它思考:
INFO found 9 previous tagsINFO The last full version in this branch's history was 2.4.0INFO Found 2 commits since the last release!INFO The type of the next release release is: patch2.4.1这堆 commit 里有一个 fix:,没有 feat:,没有 breaking footer → patch → 2.4.1。没有开会,没有讨论,没有”我感觉这个算 minor 吧”。
第 3 站——盖章。 新版本号写进 pyproject.toml,changelog 条目从那些 commit message 里生成出来,两处改动一起提交并打上 v2.4.1 的 tag——署名正是第 1 站负责拦截的那个机器人身份。
第 4 站——打包。 包被构建成 wheel 和 sdist。配置里一行 build_command,工具在发版过程中顺手替你把构建跑了。
第 5 站——上架。 两个货架,服务两种客人:GitHub Release(人类来翻 changelog、下载构建产物)和包仓库(pip/uv 从这里安装)。2.4.1 的 wheel 两边各摆一份。
跳过通道。 那如果合进来的是 docs: fix typo 呢?第 2 站判定不发版——整条流水线提前熄火,构建都不会跑。所以下游每一站都必须有”这次真的发版了吗”的闸门,不然就会去货架上找根本没造出来的包。我们的 workflow 恰好把这里搞错了,而且以一种几个月都看不见的方式——那就是另一篇的故事。
人类守则
注意这台机器从头到尾没做过的一件事:判断。它只是机械地、永远地执行你的判断——你编码在面单里的那份。这意味着发版流程里最脆弱的部分不在 YAML 里,而在团队的习惯里。三条守则撑住它:
守则一:机器管的东西,人不要碰。 pyproject.toml 里的版本号、git tag、changelog——三样都是机器人写的,而且每次发版都以上一次为基准往下算。手改它们就像拨自动上链手表的指针:不光时间不对了,整个机芯都乱了。想让版本号变,就在 commit 面单里说——那是机器唯一读的输入。
守则二:搞清楚你们的 merge 策略——团队真正翻车都翻在这。 用 merge commit 或 rebase-merge 时,PR 里每条 commit 都会落到 main,面单一条条被读。而 squash merge 会把整个 PR 压成一条 commit,message 默认取 PR 标题——箱子里那些面单跟着箱子一起销毁了,只有货柜外面那张标签活下来。一个用 squash merge 但 PR 标题随手写的团队,等于在不知情的情况下拔掉了整台机器的电源:合了几个月的代码,一个版本没发出去,还没人说得清为什么。如果你们用 squash,那 PR 标题就是你们的 conventional commit,需要同等的纪律。
守则三:面单是对陌生人的承诺。 某个你从来没听说过的仓库,在依赖里写了 <3,赌的就是你们团队分得清”换锁”和”修合页”。每一条 commit message 都是在兑现——或者悄悄撕毁——这个承诺。这才是”该标 fix 还是 feat”值得在提交时想十秒钟的真正原因,从来都不是为了 git log 好看。
最后
把这套搭起来真的只要一个下午:一个 workflow 文件、pyproject.toml 里一段配置、把工具加进 dev 依赖、两个 secret。机器的部分很简单,而且会一直简单。
但自动化并没有消灭发版里的人类判断,它只是把判断搬了家——从发版负责人的检查清单(每次发版做一遍),搬到了每个队友未来写的每一条 commit message 里。版本号不再是某个人挑出来的数字,而是整个团队每天的样子。机器人只负责算术。