前端 Changelog 自动化生成:基于 Conventional Commits 的语义化版本

发布时间:2026/11/13 17:25:52

前端 Changelog 自动化生成:基于 Conventional Commits 的语义化版本 前端 Changelog 自动化生成基于 Conventional Commits 的语义化版本人工写 Changelog 三天就放弃自动生成的前提是你的 commit message 本身就是数据。一、场景痛点每周五你要发版QA 递过来一张清单这周改了哪些 bug、加了哪些功能、做了哪些重构。你打开 Git log 一看50 条 commit message 里一半是fix: something一半是update、修改、wip、暂存。你要花一小时手动分类再写 Changelog再改版本号再推 tag。下一次发版你又做了一遍同样的事。然后你决定以后要规范 commit message但团队三个人两周后就有人开始写fix bug没有 type 也没有 scope规范又回到混乱。核心矛盾Changelog 的生成成本取决于 commit message 的规范程度但规范本身需要持续的人力维护。自动化生成的前提不是工具而是数据质量。二、底层机制与原理剖析2.1 Conventional Commits 规范Conventional Commits 的核心格式type(scope): subject。type 决定这条 commit 属于哪个版本类型type版本影响Changelog 分类featminor新功能Featuresfixpatch修复 bugBug Fixesrefactorpatch重构不计入 Changelogperfpatch性能优化Performancedocs不影响版本不计入test不影响版本不计入ci不影响版本不计入BREAKING CHANGEmajor⚠ BREAKING CHANGESscope 是可选的表示影响的模块范围。feat(auth): add OAuth2 login比feat: add OAuth2 login更精确。2.2 语义化版本的计算逻辑关键规则major minor patch。一个版本周期内同时有 feat 和 fix只升 minor不升 patch。同时有 BREAKING CHANGE只升 majorminor 和 patch 清零。2.3 Changelog 生成的数据流Changelog 不是简单的列出所有 commit。它需要从 commit message 中提取 type、scope、subject、breaking change 注释按 type 分组feat → Featuresfix → Bug Fixes按 scope 子分组同一模块的变更放一起生成 Markdown 格式输出叠加到已有 Changelog 的头部不覆盖历史记录三、生产级代码实现3.1 commitlint husky 配置// commitlint.config.ts —— commit message 格式校验 // 目的在 git commit 阶段拦截不规范 message保证数据源质量 import type { UserConfig } from commitlint/types; const Configuration: UserConfig { extends: [commitlint/config-conventional], rules: { // type 必须是以下之一拒绝任意 type type-enum: [ 2, // level: error不合规的 commit 直接拒绝 always, [ feat, // 新功能触发 minor 版本 fix, // 修复触发 patch 版本 perf, // 性能优化触发 patch refactor, // 重构不触发版本号变化 docs, // 文档不影响版本 test, // 测试不影响版本 ci, // CI/CD不影响版本 chore, // 杂项不影响版本 revert, // 回滚自动生成 ], ], // scope 允许为空但不强制建议团队在 scope-enum 中列出模块名 scope-empty: [1, never], // warning建议写 scope subject-max-length: [2, always, 80], // subject 不超过 80 字符 subject-min-length: [2, always, 5], // subject 至少 5 字符拒绝fix bug // body 中如果有 BREAKING CHANGE必须在 footer 单独声明 footer-leading-blank: [2, always], }, }; export default Configuration;3.2 标准 Changelog 自动化脚本// changelog-generator.ts —— 基于 conventional commits 的 Changelog 生成器 import { execSync } from child_process; import fs from fs; import path from path; interface CommitEntry { hash: string; type: string; scope: string | null; subject: string; body: string | null; breakingChange: boolean; date: string; } /** 从 Git log 中解析 conventional commit 格式 */ function parseCommits(fromTag: string, toTag: string HEAD): CommitEntry[] { // 格式%H hash | %s subject | %b body | %ai date // --no-merges 过滤掉 merge commit它们不算版本变更 const logFormat %H%n%s%n%b%n%ai%n---COMMIT_END---; const raw execSync( git log --no-merges --format${logFormat} ${fromTag}..${toTag}, { encoding: utf-8, maxBuffer: 10 * 1024 * 1024 } ); const commits: CommitEntry[] []; const entries raw.split(---COMMIT_END---).filter(Boolean); for (const entry of entries) { const lines entry.trim().split(\n); if (lines.length 3) continue; const hash lines[0].trim(); const subjectLine lines[1].trim(); const body lines.slice(2, -1).join(\n).trim(); // 最后一行是 date const date lines[lines.length - 1].trim(); // 解析 type(scope): subject const match subjectLine.match(/^(\w)(?:\(([^)])\))?(!?):\s(.)$/); if (!match) { // 不符合 conventional 格式的 commit归入 chore continue; // 直接跳过不纳入 Changelog } const type match[1]; const scope match[2] || null; const breakingFlag match[3] !; // feat! 等于 BREAKING CHANGE const subject match[4]; // 检查 body 中是否包含 BREAKING CHANGE 注释 const breakingInBody body?.includes(BREAKING CHANGE) || false; commits.push({ hash, type, scope, subject, body, breakingChange: breakingFlag || breakingInBody, date, }); } return commits; } /** 按 type 分组生成 Markdown 格式的 Changelog 区段 */ function generateSection( title: string, emoji: string, commits: CommitEntry[], showScope true ): string { if (commits.length 0) return ; let section ### ${emoji} ${title}\n\n; if (showScope) { // 按 scope 子分组同一模块的变更集中展示 const scopeGroups: Recordstring, CommitEntry[] {}; const noScope: CommitEntry[] []; for (const c of commits) { if (c.scope) { scopeGroups[c.scope] scopeGroups[c.scope] || []; scopeGroups[c.scope].push(c); } else { noScope.push(c); } } for (const [scope, group] of Object.entries(scopeGroups).sort()) { section **${scope}:**\n; for (const c of group) { const hashShort c.hash.slice(0, 7); section - ${c.subject} (${hashShort})\n; } section \n; } if (noScope.length 0) { for (const c of noScope) { const hashShort c.hash.slice(0, 7); section - ${c.subject} (${hashShort})\n; } } } else { for (const c of commits) { const hashShort c.hash.slice(0, 7); section - ${c.subject} (${hashShort})\n; } } return section \n; } /** 生成完整 Changelog 并叠加到已有文件头部 */ function generateChangelog( version: string, fromTag: string, changelogPath: string CHANGELOG.md ): void { const commits parseCommits(fromTag); // 分类breaking → feat → fix → perf const breaking commits.filter((c) c.breakingChange); const features commits.filter((c) c.type feat !c.breakingChange); const bugFixes commits.filter((c) c.type fix !c.breakingChange); const perfs commits.filter((c) c.type perf !c.breakingChange); // 日期取最新 commit 的时间 const date commits.length 0 ? commits[0].date.split( )[0] : new Date().toISOString().split(T)[0]; let content ## ${version} (${date})\n\n; // 按优先级排列BREAKING CHANGES Features Bug Fixes Performance content generateSection(BREAKING CHANGES, ⚠️, breaking, true); content generateSection(Features, , features, true); content generateSection(Bug Fixes, , bugFixes, true); content generateSection(Performance Improvements, ⚡, perfs, true); // 叠加到已有 CHANGELOG.md 头部不覆盖历史记录 if (fs.existsSync(changelogPath)) { const existing fs.readFileSync(changelogPath, utf-8); // 保留文件头部的标题和说明新内容插入在标题之后 const headerEnd existing.indexOf(\n## ); if (headerEnd 0) { const header existing.slice(0, headerEnd 1); const rest existing.slice(headerEnd 1); fs.writeFileSync(changelogPath, header content rest, utf-8); } else { fs.writeFileSync(changelogPath, existing \n content, utf-8); } } else { const header # Changelog\n\nAll notable changes to this project will be documented in this file.\n\n; fs.writeFileSync(changelogPath, header content, utf-8); } } // CLI 调用入口node changelog-generator.js version fromTag const [version, fromTag] process.argv.slice(2); if (!version || !fromTag) { console.error(Usage: node changelog-generator.js version fromTag); process.exit(1); } generateChangelog(version, fromTag); console.log(Changelog generated for version ${version});3.3 CI/CD 中的版本自动计算与 Changelog 生成# .github/workflows/release.yml —— 自动发版流程 name: Auto Release on: push: branches: [main] # 只在 push commit 到 main 时触发 jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 # 拉取全部历史Changelog 生成需要完整 commit log - name: Setup Node uses: actions/setup-nodev4 with: node-version: 20 - name: Install dependencies run: npm ci # 计算 next version根据上一个 tag 到 HEAD 之间的 commit 决定版本号 - name: Determine next version id: version run: | LAST_TAG$(git describe --tags --abbrev0 HEAD~1 2/dev/null || echo v0.0.0) echo last_tag$LAST_TAG $GITHUB_OUTPUT # 分析 commit 类型决定版本 bump COMMITS$(git log --no-merges --format%s $LAST_TAG..HEAD) if echo $COMMITS | grep -qE ^[^(:]!: || echo $COMMITS | grep -q BREAKING CHANGE; then BUMPmajor elif echo $COMMITS | grep -qE ^feat; then BUMPminor elif echo $COMMITS | grep -qE ^fix|^perf; then BUMPpatch else BUMPnone # 没有 feat/fix不发版 fi echo bump$BUMP $GITHUB_OUTPUT if [ $BUMP ! none ]; then # 从 last_tag 中提取版本号并 bump BASE_VERSION${LAST_TAG#v} IFS. read -r MAJOR MINOR PATCH $BASE_VERSION case $BUMP in major) NEXT_VERSION$((MAJOR1)).0.0;; minor) NEXT_VERSION$MAJOR.$((MINOR1)).0;; patch) NEXT_VERSION$MAJOR.$MINOR.$((PATCH1));; esac echo next_versionv$NEXT_VERSION $GITHUB_OUTPUT fi - name: Generate Changelog if: steps.version.outputs.bump ! none run: | node scripts/changelog-generator.js \ ${{ steps.version.outputs.next_version }} \ ${{ steps.version.outputs.last_tag }} - name: Create Release if: steps.version.outputs.bump ! none uses: softprops/action-gh-releasev1 with: tag_name: ${{ steps.version.outputs.next_version }} body_path: ./release-body.md # 从 Changelog 中提取本次版本的内容 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}四、边界分析与架构权衡4.1 规范落地的成本commitlint 只能在 commit 阶段拦截不规范 message但它管不了commit 内容与 type 不匹配。比如用feat描述一个 bug 修复commitlint 不会报错但 Changelog 会把 bug 修复归到 Features 区段。对策在 code review 环节加一步——reviewer 检查 commit message 与实际变更的一致性。这是人力成本但比手动写 Changelog 低得多。4.2 merge commit 的干扰GitHub 的 squash merge 会把多条 commit 合成一条subject 是 PR 的标题。如果 PR 标题不符合 conventional 格式这条 commit 就被 Changelog 生成器跳过。对策GitHub 设置中开启 Require linear history PR title 校验钩子确保 squash merge 后的 commit message 符合规范。4.3 适用边界与禁用场景适用npm 包、CLI 工具、有明确版本发布节奏的项目、团队 ≥2 人禁用持续部署没有版本概念、内部工具不需要 Changelog、一人维护的小项目规范成本 收益4.4 与 semantic-release 的对比semantic-release 是全自动版本管理工具它在 CI 中自动计算版本号、生成 Changelog、创建 git tag、发布 npm 包。比手动脚本更彻底但代价是你必须完全依赖 CI本地开发时版本号是 0.0.0-development。选择标准如果你的项目通过 npm 发布、有完整的 CI 流程、团队信任自动化用 semantic-release。如果版本发布需要人工审批比如审核 Changelog 内容用半自动脚本。五、结语Changelog 自动化的本质不是机器帮你写文档而是commit message 本身就是结构化数据Changelog 只是数据的二次加工。commitlint 保证数据质量conventional 格式提供数据结构生成脚本完成加工输出。三个环节缺一个自动化的链条就断。团队规范比工具更重要——工具只是规范的执行者规范本身需要持续维护和 code review 的把关。
延伸阅读

