【码动四季·秋】从 commit 到发版全自动:Conventional Commits + semantic-release 发布流水线实战

本文为 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,行为如随版本变化以官方文档为准。

参考资源

专栏导航

如果本文对你有帮助,欢迎点赞、收藏、转发。有任何问题或建议,请在评论区留言交流。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。

相关推荐
Zhou1411361 天前
CICD_01_持续集成与Jenkins入门
运维·ci/cd·jenkins
大貔貅喝啤酒1 天前
Gitea+Jenkins+Docker 搭建 Node 项目 CI/CD 自动部署完整教程
ci/cd·docker·jenkins·js·gitea
Flynt1 天前
Claude Code 103 秒删掉 4.8 万个文件之后,我把自己的仓库"删"了一遍:git 能救的比你想的少
git·ai编程·claude
csdn2015_1 天前
git 可以修改已经push上去的commit说明吗
git
熊猫钓鱼>_>1 天前
开源鸿蒙平台 KMP 三方库 Essenty 适配全流程:从生命周期抽象到状态重建验证
华为·开源·harmonyos·鸿蒙·kmp·atomgit·essenty
smartpi_ai2 天前
通用脱机烧录器为什么烧不进 CI-03?下载协议的门槛、免唤醒 10 条的建议值属性
服务器·网络·ci/cd
MinggeQingchun2 天前
Git - 令牌登录
git
极小狐2 天前
CI 作业里 kubectl 连不上集群?用 Kubernetes Agent 打通部署链路的 7 个步骤
ci/cd·kubernetes·gitlab·devops·k8s部署