⚠️ 法律与用途声明:本文讨论基于 Conventional Commits 与 semantic-release 的自动化版本管理,基于开源工具(Conan 2 / GitHub Actions / Node.js),属于经验分享。所有企业名、项目名、内部脚本与路径命名均已脱敏,代码示例中的标识符均为占位。读者须遵守所在地区法律法规。

一、版本号到底该谁拍
B3 讲了包怎么命名、源码怎么归档。但还有一个问题没解决:版本号谁定。
传统做法是:开发者改完代码,自己决定该升 patch 还是 minor,手动改 metadata.json 里的 version 字段,手动写 CHANGELOG,手动打 git tag。这套流程的毛病很明显:

- 该升几级靠人拍。加了一个功能,升 patch 还是 minor?修了个 bug 但改了 API 签名,算 fix 还是 breaking?十个人有十种判断。
- CHANGELOG 手写容易糊弄。到发版时才回头想"这几周改了啥",往往写成"修复若干问题、优化体验"这种废话。
- git tag 容易忘。改了版本号但忘打 tag,或者 tag 名和版本号不一致,排查半天。
这些问题有一个共同的根:版本决策依赖人的主观判断和手动操作。人不可靠,所以版本号也不可靠。
解法是把版本决策交给工具自动推导——工具读你的 commit 历史,按规则判断该升几级,自动改版本号、生成 CHANGELOG、打 tag。人只管写规范的 commit 消息,其他全自动。
二、Conventional Commits:让提交消息自己说话
这套自动化有个前提:你的 commit 消息得是规范的。否则工具读不懂,自动推导就废了。

规范叫 Conventional Commits(B1 提过,这里展开讲),核心就是 commit 消息用一个类型前缀开头,表示这次改动是什么性质:
feat: 新增 FIR 滤波算法
fix: 修复 Cortex-M4 上 FPU 初始化顺序
docs: 更新 benchmark 使用说明
refactor: 抽取公共滤波器接口
chore: 升级工具链版本
test: 补充 FFT 单元测试
还有一个大杀器——BREAKING CHANGE:
feat!: 重构公共接口,移除 deprecated API
BREAKING CHANGE: process() 函数签名从 (float*, int) 改为 (const buffer_t*)
所有调用方需更新
feat! 或 BREAKING CHANGE 脚注,表示破坏性变更。这个标记直接触发 major 版本升级。
这几类 commit 和 SemVer 版本号的对应关系很清晰:
| commit 类型 | SemVer 动作 |
|---|---|
feat |
升 minor(1.0.0 → 1.1.0) |
fix |
升 patch(1.0.0 → 1.0.1) |
docs / refactor / chore / test |
不发版(纯内部改动) |
feat! 或 BREAKING CHANGE |
升 major(1.0.0 → 2.0.0) |
这个对应关系不是谁拍脑袋定的,是 SemVer 规范和 Conventional Commits 规范共同约定的。工具照着这个表自动推导,不需要人判断。
B1 讲组织管理时提过"提交规范是自动化的燃料"——现在兑现了。没有规范的 commit,后面这套自动版本就跑不起来。
三、semantic-release:从 commit 到版本号全自动
工具叫 semantic-release,一个 Node.js 的生态工具。它的干的事是:读 commit 历史 → 按规则推导下一个版本号 → 改版本文件 → 生成 CHANGELOG → 打 tag → 发布。

