给项目发一个新版本,代码改完了,版本号却不一定好定。

修复了一个影响很大的 Bug,要不要直接升到 2.0.0?新增了不少代码,但旧功能照常使用,又该怎么编号?还有常见的 1.0.0-beta.1,它和 1.0.0 到底差在哪里?

语义化版本(Semantic Versioning,简称 SemVer)提供了一套约定:用三段数字说明一次更新对使用者的影响。判断时,先看原有使用方式是否仍然成立,再看这次增加了功能,还是修正了错误。

这篇从 SemVer 2.0.0 中文规范出发,用一个虚构的文章列表组件举例。它已经发布了 1.4.2,其他项目通过 listPosts() 读取文章。下面分别看看几种改动该怎样编号。

三段数字分别是什么

1
2
3
4
1 . 4 . 2
│ │ └─ 修订号 Patch
│ └───── 次版本号 Minor
└───────── 主版本号 Major

对于已经进入 1.0.0 及以后阶段的项目,可以先按这张表判断:

这次发布的变化 从 1.4.2 升到 使用者需要关注什么
兼容原有用法的问题修复 1.4.3 哪个错误得到了修正
兼容原有用法的功能新增 1.5.0 新功能怎样使用
公共 API 出现不兼容修改 2.0.0 旧代码需要怎样迁移

这里的公共 API,是项目明确提供给别人依赖的接口和行为。它可以是函数的参数、返回值,也可以是有文档约定的命令行选项或配置格式。

先写清楚这些约定,才能判断更新是否兼容。假如文档承诺 listPosts() 返回文章数组,调用方就有理由写出这样的代码:

1
2
const posts = listPosts();
posts.map(post => post.title);

后续修改要考虑这段代码还能不能按约定工作。

从 1.4.2 出发,修复、新功能和不兼容修改分别对应 1.4.3、1.5.0 和 2.0.0

三个独立的升级选择,按本次变更对公共 API 的影响判断。

修复 Bug 后,版本号怎么升

假设组件约定按发布时间从新到旧排列文章,但某些日期被当成字符串错误比较,导致排序错乱。修正比较逻辑后,函数参数、返回类型和排序约定都保持原样。

这次可以发布为:

1
1.4.2 → 1.4.3

即使排查花了三天,最后修改了几百行,判断仍然取决于修复对使用者的影响。严重 Bug 也可能通过修订版本修复。

不过,写着“修复 Bug”并不代表一定适合只升修订号。

例如,原先公开约定文章 ID 可以是字符串,现在为了简化校验,只允许传数字。原本合法的 getPost("42") 开始报错,这就改变了使用约定。不能因为提交说明写了 fix,便忽略兼容性变化。

另一种更难处理的情况是:某个行为确实不符合文档,但已经被大量使用者依赖。此时需要评估迁移成本,说明受影响范围,必要时通过主版本发布。规范的 FAQ 也讨论了这种修复对使用者造成重大影响的情况。参考:SemVer FAQ

新增功能后,版本号怎么升

接下来,希望调用方可以选择文章分类,于是给 listPosts() 增加一个可选参数:

1
2
3
4
5
// 原有调用仍然返回全部文章
listPosts();

// 新增按分类筛选的用法
listPosts({ category: "编程开发" });

只要原有调用的行为保持兼容,这属于功能新增。如果上一个版本已经是 1.4.3,这次就发布 1.5.0。

注意最后一段归零了,不是接着写 1.5.3。次版本递增时,修订号从零开始。

如果这次同时修了几个 Bug,也可以一起包含在 1.5.0 中。一批变更可以在同一个版本发布,版本号按其中需要的最高级别确定。

还有一种容易漏掉的变化:把公共 API 标记为弃用,也需要递增次版本号。 “弃用”表示建议使用者开始迁移,旧接口此时仍然可用;直接删除则是另一回事。参考:SemVer 第 7、8 条

哪些改动需要升主版本号

组件后来要支持分页。开发者决定把返回值从数组改成包含总数的对象:

1
2
3
4
5
6
7
8
// 旧返回值
[{ id: 1, title: "第一篇文章" }]

// 新返回值
{
items: [{ id: 1, title: "第一篇文章" }],
total: 48
}

新结构看起来更方便,但之前的 posts.map(...) 已经不能用了。调用方必须改成 result.items.map(...)。

如果保留 listPosts() 这个名字却直接改变它的返回约定,就应该通过新的主版本发布,例如从 1.5.0 升到 2.0.0,并提供迁移说明。

