news 2026/7/27 10:27:12

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端 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(新功能)Features
fixpatch(修复 bug)Bug Fixes
refactorpatch(重构)不计入 Changelog
perfpatch(性能优化)Performance
docs不影响版本不计入
test不影响版本不计入
ci不影响版本不计入
BREAKING CHANGEmajor⚠ BREAKING CHANGES

scope 是可选的,表示影响的模块范围。feat(auth): add OAuth2 loginfeat: add OAuth2 login更精确。

2.2 语义化版本的计算逻辑

关键规则:major > minor > patch。一个版本周期内同时有 feat 和 fix,只升 minor,不升 patch。同时有 BREAKING CHANGE,只升 major,minor 和 patch 清零。

2.3 Changelog 生成的数据流

Changelog 不是简单的"列出所有 commit"。它需要:

  1. 从 commit message 中提取 type、scope、subject、breaking change 注释
  2. 按 type 分组(feat → Features,fix → Bug Fixes)
  3. 按 scope 子分组(同一模块的变更放一起)
  4. 生成 Markdown 格式输出
  5. 叠加到已有 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: Record<string, 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/checkout@v4 with: fetch-depth: 0 # 拉取全部历史,Changelog 生成需要完整 commit log - name: Setup Node uses: actions/setup-node@v4 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 --abbrev=0 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 BUMP="major" elif echo "$COMMITS" | grep -qE "^feat"; then BUMP="minor" elif echo "$COMMITS" | grep -qE "^fix|^perf"; then BUMP="patch" else BUMP="none" # 没有 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="$((MAJOR+1)).0.0";; minor) NEXT_VERSION="$MAJOR.$((MINOR+1)).0";; patch) NEXT_VERSION="$MAJOR.$MINOR.$((PATCH+1))";; esac echo "next_version=v$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-release@v1 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 的把关。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/27 10:27:00

QBit响应式编程实战:StatService与事件驱动架构详解

QBit响应式编程实战&#xff1a;StatService与事件驱动架构详解 【免费下载链接】qbit The Java microservice lib. QBit is a reactive programming lib for building microservices - JSON, HTTP, WebSocket, and REST. QBit uses reactive programming to build elastic RES…

作者头像 李华
网站建设 2026/7/27 10:26:43

从Git版本控制到创作全脉络:技术策展如何实现艺术过程可视化

在当代艺术创作领域&#xff0c;如何系统性地呈现一个创意从灵光乍现到最终成品的完整历程&#xff0c;一直是策展与展示的难点。传统的艺术展览往往聚焦于最终的、静态的作品本身&#xff0c;而将背后的思考、草图、迭代乃至失败的过程隐藏起来。第六届“IDEA! 想法”叙事艺术…

作者头像 李华
网站建设 2026/7/27 10:25:38

TDC7200EVM评估板实战:从硬件连接到高精度飞行时间测量

1. 项目概述与核心价值 如果你正在寻找一种能够精确测量纳秒甚至皮秒级别时间间隔的解决方案&#xff0c;那么德州仪器&#xff08;TI&#xff09;的TDC7200时间数字转换器&#xff08;Time-to-Digital Converter&#xff09;绝对值得你花时间深入研究。我最近花了不少时间折腾…

作者头像 李华
网站建设 2026/7/27 10:25:28

5分钟掌握NCM文件解密:ncmdumpGUI图形界面工具完整指南

5分钟掌握NCM文件解密&#xff1a;ncmdumpGUI图形界面工具完整指南 【免费下载链接】ncmdumpGUI C#版本网易云音乐ncm文件格式转换&#xff0c;Windows图形界面版本 项目地址: https://gitcode.com/gh_mirrors/nc/ncmdumpGUI 你是否在网易云音乐下载了心爱的歌曲&#…

作者头像 李华
网站建设 2026/7/27 10:24:29

DS64BR111信号中继器:6.4Gbps高速链路信号完整性修复实战指南

1. 项目概述与核心价值在高速串行通信的世界里&#xff0c;工程师们每天都在与一个看不见的敌人战斗&#xff1a;信号衰减和失真。无论是服务器背板、数据中心的光模块&#xff0c;还是工业相机的高速接口&#xff0c;当数据速率攀升到数Gbps甚至更高时&#xff0c;信号在PCB走…

作者头像 李华
网站建设 2026/7/27 10:24:08

HTTrack:掌握网站离线下载工具的5个核心技巧

HTTrack&#xff1a;掌握网站离线下载工具的5个核心技巧 【免费下载链接】httrack HTTrack Website Copier, copy websites to your computer (Official repository) 项目地址: https://gitcode.com/gh_mirrors/ht/httrack 想要永久保存喜欢的网站内容吗&#xff1f;HTT…

作者头像 李华