整条流水线由一个 CI workflow 自动触发(通常在 main 分支有新合并时),不需要人手动跑任何命令。人在这个流程里唯一要做的事就是:写规范的 commit 消息。
配置:告诉它改哪个文件
在模板仓里,semantic-release 需要知道"版本号写在哪里"。我们的答案是 metadata.json(B0 讲过的单一数据源)。所以配置里要指定一个"回写版本号到 metadata.json"的动作:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"prepareCmd": "node -e \"const fs=require('fs'); const c=fs.readFileSync('metadata.json','utf8'); const u=c.replace(/\\\"version\\\"\\s*:\\s*\\\"[^\\\"]+\\\"/, '\\\"version\\\": \\\"${nextRelease.version}\\\"'); fs.writeFileSync('metadata.json',u);\""
}
],
[
"@semantic-release/git",
{
"assets": ["metadata.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
"@semantic-release/github"
]
}
这段配置做了几件事,逐个拆:
commit-analyzer:读 commit 历史,按 Conventional Commits 规范判断该升几级。这是"大脑"——它决定版本号。
release-notes-generator:根据 commit 历史生成发版说明(release notes),这就是 CHANGELOG 的素材。
changelog:把 release notes 写进 CHANGELOG.md 文件。
exec(自定义回写):这是关键定制——它把新版本号写回 metadata.json 的 version 字段。因为我们的版本号不在 package.json(Node.js 惯例)里,而在 metadata.json(C/C++ 模板仓惯例)里。这行 Node.js 代码就是做正则替换:把 metadata.json 里的 "version": "旧版本" 替换成 "version": "新版本"。
git:把改过的 metadata.json 和 CHANGELOG.md 提交回仓库,commit 消息是 chore(release): <版本号> [skip ci]。[skip ci] 是为了防止这个提交又触发一轮 CI(死循环)。
github:创建 GitHub Release,把 release notes 附上去。
四、metadata.json 自动回写:B0 的闭环兑现
B0 讲了 metadata.json 是单一数据源,所有消费者从它继承版本号。semantic-release 的回写动作,就是让这个"单一数据源"的版本号自动更新。

# semantic-release 触发前的 metadata.json
{
"name": "acme-algo",
"version": "1.0.0" # ← 旧版本
}
# semantic-release 执行后
{
"name": "acme-algo",
"version": "1.1.0" # ← 自动改了(因为有一个 feat commit)
}
版本号改了之后,B2 讲的所有消费者自动拿到新值:
- Conan recipe 的
init()读到version: "1.1.0",下次conan create出来的就是 1.1.0。 - CMake 的产物名含 1.1.0。
- CI 的构建产物包名含 1.1.0。
- 文档系统展示的版本号是 1.1.0。
B0 的"一处改处处变"承诺,在这里被自动兑现——连"改"这一步都自动化了,不用人动手。
五、CHANGELOG:自动生成不是废话
CHANGELOG 最大的问题不是写不写,而是手写出来的通常是废话。到发版时才回头想"这周改了啥",往往写成"修复若干 bug,优化性能"。
semantic-release 生成的 CHANGELOG 不一样——它直接从 commit 消息提取,每条都是开发者当时写的原始描述,不是事后回忆:
## [1.1.0] (2026-08-01)
### Features
- 新增 FIR 滤波算法
- 支持 Cortex-A Linux 上的 benchmark 输出
### Bug Fixes
- 修复 Cortex-M4 上 FPU 初始化顺序
- 修复 chunked prefill 在 75K 上下文时的内存泄漏
### Breaking Changes
- **重构公共接口**:process() 函数签名变化,调用方需更新
这种 CHANGELOG 的价值是:每个版本改了什么,一目了然。不是给人看的废话,是给维护和排查用的精确记录。

六、SemVer 在嵌入式 SDK 里的一个坑
SemVer 在纯软件项目里很好用,但在嵌入式 SDK 里有个容易被忽视的坑:二进制兼容性。
SemVer 的 major 版本表示"有破坏性变更"。在纯软件项目里,这通常指 API 签名变了。但在嵌入式场景,还有一种破坏性变更:工具链版本变了,导致编出来的二进制不兼容。
比如你把工具链从 gcc 11 升到 gcc 12,ABI 可能不兼容(C++ 异常处理变了、name mangling 变了)。这在 SemVer 里算不算 breaking change?严格说算,因为下游用老二进制的消费者可能链接不过。
但工具链升级通常不会写 feat!——它写的是 chore: 升级工具链到 12.x。按规则,chore 不触发版本升级。于是工具链变了但版本号没变,下游拿到的二进制实际上不兼容了。
这个问题没有银弹解法,但有两个缓解措施:
- 靠 Conan 的 package_id 兜底(B3 讲过)。工具链版本是 Conan settings 的一部分,settings 变了 package_id 就不同,Conan 会要求重新编译。所以即使 SemVer 版本号没变,Conan 层面也不会让不兼容的二进制静默命中。
- 团队约定:工具链升级这类变更,即使不写
feat!,也人工确认一下是否需要发一个 minor 版本(提醒下游"有变化")。
换句话说:SemVer 管 API 层面的兼容性,Conan 的 package_id 管二进制层面的兼容性,两层各管各的。别指望一个版本号解决所有兼容性问题。
七、几条攒下来的判断
版本号不该人拍。人判断"该升 patch 还是 minor"是不可靠的。交给工具按规则推导,一致性和可信度都高得多。
commit 规范是自动化的前提。不写 Conventional Commits,semantic-release 就读不懂,整个自动版本就废了。B1 讲"提交规范是自动化的燃料",这里兑现。
回写 metadata.json 让 B0 闭环。semantic-release 不只是算版本号,它还把版本号写回 metadata.json,触发 B0 讲的"一处改处处变"。单一数据源的版本号更新全自动。
CHANGELOG 自动生成不是废话。从 commit 消息提取的 CHANGELOG,是原始描述,不是事后回忆的废话。对维护和排查有实际价值。
SemVer 管 API,package_id 管二进制。两层兼容性各管各的,别指望一个版本号解决所有问题。
下一篇,我们深入 B3 提到的双接口设计:C/C++ 双接口与依赖分组——一个仓库怎么同时出 C 接口库和 C++ 接口库,它们的依赖怎么分组、CMake 怎么按需链接。

参考链接: [1] Conventional Commits 规范 https://www.conventionalcommits.org/
[2] Semantic Versioning(SemVer) https://semver.org/
[3] semantic-release(自动版本管理工具) https://github.com/semantic-release/semantic-release
[4] @semantic-release/exec(自定义命令插件) https://github.com/semantic-release/git
[5] GitHub Actions:自动触发 release workflow https://docs.github.com/en/actions/how-tos/manage-workflow-runs
[6] Conan 2:recipe 从 metadata 继承 version https://docs.conan.io/2/reference/conanfile/attributes.html
[7] Conan 2:package_id 与二进制兼容性 https://docs.conan.io/2/reference/config_files/settings.yml.html