更多相关文章

2026/11/10 4:28:27

three.js 编辑器在国内开源生态里的位置

three.js 编辑器在国内开源生态里的位置 本文围绕 three.js 编辑器(一款基于 Three.js 的 AI 驱动可视化低代码编辑器)展开。- 🌐 在线预览:https://z2586300277.github.io/threejs-editor/- 📦 GitHub 开源仓库&#…

2026/11/9 7:31:47

企业级Agent从SaaS到私有化:技术迁移的三大关键跃迁

2026年,企业级AI Agent的部署模式正在经历一场从SaaS到私有化的结构性转变。行业数据显示,2026年中国大模型市场规模预计突破700亿元,其中私有化部署占比预计达63%。企业对私有化部署的意愿已从33.25%跃升至88.75%。从“公有云上调用API”到“…

2026/11/13 21:18:17

拆电池包需要电工证吗千万别踩坑这些细节必须知道

拆电池包需要电工证吗千万别踩坑这些细节必须知道 你是不是也担心, 学历不高怕报不上名 ?在新能源行业越来越火的今天,很多人想入行却卡在了“证书门槛”这道关上。其实,像“拆电池包需要电工证吗”这种问题,很多人都搞不清楚,结果白白浪费了时间和机…

2026/11/13 21:18:17

