语义化版本与自动发版实战:从提交规范到一键发布
忘了打 tag、CHANGELOG 漏项、下游被"小版本"搞崩——版本管理平时没人关心,出事全是它。这篇讲清 SemVer 的约定、"提交规范到版本号"的推断机制,以及一条从合并到发布的全自动流水线。
一、语义化版本(SemVer)约定了什么
版本号形如 MAJOR.MINOR.PATCH,三段各司其职:
| 段位 | 何时递增 | 一句话 |
|---|---|---|
| MAJOR | 有不向后兼容的变更 | 升级需要改代码 |
| MINOR | 新增向后兼容的功能 | 加东西了,不破坏旧用法 |
| PATCH | 向后兼容的问题修复 | 修 bug,放心升 |
两个容易忽略的规则:预发布标识——1.4.0-beta.2 里的 -beta.2 表示预发布,正式版必须大于同号预发布(1.4.0 > 1.4.0-beta.2);构建元数据——+ 后的内容不参与版本比较,只作标记。
一个高频误区:内部重构不该升 MAJOR——只要不改公开行为,对使用者就是透明的。0.x.y 阶段则特殊:公共 API 尚未稳定,MINOR 也允许破坏性变更,这是「1.0 之前别太当真」的由来。
版本号是发布者对全世界的承诺——破坏它,生态里的自动化就全成了赌博。
二、为什么版本号纪律值得认真对待
因为依赖解析完全建立在它之上。以 Node 生态为例:
| 写法 | 允许升级到 | 说明 |
|---|---|---|
^1.2.3 |
1.x 的最新版(小于 2.0.0) | 默认推荐,接受 minor 与 patch |
~1.2.3 |
1.2.x 的最新补丁 | 只接受 patch |
1.2.3 |
精确锁定 | 需要完全确定时用 |
* 或 latest |
任意版本 | 生产环境禁用 |
三、从提交规范自动推断版本号
版本号不该由人拍脑袋,它可以完全推导出来——这就是熟悉的提交规范:
| 提交类型 | 触发的版本变化 |
|---|---|
fix: … |
PATCH +1 |
feat: … |
MINOR +1(PATCH 归零) |
BREAKING CHANGE / 类型后加 ! |
MAJOR +1(MINOR、PATCH 归零) |
feat(cart): 支持优惠券叠加使用 → 1.4.2 → 1.5.0
fix(auth): 修复 token 过期未刷新 → 1.4.2 → 1.4.3
feat(api)!: 移除 v1 兼容字段 → 1.4.2 → 2.0.0
也就是说,提交规范不只服务于可读性,它还是发版流水线的输入。前面在 Git、Code Review、CI/CD 几篇里建立的纪律,在这里收回利息。
四、自动发版流水线:从合并到发布
一条完整的自动发版流程只有五步:触发(合并进 main)、分析(读取上个 tag 以来的提交,推断版本号)、产出(更新版本文件、生成 CHANGELOG、打 tag)、发布(创建 Release、推送制品)、通知(变更摘要发到团队频道)。
# .github/workflows/release.yml(示意)
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # 需要完整历史来读提交
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx semantic-release # 分析提交 → 定版本 → 打 tag → 发 Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
工具选型一句话:单包项目用 semantic-release(或 release-please);monorepo 用 changesets——后者支持每个包独立版本化,避免「改了一个包,全仓库一起蹦版本号」。
五、CHANGELOG:写给使用者,不是写给提交历史
自动生成的 CHANGELOG 最容易犯的错,是把提交标题原样堆上去。使用者只关心三类信息:升级后能多用什么(Added)、要改什么(Breaking,必须写迁移指引)、修好了什么(Fixed)。推荐 Keep a Changelog 的结构:Added、Changed、Deprecated、Removed、Fixed、Security。破坏性变更是 CHANGELOG 里唯一必须手写细节的部分——工具能告诉你「有破坏性变更」,但不能替你解释「改了什么、怎么改」。
六、预发布与灰度:把风险挡在正式版之前
- 预发布版本:从 next 或 beta 分支产出
1.5.0-beta.1,先让内测用户装一轮 - 晋升而非重打:正式版从预发布晋升,代码一致、只去掉预发布标识——避免「预发布测的和正式发的是两份代码」
- 发布不等于上线:版本发布是「制品可用」,是否对用户生效由功能开关与灰度决定——两道闸分开,才能「发了但没放量,有问题随时关」
七、五个常见坑
- 手工改版本号忘了打 tag:tag 才是版本的事实来源,漏打等于这次发布「不存在」——这正是自动化的最大动机
- CHANGELOG 由提交原文堆出来:内部重构、格式调整全写进去,使用者淹没在噪音里
- 破坏性变更不升 MAJOR:下游静默崩,是生态里最恶劣的行为之一
- 0.x 阶段完全放飞:下游无从适配;要么尽快收敛到 1.0,要么在 README 写清不稳定承诺
- monorepo 一把梭:所有包共用一个版本号,改一个包发全仓库——该用 changesets 按包版本化
八、落地路线:四步走
- 第 1 步 · 提交规范:还没做就先上 Conventional Commits 与 commitlint(已有则跳过)
- 第 2 步 · 选工具:单包 semantic-release,monorepo changesets
- 第 3 步 · 干跑验证:先开 dry-run 模式,确认推断的版本号与预期一致,再放开自动发布
- 第 4 步 · 写进团队手册:谁触发、怎么回滚(回退 tag + 重发上个版本)、破坏性说明谁补写
速查卡
| 场景 | 做法 |
|---|---|
| 判断该升哪一位 | 破坏性升 MAJOR、新功能升 MINOR、修复升 PATCH |
| 预发布 | 1.5.0-beta.1,正式版从预发布晋升 |
| 依赖范围 | 库用 ^,应用锁定精确版本 |
| 版本号从哪来 | 由提交规范自动推断,不手工改 |
| CHANGELOG | 只写使用者可感知的变更;破坏性写迁移指引 |
| monorepo | changesets 按包独立版本化 |
| 工具链 | 单包 semantic-release,多包 changesets |
写在最后
语义化版本看起来只是三个数字,实际是一份对使用者的公开承诺:哪些升级是安全的、哪些需要改代码。把提交规范接上自动发版,这份承诺就从「靠自觉」变成「由流水线执行」——版本号、CHANGELOG、tag、Release 永远一致,人只需要在破坏性变更上补一句迁移说明。
评论(0)