7AA7 博客

语义化版本与发布纪律

NullRef UID:87
2026-09-17 11:20
102 2 3

版本号是一种契约:它告诉使用方”升级这一步,我需要付出多少注意力”。1.4.2 → 1.4.3 意味着可以直接升,1.4.2 → 2.0.0 意味着要读变更说明、可能要改代码。契约一旦失真,版本号就退化成一个递增的序号,使用方只能选择永远不升或者每次都全量回归。

三段各自的契约

MAJOR.MINOR.PATCH:

  • MAJOR:包含不兼容的变更。使用方必须修改代码或配置才能升级。
  • MINOR:以向后兼容的方式增加功能。使用方不需要改动,可以直接升。
  • PATCH:以向后兼容的方式修复问题。使用方应该尽快升。

关键词是”兼容”而非”大小”。删掉一个函数可能只改一行,但它是 MAJOR;新增一个模块可能写了两千行,它是 MINOR。按改动量定版本的做法,是对这个契约最常见的误读。

另一个常被忽略的点:废弃(deprecation)不算破坏性变更。标记 @Deprecated 并保证在若干个 MINOR 版本内继续可用,属于 MINOR;真正移除它才是 MAJOR。

什么算破坏性变更

最容易漏判的不是删接口,而是”接口没变,行为变了”。

变更 是否破坏性 说明
删除或重命名公开函数/字段 是 编译或运行直接失败
函数参数顺序或个数变化 是 即使类型兼容
返回值类型变化 是 包括 null 变成抛异常
默认值变化(超时从 30s 到 10s) 是 使用方行为被悄悄改变
错误码 / 异常类型变化 是 依赖错误类型的分支逻辑会失效
边界行为变化(越界从抛错到静默截断) 是 语义变了
配置项含义或校验严格程度变化 是 配置文件不改但行为变了
依赖的最低运行时版本提升 是 使用方的环境可能不满足
输出格式变化(日志字段、序列化结构) 视约定 若文档承诺过格式,算破坏性
新增可选参数且有默认值 否 但要确认语言层面不会造成重载歧义
修复一个明显错误的计算结果 视情况 有人可能依赖了这个错误行为

表格末行这条在真实世界里最棘手:修复 bug 会改变行为。处理办法是在变更说明里明确写出,并在必要时提供一个过渡开关,让使用方主动选择新行为。

判断标准可以统一成一句话:任何让”升级前能正常工作的代码”在升级后行为不同的改动,都是破坏性的。

对库作者来说,还有一个反直觉的点:放宽校验(原来拒绝的输入现在接受)通常算兼容,收紧校验(原来接受的输入现在拒绝)算破坏性。

0.x 版本的特殊性

SemVer 规定:主版本号为 0 时,公共 API 可以随时改变,MINOR 也可以包含破坏性变更(实践中通常约定 0.x 阶段的 MINOR 即破坏性,0.x.y 的 PATCH 是兼容修复)。

这给了早期项目快速迭代的空间,代价是使用方无法从版本号获得任何安全感。因此:

  • 库在 0.x 阶段应该明确写”API 未稳定”,并且把变更说明写细。
  • 依赖 0.x 的库时,用精确版本或锁定版本,不要用范围声明——否则一次自动升级就可能引入破坏。
  • 一旦进入 1.0.0,就要按完整契约执行。1.0.0 的意义不是”功能完整了”,而是”我愿意对兼容性负责”。迟迟不发 1.0 等于长期把不确定性转嫁给使用方。

从 0.x 到 1.0.0 之前值得做的一件事是:把 API 表面过一遍,删掉那些不该暴露的东西。1.0 之后再改,成本要高得多。

预发布标签与构建元数据

完整格式是 MAJOR.MINOR.PATCH[-prerelease][+build]。

  • 预发布:1.4.0-alpha.1、2.0.0-rc.1。带预发布标签的版本低于对应的正式版(1.4.0-alpha.1 < 1.4.0)。排序规则是按标识符逐段比较,数字段按数值比(alpha.2 < alpha.10),字母按字典序。
  • 构建元数据:1.4.0+build.20261021。它不参与优先级比较,两个只有构建元数据不同的版本视为同一版本。

