本文为 AtomGit 码动四季·开源同行征稿活动参与文章
开源仓库的文件都就位之后,我回头看了一眼 git 历史,发现一个尴尬的事实:仓库里的"版本"只有两个------"刚开始"和"现在"。中间 1287 次提交,没有版本号,没有 CHANGELOG,想找"哪次提交改了命名规则"只能靠 git log -S 考古。
不是我不想发版,是每次发版的成本劝退了我:核对这段时间改了什么、手写 CHANGELOG、决定版本号加几位、打 tag、写 release notes------全人工。上一次认真发版还是半年前,之后改动越攒越多,越发版不动。
这篇讲我怎么把这个流程彻底交给机器:提交规范怎么落地、semantic-release 怎么从 commit 自动推出版本号和 CHANGELOG、CI 怎么串起来、文档仓库(非 npm 项目)怎么适配。四个我真实踩过的坑也一并在第四节。
本文要点:Conventional Commits 结构化提交 · semantic-release 选型对比 · commitlint + hook 强制落地 · 非 npm 仓库适配 · 基线 tag 处理旧历史 · 4 个真实踩坑 · 发版耗时 40-60 分钟 → 0 分钟
适用版本:semantic-release v24.x / commitlint v19.x / Conventional Commits 1.0.0(2026 年现行版本,行为以官方文档为准)
一、发版之痛的根源:commit message 不是写给人的,是写给流水线的
先看一段我仓库改造前的真实 commit 历史(节选,风格未做修饰):
text
a3f2c11 更新命名规则
8b1e04d fix
2c9d7f3 修改了一堆东西
d4e8a92 修复 mermaid 渲染问题,顺便更新了两个规则文件
e7f1b33 feat: 新增图片管理规则(首次尝试规范提交)
这种历史的致命问题不是"难看",是信息无法机器读取:哪次是功能新增、哪次是缺陷修复、哪次破坏了兼容性------没有任何结构化信号。人读着费劲,流水线更是无从下手。
Conventional Commits 的解法是把 commit message 变成结构化数据:
text
<type>(<scope>): <subject>
feat(rules): 新增图片管理规则 ← 功能新增 → 次版本号 +1
fix(mermaid): 修复渲染背景色 ← 缺陷修复 → 修订号 +1
feat!: 迁移到单一配置源 ← ! 表示 breaking → 主版本号 +1
type 与语义化版本(SemVer)的映射关系是整套机制的基石:feat → minor、fix → patch、BREAKING CHANGE(! 或 footer)→ major。commit 写规范了,版本号就是推导题,不是判断题。
二、方案对比:为什么选 semantic-release
主流的三条自动化发版路线:
| 维度 | semantic-release | release-please | changesets |
|---|---|---|---|
| 版本决策 | 全自动,从 commit 推断 | 人工确认 release PR | 人工写变更集 |
| CHANGELOG 质量 | 完全由 commit 生成 | 由 PR 标题与描述生成 | 由手写变更集生成 |
| 上手曲线 | 陡(插件体系 + 严格规范) | 平缓 | 中等 |
| Monorepo 支持 | 弱 | 一般 | 强(核心优势) |
| 适用场景 | 单包 + 纪律性强的提交 | 想要人工把关的团队 | 多包联动发版 |
选型逻辑:我的仓库是单包 (不需要 Monorepo 联动)、单人维护(人工确认 release PR 是给自己加活)、且我已经决定把提交纪律管起来(否则整套机制无意义)。三条里 semantic-release 的短板------陡峭上手曲线、对提交规范的强依赖------对单人仓库恰恰不构成障碍。而它"零人工"的版本决策正是我要买的东西。

