版本号管理与语义化版本

版本号这东西,看起来不就是几个数字加点吗?很多人这么想,但实际踩坑的不少。
我之前审计过一个项目,从 1.2.3 直接跳到了 2.0.0,结果发现只改了几个内部函数名,对外接口完全兼容。
这种随意升降级,直接把下游依赖搞崩了。所以今天好好聊聊语义化版本规范(SemVer),让版本号能真正「说话」。

语义化版本规范(SemVer)是什么

说白了,SemVer 是一套约定:版本号要能表达改动的影响范围。
格式是 主版本号.次版本号.补丁号,比如 2.3.1。
每一个数字的递增,都对应一种明确的变更级别。这样你一看就知道:到底要不要改代码、能不能放心升级。

核心原则:版本号必须按照规则递增,不能乱跳。

  • 补丁号(Patch):修复 bug,不新增功能,不影响兼容性
  • 次版本号(Minor):新增功能,但向后兼容
  • 主版本号(Major):做了不兼容的修改

我自己的习惯是,每次发布前先检查 Changelog,确认变更类型再决定升哪一位。否则很容易出现“主版本号一直 1,但接口早就面目全非”的情况。

版本号组成详解

标准格式:MAJOR.MINOR.PATCH。还可以附加预发布版本和构建元数据:

部分 含义 示例
MAJOR 不兼容的 API 修改 从 1.x 升到 2.x
MINOR 向下兼容的新功能 从 1.2.x 升到 1.3.x
PATCH 向下兼容的 bug 修复 从 1.2.3 升到 1.2.4
预发布 alpha/beta/rc,用短横线连接 1.2.3-alpha.1
构建元数据 构建标识,用加号连接,不参与优先级 1.2.3+build.20231001

预发布版本和构建元数据是可选的后缀,我一般在正式发布前用 -rc.1 做候选版本。这样团队内部可以先行验证,而用户知道这还没稳定。

小提示:构建元数据只用于构建信息传递,比如 CI 构建号;它不参与版本优先级比较。比如 1.0.0+build.1 和 1.0.0+build.2 是同一个版本。

如何判断该升主版本、次版本还是补丁版本

很多新手会纠结:到底升哪一位?我总结了一个简单决策流程:

  1. 有没有 API 不兼容的改动? 有 → 升主版本号(MAJOR)。
  2. 有没有新增功能,且向下兼容? 有 → 升次版本号(MINOR)。
  3. 只有 bug 修复,功能没变? → 升补丁号(PATCH)。
  4. 如果以上都发生了? 取最高级别:哪怕只加了一个 bug,但同时 API 不兼容,也必须升主版本。

举个例子:假如你修复了登录闪退(补丁),又加了个新接口(次版本),同时把老接口签名改了(主版本)——那你只能升主版本号,并把次版本和补丁版本重置为 0,变成 新MAJOR.0.0。

我之前在一家金融 SDK 公司审计过他们的升级流程。他们有个历史包袱:版本号从 1.9.0 升到 1.10.0 时,误认为次版本号可以随意跳,结果下游有家券商因为依赖了 ^1.9.0 自动拉到了 1.10.0,新功能里有个废弃接口直接报错。那一次我被叫去救火……后来我给他们强制加了规则:每次版本号变化都要经过 兼容性检查清单。

注意:SemVer 只适用于公开 API。如果你发的 SDK 没有稳定接口(比如内部工具库),建议先用 0.x 版本表示开发阶段。0.x 下可以随时做不兼容改动,但也要在文档里明确告知。

预发布版本与优先级

当版本号包含预发布标签时,优先级规则会变:

  • 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc < 1.0.0
  • 数字比较:1.0.0-alpha.2 > 1.0.0-alpha.10?不对!是字符串比较,所以 alpha.10 比 alpha.2 小(因为 '2' 字符比 '1' 大?别急,看规则)
  • 实际规则:先用点分隔成段,数值部分按数值比较,字母部分按字母序比较。例如 alpha.2 和 alpha.10:2 < 10(数值比较),所以 alpha.2 < alpha.10。

我建议尽量用纯数字做预发布版本号,比如 -rc.1、-rc.2,避免字母数字混合带来混乱。

常见陷阱与避坑指南

我曾经遇到的几个经典错误:

  • 「我发誓没改接口」——骗人:有人以为改了内部实现不影响版本号,但实际上修改了 @deprecated 标记的接口行为也算不兼容。
  • 「先升主版本,后面再降回来」:不可能的。版本号必须单向递增。一旦发布 2.0.0,就不能再发布 1.9.1,除非你走另一个分支。
  • 「构建元数据影响优先级」:不,1.0.0+build.100 和 1.0.0 是同一版本,不能用来区分灰度。

避坑指南:每次发布前,务必对照 API 变更清单,逐项确认兼容性。最好在 CI 中自动跑兼容性测试(比如 Java 用 japicmp,Go 用 gorelease),从工具层面防止手滑。

一个简单的 SemVer 决策流程图

下面这张图展示了我自己常用的版本号递增决策流程:

版本号递增决策流程 开始:检查本次变更 是否修改了公开API? 是 → 升主版本号 (MAJOR) 仅修复bug?新增功能? 仅bug修复 → 升补丁号 新增功能且向后兼容 → 升次版本号 同时有主版本变更 → 升主版本号 重置规则:升主版本时,次版本和补丁版本归零 例如:1.2.3 → 2.0.0

这个流程你可以在发布前快速过一遍,避免拍脑袋升版本。实际使用中,我还会配合 semver compare 工具(比如 npm 的 semver 包)来自动判断版本关系,减少人工失误。

// 一个简单的 SemVer 正则(用于校验格式)
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
// 你可以在 CI 脚本中用这个来校验版本号是否符合 SemVer

我个人的最佳实践:每次发布前,除了版本号,还要更新 CHANGELOG.md,按 ## [MAJOR.MINOR.PATCH] - YYYY-MM-DD 格式记录。这样审计时一眼就能看出版本升级周期和变更内容。

总结一下:版本号不是装饰,它是你和下游开发者之间的一份契约。尊重 SemVer,就是尊重彼此的信任。下次你看到某个库突然从 1.2.3 升到 2.0.0,你可以直接告诉同事:“别慌,先看 Changelog,大概率 API 有大变化。如果只是 bug 修复,那就是他们不遵守规范了。” 嗯,这也就是为什么我们要在审计中较真版本号的原因。