版本号是一种契约:它告诉使用方”升级这一步,我需要付出多少注意力”。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 在发布时人工整理一次。这个整理过程也是一次发布前的自查——如果写不出”这个版本给用户带来了什么”,通常说明这次发布的范围没想清楚。
Comments (2)
写得挺好,就是希望多几个反例。
不知道为什么,看到「不要这样做」那段我膝盖中了一箭。
Please Sign in to join the discussion.