
前端 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 的把关。