51.3 语义化版本与变更日志
一句话
版本号是对使用者的一份承诺:major 说“会不会坏”,minor 说“多了什么”,patch 说“只修了 bug”;变更日志是这份承诺的可读账本——两者都必须由机器从提交规范里算出来,靠人手维护必然漂移。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “版本号随便打,发出去再说” | 版本号一旦被依赖(锁文件、镜像 tag、客户合同、灰度规则),改号就是事故。语义化的价值不是“好看”,而是让升级决策不需要读代码 |
| “0.x 是测试版,破坏性改动不用管” | 0.x 的官方语义是“初始开发期,任何东西都可能变”。但已经上生产的 0.x 更要写清 breaking——因为 semver 规定 0.x 的破坏性改动只 bump minor(0.3.0 → 0.4.0),工具不会替你跳 major,用户更不会收到“这是大版本”的警告 |
| “内部服务不需要版本号” | 只要有 ≥ 2 个消费者并行部署,就需要“这次改了什么、能不能一起发”。区别只是用版本号还是用部署时间戳表达;后者无法回滚到“上一个已知好” |
“变更日志 = git log” | git log 是给作者看的:fix: typo、wip、Merge branch... 混在一起。changelog 是给使用者看的:要分组、要写影响、要说怎么迁移 |
三个数字各管一件事
| 位 | 变化意味着 | 使用者的动作 | 例子 |
|---|---|---|---|
| major | 有不兼容变更:删了 API、改了签名、改了语义 | 必须读迁移说明,改代码,回归测试 | 1.9.0 → 2.0.0 |
| minor | 向后兼容地新增能力 | 可以无脑升级,想用新功能才改代码 | 1.4.2 → 1.5.0 |
| patch | 向后兼容地修缺陷(只改行为中“错的那部分”) | 应该尽快升级,风险最低 | 1.4.2 → 1.4.3 |
判错的成本不对称:该 bump major 却只 bump 了 minor(用户被迫踩坑、线上 500)远比该 bump minor 却 bump 了 major(用户多读一遍 changelog)严重。所以判定有疑问时,往大里 bump。
什么改动 bump 什么:一张判定表
| 改动 | 版本位 | 为什么 |
|---|---|---|
| 删除公开函数 / 字段 / 接口方法 | major | 调用方编译不过 |
| 函数签名增加必填参数 | major | 调用方编译不过 |
| 新增可选参数 / 新增函数 / 新增字段 | minor | 旧调用方不受影响 |
| 新增枚举值 | minor(多数语言)/ major(对 switch 穷尽的语言与严格的客户端) | 老代码可能走 default 分支或 panic,属于语义不兼容 |
| 修 bug,但有人依赖了错误行为 | major | 行为变更是破坏性的,不管初衷多正确;判定与弃用流程见 55.5 向后兼容 |
| 改变默认值(超时 3s → 10s、重试 3 次 → 5 次、分页 20 → 50) | major | 用户没改代码,但容量与延迟模型全变 |
| 改变日志格式 / 错误码 / 排序 | major(若被程序消费) | 监控与客户端解析会崩 |
| 只改内部实现、性能优化、可读性 | patch(或不动版本) | 对使用者不可见 |
| 依赖大版本升级但对外行为不变 | patch / minor | 只要 API 与语义不变,就不算破坏 |
| 标记废弃(deprecated)但保留 | minor | 提前一个版本告诉用户要改 |
路径 / 包名变更(如 Go 模块 /v2) | major | 导入路径变化 = 源码不兼容 |
Go 生态有一条额外的硬规则:major ≥ 2 的模块,模块路径必须带 /v2 后缀(module example.com/lib/v2)。不这么做的话,go get example.com/lib@v2.0.0 会失败,只能拿到 +incompatible 的降级形态,等于放弃了模块语义。这条规则让“破坏性升级”在导入路径层面就是显式的,比 changelog 更硬。
排序陷阱:git 的 v:refname 不是 semver
发布脚本最常写错的一行是“取最新 tag”:
$ git tag
v1.10.0
v1.2.0
v1.9.0
v2.0.0-rc.1
v2.0.0-rc.2
$ git tag --sort=v:refname
v1.2.0
v1.9.0
v1.10.0
v2.0.0 # ← 正式版被排在了 rc 前面?
v2.0.0-rc.1
v2.0.0-rc.2
$ git for-each-ref --sort=-v:refname --format='%(refname:short)' refs/tags | head -1
v2.0.0-rc.2 # ← “最高版本”变成了 rc.2
git 的 v:refname 排序不是 semver 排序:它把 v2.0.0 当作 v2.0.0-rc.1 的前缀,短字符串排前面。而 semver 2.0.0 §11 规定:预发布版小于对应的正式版(2.0.0-rc.2 < 2.0.0)。后果很直接:用 --sort=-v:refname | head -1 的脚本,会在发了 v2.0.0 之后仍然认为 v2.0.0-rc.2 是最新版,把发布号打回去,或者跳过正式版。
正确做法是自己按 semver 规则比较,不要依赖字符串排序:
package main
import (
"fmt"
"sort"
"strconv"
"strings"
)
// 按 semver.org 2.0.0 §11 比较:先主.次.修订,再 prerelease。
// build metadata(+ 之后)不参与比较——最常被写错的一条。
type semver struct{ core [3]int; pre []string }
func parse(s string) (semver, bool) {
var v semver
s = strings.SplitN(s, "+", 2)[0] // 丢掉 build metadata
core, pre, has := strings.Cut(s, "-")
parts := strings.Split(core, ".")
if len(parts) != 3 {
return v, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return v, false
}
v.core[i] = n
}
if has {
v.pre = strings.Split(pre, ".")
}
return v, true
}
func num(s string) (int, bool) { n, err := strconv.Atoi(s); return n, err == nil }
func less(a, b semver) bool {
for i := 0; i < 3; i++ {
if a.core[i] != b.core[i] {
return a.core[i] < b.core[i]
}
}
if len(a.pre) == 0 && len(b.pre) == 0 {
return false
}
if len(a.pre) == 0 { // 正式版 > 任何预发布版
return false
}
if len(b.pre) == 0 {
return true
}
for i := 0; i < len(a.pre) && i < len(b.pre); i++ {
an, aok := num(a.pre[i])
bn, bok := num(b.pre[i])
switch {
case aok && bok:
if an != bn {
return an < bn
}
case aok != bok: // 数字标识符 < 字母标识符
return aok
default:
if a.pre[i] != b.pre[i] {
return a.pre[i] < b.pre[i] // ASCII 序
}
}
}
return len(a.pre) < len(b.pre) // 前缀相同,标识符少的更小
}
func main() {
spec := []string{ // semver.org §11 给出的优先级链,故意打乱
"1.0.0", "1.0.0-rc.1", "1.0.0-beta.11", "1.0.0-beta.2",
"1.0.0-beta", "1.0.0-alpha.beta", "1.0.0-alpha.1", "1.0.0-alpha",
}
vs := make([]semver, len(spec))
for i, s := range spec {
vs[i], _ = parse(s)
}
sort.Slice(vs, func(i, j int) bool { return less(vs[i], vs[j]) })
for i, v := range vs {
s := fmt.Sprintf("%d.%d.%d", v.core[0], v.core[1], v.core[2])
if len(v.pre) > 0 {
s += "-" + strings.Join(v.pre, ".")
}
fmt.Printf("%d. %s\n", i+1, s)
}
a, _ := parse("1.0.0+build.1")
b, _ := parse("1.0.0+build.999")
fmt.Printf("\n1.0.0+build.1 < 1.0.0+build.999 ? %v(应为 false)\n", less(a, b))
}
输出(本机实测):
1. 1.0.0-alpha
2. 1.0.0-alpha.1
3. 1.0.0-alpha.beta
4. 1.0.0-beta
5. 1.0.0-beta.2
6. 1.0.0-beta.11
7. 1.0.0-rc.1
8. 1.0.0
1.0.0+build.1 < 1.0.0+build.999 ? false(应为 false:build 不参与优先级)
🔑 三条最容易写错的规则:数字标识符比字母小(
beta.2 < beta.11,但alpha.beta < beta);标识符少的更小(alpha < alpha.1);build metadata 不参与排序(1.0.0+a与1.0.0+b同优先级)。任何“按字符串排序 tag”的脚本,都会在这三条上翻车。
从提交算出下一个版本:Conventional Commits
手工判定“这次该发什么号”不可持续。做法是把提交信息变成结构化输入(Conventional Commits 1.0.0),再用一条固定映射算出号:
| 提交类型 | 含义 | changelog 分组 | 版本位 |
|---|---|---|---|
feat: | 新功能 | Added | minor |
fix: | 修缺陷 | Fixed | patch |
perf: | 性能优化(不改语义) | Performance | patch |
refactor: / style: / test: / ci: / build: | 内部改动 | 通常不进 changelog | 不 bump |
docs: | 文档 | Docs(可选) | 不 bump |
chore: | 杂务、依赖升级 | 通常不进 changelog | 不 bump |
任意类型 + !(如 feat(api)!:) | 破坏性变更 | Changed(置顶) | major |
任意类型 + BREAKING CHANGE: 页脚 | 破坏性变更 | 同上 | major |
两个工程细节决定这套机制能不能落地:
- squash merge 时,落地的那条提交信息才是输入。所以规范要约束 PR 标题,而不是每个 WIP 提交。PR 模板里放一行“标题必须是
type(scope): 描述”,CI 校验标题即可。 - release 提交本身要排除(
chore(release): v1.5.0),否则 changelog 会自我引用。
可运行的 changelog 生成器
下面这个程序读 git log,按上表分组,并算出下一个版本号。它同时说明了一件事:feat! 会同时出现在 Added 和 BREAKING 两个分组里——这不是 bug,一条提交可以既是新功能又是破坏性变更。
package main
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
)
// 一条 Conventional Commit:type(scope)!: subject
var head = regexp.MustCompile(`^(?P<type>[a-z]+)(?:\((?P<scope>[^)]*)\))?(?P<bang>!)?: (?P<subj>.+)$`)
type commit struct{ typ, scope, subject, body string; bang bool }
func main() {
rng := "v1.0.0..HEAD"
if len(os.Args) > 1 {
rng = os.Args[1]
}
// 每条提交一个记录:字段用 \x00 分隔、记录用 \x01 分隔,避免 subject 里的换行串行
out, err := exec.Command("git", "log", "--reverse", "--format=%s%x00%b%x01", rng).Output()
if err != nil {
panic(err)
}
var cs []commit
for _, rec := range strings.Split(string(out), "\x01") {
rec = strings.TrimSpace(rec)
if rec == "" {
continue
}
f := strings.SplitN(rec, "\x00", 2)
m := head.FindStringSubmatch(f[0])
if m == nil {
continue // 不符合规范的提交直接跳过,而不是猜
}
c := commit{typ: m[1], scope: m[2], bang: m[3] == "!", subject: m[4]}
if len(f) == 2 {
c.body = f[1]
}
cs = append(cs, c)
}
groups := []struct{ key, title string }{
{"feat", "Added"}, {"fix", "Fixed"}, {"perf", "Performance"}, {"refactor", "Changed"},
}
fmt.Println("## [Unreleased]")
for _, g := range groups {
var items []string
for _, c := range cs {
if c.typ != g.key {
continue
}
prefix := ""
if c.scope != "" {
prefix = fmt.Sprintf("**%s**: ", c.scope)
}
items = append(items, "- "+prefix+c.subject)
}
if len(items) > 0 {
fmt.Printf("\n### %s\n%s\n", g.title, strings.Join(items, "\n"))
}
}
var breaking []string
for _, c := range cs {
if c.bang || strings.Contains(c.body, "BREAKING CHANGE:") {
breaking = append(breaking, "- "+c.subject)
}
}
if len(breaking) > 0 {
fmt.Printf("\n### Changed (BREAKING)\n%s\n", strings.Join(breaking, "\n"))
}
// 下一个版本号:有 breaking → major,否则有 feat → minor,否则 fix → patch
next := ""
for _, c := range cs {
switch {
case c.bang || strings.Contains(c.body, "BREAKING CHANGE:"):
next = "major" // 一旦出现 breaking,就是 major,不再降级
case c.typ == "feat" && next == "":
next = "minor"
case c.typ == "fix" && next == "":
next = "patch"
}
}
major, minor, patch := 1, 0, 0
switch next {
case "major":
major++
minor, patch = 0, 0
case "minor":
minor++
patch = 0
case "patch":
patch++
default:
fmt.Println("\n没有 feat/fix/breaking:不发布新版本(只发 preview 或跳过)")
return
}
fmt.Printf("\n版本号:v%d.%d.%d → v%d.%d.%d\n", 1, 0, 0, major, minor, patch)
}
输出(本机实测,区间 v1.0.0..HEAD,共 7 条提交):
## [Unreleased]
### Added
- **export**: 支持批量导出 CSV
- **export**: 支持异步任务取消
- **api**: 删除 /v1/export 旧接口
### Fixed
- **export**: 修复中文文件名乱码
- **auth**: 令牌刷新失败时不再强退
### Changed (BREAKING)
- 删除 /v1/export 旧接口
版本号:v1.0.0 → v2.0.0
注意三处:feat(api)! 同时出现在 Added 与 BREAKING(正确);docs: / chore: 没有进 changelog(正确,使用者不关心);版本号一路跳到 v2.0.0,因为 breaking 优先于 feat 与 fix——判定的优先级是 major > minor > patch,不是“看最后一个提交”。
变更日志的写法:Keep a Changelog 的六类
| 分类 | 装什么 | 常见错误 |
|---|---|---|
| Added | 新功能、新接口、新配置项 | 把内部重构也写进来 |
| Changed | 行为/默认值/依赖的变化(含 breaking) | breaking 藏在 Added 里,用户升级时才发现 |
| Deprecated | 标记废弃但还能用 | 不写“什么时候会删”,用户继续用 |
| Removed | 已删除的功能/接口 | 不写替代方案,用户只能去翻代码 |
| Fixed | 缺陷修复 | 写成“修复了一个问题”,不写触发条件 |
| Security | 安全修复 | 与 Fixed 混在一起,安全团队无法快速筛查 |
写法只有一条原则:每条都回答“对我有什么影响、我要不要做点什么”。
| 坏例子 | 好例子 |
|---|---|
fix: 修复导出问题 | Fixed: 导出文件名含中文时乱码(影响 1.2.0–1.3.2;升级即修复) |
Changed: 调整默认配置 | Changed: 客户端默认超时 3s → 10s。长事务场景请检查下游熔断阈值(见 [35.6 熔断与降级](../../L3-质量属性/35-可用性/35.6-熔断与降级.md)) |
Removed: 删除旧接口 | Removed: 删除 POST /v1/export,改用 POST /v2/exports(请求体见迁移说明)。v2.0.0 起 410 Gone |
变更日志 ≠ 发布说明:changelog 是“历史上发生过什么”的完整账本,按版本累积;release notes 是“这一次升级你要做什么”的摘要,可以只挑重点。两者都从同一份提交数据生成,但面向不同读者。
落地:指名 / 可抄 / 可验 / 可退
指名:自动化发布用 semantic-release(Node 生态,提交即发布)、release-please(Google,基于 PR 的发布 PR,适合受保护主干)、git-cliff(Rust,单二进制,适合任意语言,按模板生成 changelog);Go 项目常用 GoReleaser(tag 触发构建 + changelog)。打 tag 与推送用 git 自带能力,不要手写脚本拼版本号。
可抄(打 tag 与生成,git-cliff 部分未验证:本机未安装):
# ① 打附注 tag(附注 tag 才记录打 tag 的人与时间,方便审计)
git tag -a v1.4.0 -m "v1.4.0: 导出支持异步取消"
git push origin v1.4.0
# ② 用上面的 Go 程序生成本次区间的 changelog(本机可跑)
go run changelog.go v1.3.0..HEAD
# ③ 用成熟工具生成到文件(未验证)
git-cliff --tag v1.4.0 --unreleased -o CHANGELOG.md
# ④ 只看两次发布之间的提交标题,人工核对有没有漏掉 breaking
git log --format='%s' v1.3.0..v1.4.0
可验(本机真实输出):两条断言——① semver 比较器必须输出 1.0.0-alpha … 1.0.0 这条链(顺序错一位就是实现错);② git tag --sort=v:refname 与 --sort=-v:refname | head -1 在存在 rc 时必须与 semver 结论不一致(不一致才是对的,说明你没盲信 git 排序)。第二条是本节的验伪手段:如果你的发布脚本在这组 tag 上选出 v2.0.0-rc.2,它就有 bug。
可退:版本发错了,各生态的补救能力差一个数量级:
| 生态 | 能删吗 | 正确补救 | 注意 |
|---|---|---|---|
| npm | 72 小时内可 npm unpublish,之后基本不行 | npm deprecate 标记 + 发 patch 修 | 删包会让锁文件里的引用直接 404,比留着更糟 |
| Go modules | 不能删 tag | retract 指令在 go.mod 里声明该版本作废 | 用户 go get 会看到警告并跳过 |
| Maven Central | 不能删 | 发新 patch 版本,旧版本标记问题 | 中央仓库不可变是刻意设计 |
| 容器镜像 | 技术上可覆盖同 tag | 不要覆盖:发新 tag,旧 tag 保留 | 覆盖 tag 等于破坏“不可变制品”,见 54.1 不可变制品 |
| 内部服务 | 可回滚部署 | 回滚到上一个版本 + 修正 changelog | 已发出的版本号不复用,避免“同一个号两种行为” |
🔑 一条铁律:已发布的版本号永不复用。同一个号对应两种代码,会让所有“按版本排查问题”的手段失效——日志、镜像、锁文件、客户报障,全部对不上账。
反模式
| 反模式 | 后果 |
|---|---|
| 手改版本号,changelog 靠回忆补 | 漏记 breaking;版本号与实际改动脱节,升级决策失去依据 |
| 破坏性改动只 bump minor | 用户无感升级 → 线上崩;这是所有版本事故里最常见的一类 |
| 0.x 项目频繁破坏且不记 breaking | 用户无法判断该锁哪个版本,最终永远锁死最早的那个 |
| 用字符串排序取“最新 tag” | rc 被当成正式版(本机已验证 v:refname 会这样排),发布号回退 |
changelog 直接贴 git log | 使用者要在 wip、Merge、typo 里找“我该做什么” |
| 覆盖已发布镜像的 tag | 制品不可变被破坏,回滚时拿到的不是当时的代码 |
| 删 npm 包“清理”发错的版本 | 依赖它的项目构建直接失败,比留着一个坏版本更糟 |
| 发布提交也进 changelog | 每个版本都有一条“chore(release): vX”,噪音累积 |
自检
Q1 你修了一个 bug,但有一部分用户“依赖了错误行为”(比如他们按旧的错误排序做了缓存键)。这该发 patch 还是 major?
▸ 答案:major,并在 changelog 的 Changed 里写清“旧行为是什么、新行为是什么、受影响版本区间”。理由:版本号承诺的是“使用者是否需要改代码/改预期”,不是“我们改了多少行”。修复错误行为会让依赖它的用户静默地得到不同结果,这比编译错误更难排查。若确实无法承受 major,退路是提供开关(默认新行为、可回退旧行为),把它标成 minor + Deprecated,下一个 major 再删开关。
Q2 发布脚本用 git tag --sort=-v:refname | head -1 取最新版本。仓库里同时有 v2.0.0 和 v2.0.0-rc.2 时,它会取到哪个?为什么这是错的?
▸ 答案:取到 v2.0.0-rc.2。git 的 v:refname 是“按版本号风格排序”,不是 semver:它把 v2.0.0 看成 v2.0.0-rc.2 的前缀,短串在前,取反序后 rc 排到了最前。而 semver §11 规定预发布版小于正式版(2.0.0-rc.2 < 2.0.0)。后果是正式版发布后脚本仍认为 rc 是最新,可能把版本号打回 rc 系列,或跳过正式版的发布流程。修法:用真正的 semver 比较器(本节代码,或用 semver 库)对 tag 列表排序,再取最大。
Q3 为什么 feat(api)!: 删除 /v1/export 会同时出现在 Added 和 Changed (BREAKING) 两个分组?只留一个不行吗?
▸ 答案:不行,两个分组回答不同问题。Added 回答“多了什么”(这次发布带来了能力变化),BREAKING 回答“会不会坏、要改什么”。一条提交可以同时具备两个属性:新增了 v2 接口(能力)+ 删除了 v1 接口(破坏)。只留在 Added,升级者会漏掉迁移动作;只留在 BREAKING,读者不知道替代方案是什么。分组是实现“同一事实、多个索引”,不是去重。
Q4 团队决定“内部服务不打版本号,直接部署最新 main”。这个决定在什么条件下成立,什么时候会塌?
▸ 答案:成立条件是所有消费者与提供者总是同时部署(单体/单团队/无外部依赖),此时“版本”退化成“部署时间”,没有兼容窗口。会塌的三个信号:① 有消费者按自己的节奏发布(移动端、客户私有化部署);② 需要灰度/回滚到“三天前的代码”,而没有可引用的版本标识;③ 一次破坏性变更无法与消费者协同上线。任一出现,就需要版本号(哪怕只是 2026.10.29-3 这种 CalVer)。判断标准不是“内外”,而是发布是否解耦。
Q5 一个版本已经发到 npm,两小时后发现严重 bug。你能做什么、不能做什么?
▸ 答案:能做的:立即发 patch 修复(最快、最安全);用 npm deprecate 给坏版本打标记,让安装者看到警告;在 changelog/release notes 里说明影响区间。不能做的:指望 unpublish 干净地消失——超过 72 小时后基本不可用,且即便在窗口内,删包会让已锁定该版本的构建直接失败。所以正确顺序永远是先发修复、再标记废弃,而不是“先把坏版本删掉”。同样的道理适用于 Go 的 retract 与容器 tag:不可变 + 前向修复,是这个领域的通用原则。