郑州报考避坑指南:进网低压电工证怎么考,学历不高也能报上名

郑州报考避坑指南:进网低压电工证怎么考,学历不高也能报上名 你是不是经常听说“进网低压电工证”这个证,但又担心自己学历不够,根本报不了名?别慌,我当年也是个初中毕业的电工,愣是靠这个证转了行,现在收入比以前翻了一番。今天我就从郑州的角度,给…

2026/11/13 21:18:17

深圳电工证什么样通过率揭秘:新手避坑全攻略

深圳电工证什么样通过率揭秘:新手避坑全攻略 你是不是也在为【深圳电工证什么样】发愁?不知道去哪报名,怕被中介坑?其实你不是一个人,每年都有很多人因为不了解流程而走弯路,甚至花冤枉钱。今天我来给你 揭秘深圳电工证通过率…

2026/11/13 21:18:17

电工证考那种好良心建议怎么选别再走弯路了

电工证考那种好良心建议怎么选别再走弯路了 之前考的证在外省能不能转过来?这个事儿我跟你说,真不是一句话能说完的。现在全国多地都在推进特种作业操作证的电子化、跨省互认,但具体怎么操作,还是得看地方政策和你手里那本证的“出身”。如果你是洛阳项目…

2026/11/13 21:18:17

温州市特种工考试官方报名入口怎么找?外省证书转过来有这些坑

