【Bug已解决】Cut a new release 解决方案

📅 2026/8/1 15:25:27
【Bug已解决】Cut a new release 解决方案
【Bug已解决】Cut a new release 解决方案一、现象长什么样维护一个 Python 库比如accelerate这类到了该发版本的时候release 流程却磕磕绊绊要么版本号忘了改、要么 changelog 没更新、要么 tag 没打、要么 CI 的发布 job 悄悄失败没人发现。最终发个版变成一场手工拼凑经常出现现象pip 上看到了新版本但 __version__ 还是旧的 现象GitHub Release 有了changelog 却停在三个月前 现象tag v0.1.5 打了但源码里版本还是 0.1.4 现象发布 job 因未登录 / 缺 token 静默跳过没人察觉最小判据触发需要发布新版本 现象版本号、changelog、tag、wheel 四件套对不齐 根因release 是手工、分散、无校验的多步操作 影响用户装到版本错乱的包CI 依赖方踩坑最麻烦的是这些不一致往往不报错只是发出来的东西和系统里的真相对不上。等用户pip install后发现行为跟文档不符才回头发现版本号压根没 bump。二、背景一个规范的 release 至少包含四件事且必须彼此一致版本号 bump源码里的__version__/pyproject version递增Changelog把本周期的 PR / issue 汇总进CHANGELOG.mdGit tag打vX.Y.Z并推送作为不可变锚点构建 发布打 wheel / sdist传到 PyPI / 内部源。问题出在这四件事是分开手工做的。人手工做多步、强一致的事情必然出错bump 了版本忘了 changelog改了 changelog 忘了 tagtag 打了 CI 却没权限 push 到 PyPI。更糟的是这些步骤各自独立没有任何一处校验四件套是否一致于是错就错着发出来了。Cut a new release这个 issue 本质上不是某个函数有 bug而是发布流程缺一个单一、可重复、自带校验的入口。它属于流程型 bug靠纪律维持的流程迟早会因纪律松懈而出错。三、根因抽象成四步手工流程示意步骤A人编辑 pyproject.toml 的 version 0.1.5 步骤B人往 CHANGELOG.md 顶部加一节 步骤C人git tag v0.1.5 git push --tags 步骤DCI构建并 twine upload根因链条四步由不同人 / 不同时间手工完成没有统一驱动任一步遗漏或填错都不会被其他步发现无交叉校验tag 与源码版本、changelog 与 tag、wheel 与 tag 之间无一致性断言CI 发布 job 若因 token / 权限失败常是非阻塞步骤静默跳过结果四件套对不齐且无人 / 无系统报错——流程型 silent bug。为什么函数 bug思路救不了它因为问题不在某行代码而在多步流程缺乏单一真相与校验。要修的是流程本身。四、最小可运行复现用纯 Python 模拟四件套各自手工填、出现不一致# repro_release.py def cut_release(pyproject_ver, changelog_ver, tag_ver, wheel_ver): parts { pyproject: pyproject_ver, changelog: changelog_ver, tag: tag_ver, wheel: wheel_ver, } mismatch [k for k, v in parts.items() if v ! pyproject_ver] return parts, mismatch def main(): # 手工场景bump 了 pyproject 和 tag却忘了 changelog 和 wheel parts, mismatch cut_release(0.1.5, 0.1.4, 0.1.5, 0.1.4) print(各组件版本, parts) print(不一致的组件, mismatch) assert mismatch, 复现成功四件套对不齐 if __name__ __main__: main()运行输出各组件版本 {pyproject: 0.1.5, changelog: 0.1.4, tag: 0.1.5, wheel: 0.1.4} 不一致的组件 [changelog, wheel]changelog和wheel还停留在旧版本正是手工分头发版错乱的抽象。五、解决方案第一层最小直接修复最小且必须的一步写一个release.py把四步串成单一脚本并强制要求先给一个目标版本号脚本统一改写所有位置# fix_layer1.py import re, subprocess, sys from pathlib import Path def bump_version(target: str): # 1) 改 pyproject.toml p Path(pyproject.toml) txt p.read_text() txt re.sub(rversion\s*\s*[^], fversion {target}, txt) p.write_text(txt) # 2) 改 __init__ 里的 __version__ init Path(src/mypkg/__init__.py) if init.exists(): it init.read_text() it re.sub(r__version__\s*\s*[^], f__version__ {target}, it) init.write_text(it) # 3) 打 tag subprocess.run([git, tag, fv{target}], checkTrue) return target if __name__ __main__: bump_version(sys.argv[1])这一层让版本号从单一入口流向所有文件消除改了这个漏了那个。但仍需人记得先写 changelog、且没校验一致性。六、解决方案第二层结构性改进把 release 做成带校验的单一流程脚本读取所有真相源断言它们一致后才允许发布changelog 由脚本从上一个 tag 的 PR 列表自动汇总发布前跑一遍四件套一致性门禁# fix_layer2.py from dataclasses import dataclass from pathlib import Path import re, subprocess dataclass(frozenTrue) class ReleaseSpec: target: str class ReleaseManager: def pyproject_ver(self) - str: m re.search(rversion\s*\s*([^]), Path(pyproject.toml).read_text()) return m.group(1) def init_ver(self) - str: p Path(src/mypkg/__init__.py) m re.search(r__version__\s*\s*([^]), p.read_text()) return m.group(1) if m else ? def changelog_top(self) - str: first Path(CHANGELOG.md).read_text().splitlines()[0] m re.search(r##\s*\[?v?([\d.]), first) return m.group(1) if m else ? def last_git_tag(self) - str: out subprocess.run([git, describe, --tags, --abbrev0], capture_outputTrue, textTrue) return out.stdout.strip().lstrip(v) def assert_consistent(self, target: str): 发布前门禁所有真相源必须等于 target。 assert self.pyproject_ver() target, pyproject 版本不一致 assert self.init_ver() target, __version__ 不一致 assert self.changelog_top() target, changelog 顶部版本不一致 assert self.last_git_tag() target, git tag 不一致 def release(self, target: str): self.assert_consistent(target) # 先校验再发布 subprocess.run([python, -m, build], checkTrue) subprocess.run([twine, upload, dist/*], checkTrue) # 用法一切就绪后 - ReleaseManager().release(0.1.5)要点assert_consistent把四件套一致变成发布前的硬门禁任何一处漏改直接中断changelog 应自动从上一 tag 到当前的 PR/commit 汇总此处省略抓取逻辑重点是自动而非手写release串行执行校验 - 构建 - 发布任一步失败即停不会有发布了一半的幽灵状态。七、解决方案第三层断言 / CI 守护写 pytest 验证release 门禁能抓出四件套不一致并可接进 CI 在每次发版前自动跑# test_release_consistency.py import pytest class FakeSpec: def __init__(self, pyproject, init, changelog, tag): self.pyproject_ver lambda: pyproject self.init_ver lambda: init self.changelog_top lambda: changelog self.last_git_tag lambda: tag def assert_consistent(self, target): for name, fn in [(pyproject, self.pyproject_ver), (init, self.init_ver), (changelog, self.changelog_top), (tag, self.last_git_tag)]: assert fn() target, f{name} 版本不一致 def test_consistent_passes(): s FakeSpec(0.1.5, 0.1.5, 0.1.5, 0.1.5) s.assert_consistent(0.1.5) # 全一致 - 通过 def test_inconsistent_caught(): s FakeSpec(0.1.5, 0.1.4, 0.1.5, 0.1.5) with pytest.raises(AssertionError): s.assert_consistent(0.1.5) # init 漏改 - 被抓 def test_old_tag_blocked(): s FakeSpec(0.1.5, 0.1.5, 0.1.5, 0.1.4) with pytest.raises(AssertionError): s.assert_consistent(0.1.5) # tag 没打 - 被抓把test_inconsistent_caught这类检查接进发版 CI任何版本错乱的发布都会被挡下。八、排查清单发版前按此清单自检四件套pyproject /__version__/ changelog / tag是否都为同一版本是否有一个脚本统一 bump而非手工改四处changelog 是否覆盖本周期全部 PR / issuegit tag是否推送git push --tagsCI 发布 job 是否阻塞式——失败必须红不能静默跳过发布前跑ReleaseManager().assert_consistent(target)门禁把第七节的 pytest 接进发版 CI自动挡下版本错乱。九、小结Cut a new release不是某个函数 bug而是发布流程缺单一、可重复、自带校验的入口版本号、changelog、tag、wheel 四件套由不同人 / 不同时间手工完成且无交叉一致性校验于是发出来的包和系统真相对不上且全程不报错。三层层级第一层用release.py从单一入口统一 bump 所有位置的版本号第二层用ReleaseManager把校验 - 构建 - 发布串成串行流程发布前跑四件套一致性门禁第三层pytest 验证门禁能抓出任意一处不一致锁进发版 CI。核心教训凡是多组件必须彼此一致的流程都不能靠手工纪律维持而要靠一个单一入口 发布前一致性断言来机械保证。流程型 bug 的解法永远是用程序替代人的记忆。