也有兼容的做法:保留原函数,另加 listPostsPage() 返回分页对象。新增能力没有破坏旧接口,就可以按次版本发布。同一个功能需求,采用不同的接口设计,可能对应不同的版本级别。

删除公开函数、把可选参数改成必填、改变文档承诺的默认行为,都需要检查旧代码是否仍然有效。一次很小的接口修改,也可能需要升主版本号。

0.x.y 和 1.0.0 有什么区别

0.x.y 表示初始开发阶段,公共 API 尚未形成稳定承诺。使用者不能仅凭主版本号没变,就认为所有更新都兼容。

1.0.0 则是一个明确的起点:项目开始围绕已定义的公共 API 管理兼容性。它不意味着软件没有 Bug,也不要求把设想中的所有功能都实现。

以这个组件为例,如果参数、返回结构还在频繁试验,保留 0.x.y 很合理。等到其他项目已经依赖这些接口,就需要认真决定哪些行为要长期支持,并为它们补上文档和验证。

不同生态还可能有额外约定。例如 Go 的模块版本文档说明,v2 及以上主版本通常要在模块路径中体现版本后缀。这是 Go 的模块管理规则,不能照搬为所有项目的要求。

版本号后面的 alpha、beta、rc 是什么

正式发布 2.0.0 前,可以先提供试用版本,让调用方提前检查迁移是否顺利:

1
2
3
4
2.0.0-alpha.1
2.0.0-beta.1
2.0.0-rc.1
2.0.0

这些带连字符的后缀属于先行版本标识,实际项目也常称为预发布版本。alpha、beta、rc 是常见命名习惯;各阶段具体做多少测试、开放给哪些人,要看项目自己的说明。

比较版本时,相同三段数字下的先行版本低于正式版本。标识中的独立数字按数值比较,所以 beta.2 排在 beta.11 前面。

还可能看到这样的编号:

1
2.0.0+build.123

加号后面是构建元数据,可以用于记录构建身份,但不参与 SemVer 的优先级比较。不能据此把 2.0.0+build.124 判为比 2.0.0+build.123 更高的语义化版本。参考:SemVer 第 9—11 条

从 alpha、beta、rc 到正式版本的顺序,以及独立展示的构建元数据

预发布命名示例;构建元数据不参与版本优先级比较。

升级依赖时,版本号能告诉我们什么

给自己的项目定版本,是在向使用者说明变化;安装别人的依赖,则是在解读对方的承诺。

以 npm 为例,package.json 中的范围符号决定了哪些版本允许被选择。下面只比较正式版本,预发布版本另有匹配条件:

声明 允许的正式版本范围
1.4.2 仅 1.4.2
~1.4.2 大于等于 1.4.2,小于 1.5.0
^1.4.2 大于等于 1.4.2,小于 2.0.0
^0.2.3 大于等于 0.2.3,小于 0.3.0

这也解释了为什么不能把 ^ 一概理解成“固定主版本号”。在主版本为零的情况下,它的范围更窄。具体匹配规则见 npm/node-semver 文档。

允许安装的范围也不等于当前实际安装的版本;检查项目时还要结合锁文件与已安装的依赖。

即使版本号表示兼容,升级也可能遇到回归错误或维护者判断失误。实际操作时,我更建议先看变更记录,再验证项目依赖的关键行为。例如这个文章组件,就至少要检查旧调用、排序结果和分页返回值。

发布前,把变化写给使用者看

给上述虚构组件整理一份简短发布记录,可以写成:

1
2
3
4
5
6
7
8
9
1.4.3
修复:文章发布时间排序错误,原有调用方式保持不变。

1.5.0
新增:listPosts 支持可选的 category 筛选参数。

2.0.0
变更:listPosts 返回值由数组调整为 { items, total }。
迁移:将 posts.map(...) 改为 result.items.map(...)。

版本号提示变化级别,发布记录说明具体影响。Keep a Changelog建议为人整理有意义的变更,而不是把所有提交记录直接堆给读者。

如果项目采用约定式提交,fix、feat 和 BREAKING CHANGE 可以为发布工具提供判断依据。但自动化依赖准确的提交描述;接口已经不兼容时,仅仅写上 fix 无法让它变得兼容。

已经发布的版本内容也应保持不变。发现问题后发一个新版本,并交代修正内容,避免两位使用者拿着相同版本号,却得到不同代码。

以后再纠结该升哪一位,可以先把这次更新写成一句面向使用者的话:原有用法是否要改?如果不用改,这次是增加了能力,还是修好了原本承诺的行为?版本号通常就有了答案。