温州市特种工考试官方报名入口怎么找?外省证书转过来有这些坑 之前考的证在外省能不能转过来?这几乎是所有打算在温州就业的特种作业人员最关心的问题。尤其是像新乡的中小施工企业负责人,经常需要跨省接项目,证书是否能在温州使用,直接关系到施工的合规…

2026/11/13 21:13:16

电工证怎么考?考电工证免费刷题全攻略,官方报名入口详解

电工证怎么考?考电工证免费刷题全攻略,官方报名入口详解 你是不是正被“急需特种作业证才能上岗找工作”的问题困扰?电工证是特种作业操作证中报考人数最多的,也是很多建筑、制造类岗位的“硬门槛”。现在考电工证已经不只是去培训机构报个名那么简单,…

2026/11/12 20:35:13

电工证丢失还能补吗?千万别踩坑,丢了证等于失业

电工证丢失还能补吗?千万别踩坑,丢了证等于失业 你正在找工作,但手里的电工证突然丢了,这下怎么办?别急,先别慌, 电工证丢失还能补吗 ?答案是肯定的,但 千万别踩坑 ,补办流程如果不了解,可能会耽误你上岗、甚至影响到你的收入。…

2026/11/13 0:01:06

办上海高压电工证良心建议:别被中介坑了,自己看这篇就够了

办上海高压电工证良心建议:别被中介坑了,自己看这篇就够了 你是不是也像我一样,第一次听说“高压电工证”这个名词,就感觉一头雾水?其实很多人都跟我一样,不知道去哪报名、怕被中介坑,结果花了冤枉钱还考不上。今天我就从一个在商丘培训过的学员角度,…

2026/11/13 0:01:06

黎里考电工证在哪里值不值得考

黎里考电工证在哪里值不值得考 之前考的证在外省能不能转过来?这是很多在外地工作、准备回老家发展的朋友最关心的问题。特别是在黎里地区,很多从事电力相关工作的朋友,都面临这样一个难题: 外省的电工证能否在本地使用?值不值得考?…

2026/11/13 0:01:06

毕节哪里考电工证 怎么报名学历不高也能上手

毕节哪里考电工证 怎么报名学历不高也能上手 你是不是也和我一样,学历不高,想着考个电工证,但又担心自己条件不够,怕报不上名?其实不然,只要符合条件,学历不高也能考电工证。今天就来聊聊【毕节哪里考电工证】这个问题,还有怎么报名,让你一步到位不…

2026/11/12 22:10:13

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/13 12:14:39

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