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

两个工程细节决定这套机制能不能落地:

  1. squash merge 时,落地的那条提交信息才是输入。所以规范要约束 PR 标题,而不是每个 WIP 提交。PR 模板里放一行“标题必须是 type(scope): 描述”,CI 校验标题即可。
  2. 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:不可变 + 前向修复,是这个领域的通用原则。

关联