你有没有经历过这样的场景:线上出了 Bug,需要回滚到上一个稳定版本,结果发现最近的版本号是 1.0.3-fix2,再往前是 1.0.3-fix1 和 1.0.3-final,而到底哪个是真正稳定的,谁也说不清。这就是版本号管理失控的后果。一个好的版本号体系,不是为了让版本号“好看”,而是为了让每一个版本的意义一目了然。
一、为什么版本号不能随便打?
在聊规范之前,先想想版本号的本质是什么。
版本号是一个约定,它向所有使用这个制品的人传递信息。 当你看到 Spring Boot 3.2.0 -> 3.2.1,你知道这是一个小修小补;看到 2.7.0 -> 3.0.0,你知道这是个大动作——API 可能不兼容了。这种信息传递,建立在“大家都遵守同一个约定”的基础上。
如果版本号没有规则,会发生什么?1.0.1 可能是一次 Bug 修复,也可能是一次大重构;2.0 可能真的不兼容了,也可能只是感觉“差不多该升个版本号了”。当版本号失去意义,依赖管理就变成了碰运气。
二、SemVer:语义化版本的黄金标准
SemVer(Semantic Versioning)是目前应用最广的版本号规范。它的格式是:
MAJOR.MINOR.PATCH
用通俗的话说:
- MAJOR(主版本号):你做了不兼容的 API 修改。用了旧 API 的代码,升级到这个版本就得改。
- MINOR(次版本号):你新增了功能,但向下兼容。旧代码不用改就能升级。
- PATCH(修订号):你修复了 Bug,没有新增功能,向下兼容。
当一个版本号增加时,比它小的版本号都要归零。比如 1.2.3 升一个 MINOR,变成 1.3.0;升一个 MAJOR,变成 2.0.0。
SemVer 还定义了预发布版本(Pre-release)和构建元数据:
1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.1
优先级的排序是:1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc < 1.0.0。Maven、npm 等工具都会按照这个规则来判断“最新版本”。这也解释了为什么 1.0.0-SNAPSHOT 在 Maven 里永远不会被选中为发行版——SNAPSHOT 不是一个合法的 SemVer 预发布标识,它在 Maven 的版本比较体系里始终低于任何发行版。
SemVer 的完整字面规格可以在 semver.org 上找到。这里不再逐条翻译,但有几个值得留意的点:
一旦发布了版本化软件包,就必须不修改该版本的内容。任何修改都必须作为新版本发布。
主版本号为零(0.y.z)的软件处于初始开发阶段。一切都可能随时改变。此时的公共 API 不应被视为稳定。
这两条直接对应了上一篇文章聊的“制品的不可变性”和“快照版 vs 发行版”。
SemVer 不适用的情况:如果你的项目不向外暴露 API(比如一个单体应用、一个内部管理系统),SemVer 的 MAJOR.MINOR.PATCH 区分就没有太大意义。没有消费者需要关心你的 API 兼容性,你只需要一个能标识先后顺序的版本号。
三、CalVer:当你更关心的是时间
CalVer(Calendar Versioning)是按日历打版本号。常见的格式有:
| 格式 | 示例 | 适用场景 |
|---|---|---|
YYYY.MM.DD |
2024.03.15 |
Ubuntu 的版本策略(24.04、24.10) |
YYYY.MINOR.MICRO |
2024.1.0 |
Python 的 3.12.0(不完全 CalVer,但混合了时间) |
YY.MINOR.PATCH |
24.1.0 |
JetBrains IDE(2024.1) |
YYYY.MM |
2024.03 |
Unity 引擎 |
CalVer 的核心价值不在于语义,而在于时间信息。你一眼就知道这个版本是什么时候发布的、它大概有多老、是否需要升级。
什么场景适合 CalVer?
- 产品型的、没有明确 API 契约的项目:比如一个游戏引擎、一个 IDE、一个操作系统。用户关心“我是不是用着最新的版本”,而不是“API 是否兼容”。
- 定期发布节奏的项目:比如每月一版的内部工具、按季度发布的平台版本。
- 不需要区分“主版本号”和“次版本号”的场景:比如一个内部微服务,没有外部消费者,版本号纯粹是个标识。
CalVer 的局限也很明显:它不能传达兼容性信息。2024.03 和 2024.04 之间,可能只是一个文档修正,也可能把所有 API 都改了。如果项目有 API 兼容性要求,CalVer 就不够用了。
四、什么时候用什么?
这不是一道非此即彼的选择题。很多项目把两者结合起来:
- SemVer 为主,CalVer 为辅:有些项目在版本号中嵌入年份信息,比如
2024.1.0——第一个数字既是年份,也代表 MAJOR。 - 对外用 CalVer,对内用 SemVer:产品给用户的版本号用
2024.03,内部的 API 包用2.1.0。 - 用 CalVer 做版本标识,用 SemVer 做依赖管理:发布平台的版本用日期号,但内部的 SDK 和共享库坚持 SemVer。
简单的决策思路:
- 你的项目有外部消费者(API 使用者、库的用户)吗?→ 用 SemVer
- 你的项目是一个最终产品、没有 API 契约吗?→ 用 CalVer
- 两者都有?→ 产品对外用 CalVer,内部库用 SemVer
五、Maven/Gradle 里的版本号实践
在 Java 生态里,版本号的最终形态体现在 pom.xml 或 build.gradle 里。以 Maven 为例:
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.2.0</version>
Maven 的版本比较规则大体符合 SemVer,但也有自己的逻辑:1.0.0、1.0、1 在 Maven 里是等价的(Maven 会把版本按“.”拆分后逐段比较,不够的补零)。1.0.0-rc1 和 1.0.0 之间,Maven 知道 “-” 分隔的后缀是预发布标识,排在正式版前面。
几个值得注意的实践:
SNAPSHOT 的使用场景。 只在开发分支上用 SNAPSHOT。一旦准备发布,去掉 SNAPSHOT 后缀,打出正式版本。Maven Release Plugin 可以帮你自动完成这个过程:把 1.0.0-SNAPSHOT 改成 1.0.0,提交,打 Tag,再改成 1.0.1-SNAPSHOT。
版本号放哪里。 pom.xml 的 <version> 标签可以是硬编码的,也可以从属性中读取。多模块项目通常用 <revision> 属性统一管理,避免每个模块各自维护版本号。
<properties>
<revision>1.2.0</revision>
</properties>
<version>${revision}</version>
Gradle 的等价做法是在 gradle.properties 中定义 version=1.2.0,然后在 build.gradle 中引用。
版本号的自动化。 如果你的提交信息遵循 Conventional Commits(比如 feat:、fix:、BREAKING CHANGE:),那么版本号可以由工具自动推导。fix: 类型的提交 → 升 PATCH,feat: → 升 MINOR,BREAKING CHANGE → 升 MAJOR。这就是下一篇文章要聊的“变更日志自动化”的基础。
六、小结
版本号看似是件小事,但它是一个项目对外沟通的语言。选 SemVer 还是 CalVer,取决于你要向使用者传达什么信息:是“兼容还是破坏”(SemVer),还是“这是什么时候的版本”(CalVer)。
定好版本号规则之后,下一个问题就是:怎么让每一次版本发布都有迹可循?这就是变更日志发挥作用的地方。
每天前进一小步,就是一个新的高度!