我们的 release 流水线,每次发版都把包 build 两遍。
这是一次真实发版的日志,两个 step 前后脚:
[Python Semantic Release step]21:48:53 Successfully built dist/my_package-2.4.1.tar.gz21:48:53 Successfully built dist/my_package-2.4.1-py3-none-any.whlBuild completed successfully!
[Build distribution packages step]21:48:57 Successfully built dist/my_package-2.4.1.tar.gz21:48:57 Successfully built dist/my_package-2.4.1-py3-none-any.whl一模一样的文件,前后差四秒。第二次 build 把第一次的产物原地覆盖了一遍,贡献精确为零。它就这么在每次发版里默默跑着——这个仓库跑,后来我才知道另外十九个仓库也在跑,跑了大半年。没人发现,因为它从来不报错。
上一篇我写了逐行读 PR workflow 时挖出的死代码。这篇是我接着往下读 release workflow 时发生的事。故事更好看,因为这个 bug 有三幕:发现它、发现它到处都是、以及修它的时候它反咬了我一口。
第一幕:一个 bug
Release workflow 用 python-semantic-release 做自动版本管理(那整台机器怎么运转,另有一篇专门讲)。这里只需要 pyproject.toml 里的一行:
[tool.semantic_release]build_command = "pip install uv && uv build"按这个工具的文档,只要配了 build_command,release 那一步会自己把包 build 出来,作为版本号更新的一部分,内部完成。而我们的 workflow 里,紧跟在它后面还有一步:
- name: Build distribution packages run: uv build把刚 build 好的东西,再显式 build 一遍。我没有只信文档——CI 配置恰恰是”文档说的”和”实际跑的”最容易脱节的地方——所以去翻了一次真实发版的日志,就是开头那段。两次 build,时间戳摆在那,差四秒。实锤。
无害,对吧?每次发版浪费几秒而已。这个念头先留着,第三幕要用。
第二幕:二十个 bug
动手修之前,我想先搞清楚它是怎么来的。对两处——build_command 配置和那个多余的 step——分别 git blame,线索指向了比这个仓库有意思得多的地方:两行代码是同一个 commit 加进来的,同一个作者,加在我们所有 Python 服务的脚手架模板仓库里。比这个服务的出生还早好几个月。
模板仓库就是台复印机。母版上的一个污点,不是一页纸脏了,而是这台机器今后印出的每一页都脏。新建一个服务就是按一下复印键——从那个 commit 之后,每一份复印件出来都自带这个污点:配置和多余步骤,成对出生,出生即重复。
在全组织搜了一圈代码,发现共用这份 release workflow 的仓库有二十多个。然后我差点跳过了一课:我本来直接照着搜索结果起草受影响仓库清单,准备发公告——结果真正把每个仓库的文件打开核对时,有两个是冤枉的。它们有那个显式 build step,但没配 build_command,也就是说那一步是它们唯一的 build,根本不冗余。搜索只能告诉你去哪看,文件才能告诉你什么是真的。十九个确认,两个无罪释放。
第三幕:bug 反咬一口
这个修复看起来是我这辈子最轻松的 PR。删三行:
- name: Build distribution packages run: uv build内部 build 已经产出 dist/,后面的发布步骤直接从磁盘拿,完事。PR 开了,通过了,合了。
然后一个 AI review 机器人在下游仓库的 PR 上留了条评论,我读了三遍:
删掉这个无条件的 build step 之后,
dist/只在真正发版时才存在。而 artifact 上传那一步每次 push 都会跑,还配着if-no-files-found: error——没有发版的运行现在会直接失败。
它说得对。有一条路径,我和八个月的绿色对勾都从来没想过:这个 release 工具不是每次都发版。往 main 推一个 docs: fix typo 这样的 commit,它扫完历史、判断不需要升版本号,就提前 return 了——在执行 build_command 之前。不发版,就不 build,就没有 dist/。
在旧的 workflow 里这从来不是问题,因为那个”多余的”显式 build 是无条件跑的。每次运行都有 dist/,所以下游那个没加任何条件的上传步骤,永远找得到文件。
我拆的那面墙,是承重的。
它看起来纯属装饰——文档和日志双重确认的废物。但八个月来,它一直在悄悄撑着另一个 bug:那个上传步骤本该有一个”真的发版了吗”的判断条件,从来没人加。两个错误互相扛着,加起来就是全绿。耳熟吗?上一篇里那两行死掉的 export 也是这么活下来的——被一个不相干的配置巧合抵消了。看来这是个规律:CI 保持绿色不是因为它正确,而是因为它的 bug 成对出现、恰好互相抵消。
真正的修法,是把拆墙和上梁放进同一个 PR:
- name: Build distribution packages run: uv build
- name: Publish package to GitHub Releases if: steps.release.outputs.released == 'true' uses: python-semantic-release/publish-action@...
- name: Upload | Distribution Artifacts if: steps.release.outputs.released == 'true' uses: actions/upload-artifact@...删掉冗余的 build,同时给每一个假设”dist/ 一定存在”的步骤都加上”确实发版了”的闸门——发 PyPI 那步其实早就有这个条件,当年不知道谁想到了给它加,却没给别的步骤加。
一个 bug 换来三条规则
模板会在出生时就把错误翻倍。 普通仓库里的 bug 是一个 bug,模板里的 bug 是未来每一个从它脚手架出来的仓库的 bug——我们这个被复印了十九次才有人抬头看。Review 模板的严格程度应该和它的复印数成正比;而在任何一个脚手架出来的仓库里发现 bug,第一反应都该是回头查母版。修复印机,别修那一页纸。
冗余可能在承重。 删任何”显然没用”的东西之前,先问一句:有什么可能正靠在它身上?最安全的修法是把删除和替代的保护放在同一个 PR 里。(还有,机器人可能看见你看不见的路径:我靠文档加日志验证了主路径,还是漏了”不发版”那个分支。机器人不跟着故事走,它挨条枚举路径。人叙事,机器穷举。)
绿色对勾只能证明它自己。 和上次同一个教训,只是爆炸半径大了:二十个仓库、大半年、零失败——期间始终躺着两个真 bug。绿色只说明坏掉的路径还没被走到。想提前找到它们,唯一的办法就是去读没人读的文件——尤其是那个所有人都在复印的仓库里的。