用法上的区别:预发布用于”还不够稳,但想让人试”;构建元数据用于标记来源(构建号、提交哈希),不影响版本语义。不要在依赖声明里依赖构建元数据做区分,很多包管理器会忽略它。

预发布的排序规则值得注意:rc 与 beta 的字母序决定了它们的先后,所以 beta < rc 这类约定是人为的,团队里要写清楚并保持一致。

依赖范围:^ 与 ~

声明 允许范围(以 1.4.2 为例) 风险
1.4.2 仅此版本 需要手工升级,但完全可控
~1.4.2 >=1.4.2 <1.5.0 只接受补丁,风险最低
^1.4.2 >=1.4.2 <2.0.0 接受任何 MINOR,风险中等
^0.4.2 >=0.4.2 <0.5.0 对 0.x,多数包管理器把 ^ 限制在 MINOR 内
>=1.4.2 无上限 会把 MAJOR 也拉进来,几乎总是错的
* 任意 等于把兼容性判断交给运气

^ 的风险在于它把”每次 MINOR 发布”变成了对使用方的隐式回归测试。理论上 MINOR 是兼容的,实际上:新功能可能引入行为差异,新增依赖可能冲突,性能特征可能变化。经验上,库作者对兼容性的判断和使用方的实际感受经常不一致。

更麻烦的是传递依赖:你的直接依赖用 ^,它的依赖也用 ^,最终解析出的组合可能从未被人测试过。这是”昨天还能构建,今天就不行”的常见来源。

锁文件的必要性

锁文件记录的是”当前这次安装解析出的完整依赖树,精确到每个包的版本与哈希”。它的作用不只是可复现构建:

  • 消除”同一份代码在不同机器上装出不同依赖”的问题。
  • 让升级变成显式动作:改锁文件需要一次提交,可以评审、可以回滚。
  • 提供完整性校验(哈希),防止仓库内容被替换。

应用(最终部署的东西)必须提交锁文件。库是否提交锁文件有争议:提交能保证贡献者的开发环境一致,不提交则避免把自己的解析结果强加给使用方。折中做法是库在 CI 里用锁文件保证可复现,但不把它作为对使用方的约束。

配套的实践是:定期(例如每周)用一个自动化任务跑”升级依赖 + 测试 + 提 PR”,把升级拆成可评审的小步,而不是攒半年一次性升几十个包。

CHANGELOG 的写法

CHANGELOG 的读者是使用方,不是提交者。它要回答的是”升级这个版本,我需要做什么”。

按类型分组的结构:

## [2.1.0] - 2026-10-21

### Breaking Changes
- `Client.SetTimeout` 参数由秒改为 `time.Duration`。
  迁移:`SetTimeout(30)` → `SetTimeout(30 * time.Second)`
- 移除 1.8 起标记废弃的 `LegacyParse`,替代为 `Parse`。

### Added
- 新增 `WithRetry` 选项,支持自定义重试策略。

### Fixed
- 修复并发调用 `Close` 时偶发 panic 的问题 (#482)。

### Deprecated
- `ParseV1` 将在 3.0.0 移除,请迁移到 `Parse`。

### Migration Guide
详见 docs/migration/v2.md

几条规则:

  • 面向使用者:写”改了什么、要做什么”,而不是”重构了 parser 模块”。内部重构不进 CHANGELOG。
  • 破坏性变更放最前面,并且必须带迁移步骤。只说”行为有变化”而不说怎么改,等于没有。
  • 关联 issue 编号,方便追溯背景。
  • 不写提交历史。CHANGELOG 不是 git log 的输出。
  • 版本与日期齐全,未发布的变更放在 Unreleased 段落下。

自动生成工具能帮你收集提交信息,但提交的措辞往往不是给使用者看的。有效的做法是:提交信息保持工程化,CHANGELOG 在发布时人工整理一次。这个整理过程也是一次发布前的自查——如果写不出”这个版本给用户带来了什么”,通常说明这次发布的范围没想清楚。

1.8k浏览量
102赞
2评论
2参与用户
9 分钟阅读时间

评论(2)

  • 舒野 · 2026-09-17 13:29

    写得挺好,就是希望多几个反例。

    回复
  • AsyncLab · 2026-09-17 22:56

    不知道为什么,看到「不要这样做」那段我膝盖中了一箭。

    回复