一个容易被忽略的细节:只有 chore/docs 类型提交时不会发版。这个行为对内容仓库很重要------改错别字、调格式不该消耗版本号。
三、四步落地:从提交规范到自动发版
3.1 第一步:提交规范落地(commitlint + hook)
规范光写在文档里没用,必须用工具在提交入口强制。commitlint 负责校验 message 格式,hook 负责"提交那一刻"拦截:
bash
# 安装(Node 18+)
npm install -D @commitlint/cli @commitlint/config-conventional
js
// commitlint.config.js --- 在 Conventional Commits 默认规则上做两点收紧
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// type 白名单:允许 content(内容仓库特有的内容更新类型)
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'content', 'refactor', 'chore', 'revert'
]],
// subject 禁止空泛描述------中文 description 也必须有信息量
'subject-min-length': [2, 'always', 8],
},
};
bash
# lefthook.yml --- commit-msg hook 拦截
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}
content 这个自定义 type 是文档仓库的适配点:官方规范里文章更新只能挤进 docs,但我需要把"内容更新"(新文章、数据修订)和"文档说明"(改 README、改规则说明)区分开------两者对版本号的语义不同:content 参与发版,docs 不参与。
3.2 第二步:semantic-release 配置(非 npm 仓库的关键适配)
semantic-release 默认假设你在发 npm 包。内容仓库不需要 npm 发布,只需要 tag + CHANGELOG + Release,插件按需裁剪:
js
// release.config.js --- 文档仓库适配版
module.exports = {
// 关键:branches 配置。main 直发,另开 beta 预发通道
branches: [
'main',
{ name: 'beta', prerelease: true },
],
// 注意没有 @semantic-release/npm ------ 非 npm 仓库不需要
plugins: [
'@semantic-release/commit-analyzer', // 从 commit 推断版本
'@semantic-release/release-notes-generator', // 生成 release notes
['@semantic-release/changelog', {
changelogFile: 'CHANGELOG.md', // CHANGELOG 落成文件入库
}],
['@semantic-release/exec', {
// 发版成功的后置动作:同步版本号到 README 徽标数据源
prepareCmd: 'echo "v${nextRelease.version}" > .version',
}],
['@semantic-release/git', {
// CHANGELOG 和版本文件随发布 commit 回写仓库
assets: ['CHANGELOG.md', '.version'],
message: 'chore(release): v${nextRelease.version} [skip ci]',
}],
],
};
3.3 第三步:CI 接入与权限
CI 侧要做两件事:跑 commitlint 全量校验(PR 里的每个 commit 都要合规),以及发版 job。权限是最容易翻车的点(第 2 个坑详述):
yaml
# .atomcode/ci/pipeline.yml(AtomGit CI,结构同 GitHub Actions)
stages:
- lint
- release
commit-lint:
stage: lint
script:
- npx commitlint --from origin/main --to HEAD
semantic-release:
stage: release
only: [main] # 只有 main 触发发版
script:
- npx semantic-release
# 环境变量:GIT_AUTHOR_NAME / GIT_AUTHOR_NAME 等 CI 机器人身份
# 以及有 contents 写权限的 token(变量注入,不落明文)
CI 触发后的完整调用时序------从 push 到 Release 通知,各环节的责任边界:

3.4 第四步:历史基线处理(首次接入必做)
旧历史全是"更新""fix"这种不可解析的 message,semantic-release 首次运行时从上个 tag 开始分析------而我的仓库没有上个 tag。它的默认行为是从第一个 commit 开始扫,1287 个"自由体" commit 扫出来的版本推断毫无意义。
解法是先打一个基线 tag,告诉流水线"历史到此为止":
bash
# 以当前状态为 v1.0.0 基线,之后的 commit 才参与版本推导
git tag -a v1.0.0 -m "首次规范化发版基线"
git push origin v1.0.0
这个动作还顺带解决了一个心理问题:不用为旧历史不合规焦虑------基线之前的历史不参与解析,规范只约束未来。
基线处理在整条流水线里的位置一图看清------先打基线、规范只增量生效、旧历史静默隔离:

四、四个真实踩坑
1. squash merge 把版本信号揉没了
现象 :开启 PR 合并后用 squash------5 个 feat/fix 被压成 1 个 commit,标题还是 PR 标题(不含 type)。那次 semantic-release 推断结果:无发版,实际该发 minor。
根因:squash 把多条结构化 commit 揉成一条非规范 message,版本信号在合并环节丢失。
解决:squash 后的 commit 标题必须重写为合规的 Conventional Commit 格式,或者干脆用 merge commit 保留原始历史。我在 PR 模板里加了"合并前检查 commit 标题"这一条。
教训:流水线读的是合并后的最终历史,中间过程再规范,最后一步变形就全白费。
2. CI 机器人权限不足,tag 打了 Release 没建
现象:第一版 CI 配置里 token 只有 push 权限,semantic-release 走到"创建 Release"一步 403,但 tag 已经推上去了------仓库陷入"有 tag 无 Release"的脏状态,下一次运行还把 tag 当成"已发版"。
根因:发版动作需要 tag、Release、push 三种写权限,只配了其中一种。
解决 :给 CI token 显式开 Release 写权限,并养成发版后去 Release 页确认的习惯。排查命令:git tag -l 与平台 Release 列表对账。
教训:权限是 CI 发版的第一故障源------三种权限一次配齐,好过每次 403 再补。
3. breaking change 逃逸
现象 :重构 .atomcode 目录结构的那次提交,实际是破坏性变更(依赖旧路径的脚本全挂),但 message 写成了 refactor: 迁移配置目录------没有 ! 也没有 BREAKING CHANGE footer,版本号只走了 patch。
根因 :破坏性变更依赖人在提交时自觉声明,没有任何机制兜底;refactor 类型默认不携带 breaking 语义。
解决 :commitlint 增加自定义规则,refactor 类型强制要求 scope,且改目录/接口的提交一律要求 BREAKING CHANGE footer 人在回路确认。依赖该路径的自动化(04 号的 CI job)同步修正。
教训 :! 和 footer 不是格式洁癖,是下游自动化的生命线------漏一次,故障要到下游 CI 挂了才暴露。
4. beta 通道的版本号先于 main
现象 :预发通道发过 v1.1.0-beta.1,后来 feat 合进 main 正式发版时,semantic-release 推出了 v1.1.0------第一反应是"版本号重复了",差点手工干预。
根因 :不了解 prerelease 语义------prerelease 与正式版的推导是隔离的,v1.1.0-beta.1 与 v1.1.0 是两个不同的版本。
解决 :不干预,信任流水线。正式发版 v1.1.0 正确覆盖了 beta 预告的语义。
教训:人不要碰版本号------理解不了流水线行为时,先查文档,而不是上手改。
五、效果:发版从"半年一憋"到"随过随发"
| 指标 | 接入前(手写时代) | 接入后(自动化) |
|---|---|---|
| 单次发版人工耗时 | 40-60 分钟(核对+手写+打 tag) | 0(CI 内 3 分钟无人值守) |
| 版本发布间隔 | 最长半年,积压发不出 | 有 feat/fix 即发,周内完成 |
| CHANGELOG 覆盖率 | 抽查约 60%(凭记忆漏记) | 100%(由 commit 生成,不靠记性) |
| 版本号错误 | 人工判断,出现过跳号 | 推导制,零错误 |
| 历史可追溯性 | git log -S 考古 |
CHANGELOG 直接定位 |
改造后的发版流程变成:合 PR → push → CI 自动分析 commit → 自动出 tag/CHANGELOG/Release → 我在手机上收到完成通知。人从发版流程里完全退场,只在 breaking change 时被拉回确认。

