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

发布时间:2026/9/13 9:05:09

前端 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/9/13 9:03:14

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

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

2026/9/13 0:46:00

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

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

2026/9/13 9:02:28

商用密码应用安全性评估(密评)要点与题库解析

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

2026/9/13 9:02:28

VMware虚拟机网络连接问题排查与解决指南

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

2026/9/13 8:57:28

Linux垃圾文件清理与磁盘空间释放实战指南

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

2026/9/13 0:01:16

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/13 0:01:16

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/12 6:29:36

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/12 14:32:17

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/12 6:37:43

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

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

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

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