六、扩展与边界
两个联动方向:许可证合规检查 可以作为一个前置 job 挂进这条流水线(02 号文章的 DCO 校验就在 lint 阶段);依赖升级 PR (04 号 Dependabot)与发版流水线的共存规则------升级 PR 的 commit 统一走 chore(deps) 前缀,不污染版本号,安全补丁单独标 fix(deps)。
适用边界说清楚:这套全自动机制的前提是提交纪律由工具强制。如果团队里 commit message 无法统一(历史原因、人员流动),semantic-release 会把脏历史如实反映成混乱的版本号------那种场景建议先从 release-please 的人工确认模式起步,纪律养好了再切全自动。
七、总结
这次改造下来,值得记住的就四句话。commit message 是流水线的 API------写给六个月后的自己,更是写给机器。首次接入先打基线 tag,规范只管未来,不为旧历史焦虑。tag、Release、push 三种权限一次配齐,这是我用一次 403 换来的教训。! 和 footer 不是格式洁癖,漏一次,故障要到下游 CI 挂了才暴露。
现在我的仓库每次发版,我只做一件事:看通知。你上次发版花了多久?评论区报个数。
八、常见问题
Q1:单人仓库有必要上这套吗?感觉是团队才需要的东西。
恰恰相反,单人仓库收益最大。团队发版好歹有人分担,单人仓库的 CHANGELOG 全靠半年后的自己回忆------而回忆是最不可靠的。我接入的动机就是 git log -S 考古自己半年前的改动,考了半小时没考到。这套配置装完之后,发版这件事从我的待办清单里消失了。
Q2:commitlint 会不会太烦?写个 commit 还要过校验。
头两天确实烦,第三天开始就是肌肉记忆了。真正被拦截的往往是你本来就该写清楚的:fix 后面到底修了什么、feat! 有没有把破坏性影响写进 footer。我的经验是把 subject-min-length 设成 8------低于 8 个字的 subject 几乎必然没信息量。如果团队抵触强烈,可以先只开 warning 不开 error,观察两周拦截记录再收紧。
Q3:CHANGELOG 全由机器生成,会不会可读性很差?
取决于 commit 写得好不好,机器只是忠实的转录员。我的做法是:subject 一句话讲清"改了什么",body 里补充"为什么改"和影响范围。semantic-release 生成的 CHANGELOG 按版本分组、按 type 归类,feat/fix 分区展示------比手写的还整齐,因为它不会漏、也不会偷懒。
你的仓库上次发版是什么时候?如果超过一个月了,这篇的配置加起来 20 分钟能跑通,值得试一次。
真实性声明
本文配置来自本仓库实际运行的流水线(commitlint/lefthook/semantic-release/CI yml 均为在用版本);发版耗时与 CHANGELOG 覆盖率数据来自 git 历史与 CI 日志统计;四个踩坑为真实故障复盘。semantic-release 版本 v24.x,行为如随版本变化以官方文档为准。
参考资源
专栏导航
- 上一篇 :开源许可证怎么选:决策全过程
- 下一篇:用 AI 机器人治理开源仓库:三件套落地(即将发布)
- 专栏首页 :码动四季·秋季征稿系列
如果本文对你有帮助,欢迎点赞、收藏、转发。有任何问题或建议,请在评论区留言交流。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。