从零搭建组件库版本管理:语义化版本与 Changelog 自动化

📅 2026/7/11 12:42:07
从零搭建组件库版本管理:语义化版本与 Changelog 自动化
从零搭建组件库版本管理语义化版本与 Changelog 自动化一、v2.7.0 到 v2.7.1 的那次 Patch 让三个团队浪费了两天那是一个周四下午。组件库发布了v2.7.1——标签是patch但实际包含了 3 个minor级别的变更DatePicker的format属性默认值从YYYY-MM-DD变成了YYYY/MM/DDTable组件的onRowClick回调签名多了一个columnIndex参数以及Modal的zIndex被从 CSS 变量管理迁移到了 JS 运行时控制。这三个变更在语义化版本的定义中每一项都是minor合在一起应该发布v2.8.0。但由于发布者只看 PR 标题判断——fix: 修复 Table 行点击参数缺失——自动生成的 Changelog 也只包含了这条信息。结果三个业务线在安装v2.7.1后DatePicker 的所有日期格式验证正则全部失效YYYY-MM-DD的正则不匹配YYYY/MM/DDTable 的事件总线因为回调签名不兼容而静默失败。修复这些问题的总耗时三个前端开发各配一个 QA加在一起超过 16 人时。根源不是代码质量而是版本语义失控。版本管理是组件库工程化中最被低估的一环。它不产生任何视觉价值但它决定了下游消费者安装一个更新时是秒升还是回退排查骂人。二、语义化版本的工程约束与 Changelog 生成管线Semantic VersioningSemVer将版本号定义为MAJOR.MINOR.PATCH三元组MAJOR主版本不向下兼容的 API 变更。例如删除了一个公开导出的函数、修改了必传 Props、CSS 变量被移除或重命名。MINOR次版本向下兼容的新增功能。新增了一个可选 Props、新增一个组件、新增一个 CSS 变量。PATCH修订版本向下兼容的 Bug 修复。修复样式错位、修复类型定义错误、修复运行时边界情况。这三者之间的切换边界在实际工程中是模糊的。一个官方的patch修复在某个业务方眼里是minor——因为那个业务方恰好依赖了那个bug的行为。这种依赖反转是 SemVer 在实践中的最大挑战。Changelog 自动化的关键在于从提交信息中提取结构化的变更描述。标准做法是采用 Conventional Commits 规范type(scope): description [optional body] [optional footer]其中type决定了变更的版本号影响type版本影响示例featMINORfeat(DatePicker): 增加季度选择模式fixPATCHfix(Table): 修复排序后行选择状态丢失BREAKING CHANGE/!MAJORfeat!(Button): 移除 size prop改用 variantperfPATCHperf(VirtualList): 优化滚动帧率refactorPATCH无行为变化的代码重构flowchart TD A[Git 提交历史] -- B[解析 Conventional Commits] B -- C{type 分类} C --|BREAKING CHANGE| D[MAJOR 版本 1] C --|feat| E[MINOR 版本 1] C --|fix / perf / refactor| F[PATCH 版本 1] D -- G[计算下一版本号] E -- G F -- G G -- H[按分类生成 Changelog 条目] H -- I[写入 CHANGELOG.md] I -- J[创建版本 Tag 并发布]这里有一个容易被忽略的细节如果一次提交中同时存在feat和fix版本号应该按最高影响级别计算即 MINOR 优先于 PATCH。如果存在BREAKING CHANGE无论其他类型如何版本号必须触发 MAJOR 升级。三、自动化版本管理与 Changelog 生成引擎// package.json 中的配置 { name: team/design-system, version: 2.7.0, scripts: { release: node scripts/release.mjs, version: node scripts/version.mjs, changelog: node scripts/changelog.mjs }, devDependencies: { conventional-changelog-conventionalcommits: ^7.0.0, semver: ^7.6.0 } }/** * 自动化版本发布脚本 * * 工作流 * 1. 扫描自上次 release 以来的所有 commit * 2. 按 Conventional Commits 分类并计算目标版本号 * 3. 自动生成 CHANGELOG 条目 * 4. 更新 package.json 版本号 * 5. 创建 Git tag * * 依赖semver版本计算、conventional-changelog-conventionalcommits提交解析 */ import { execSync } from child_process; import fs from fs; import semver from semver; // 第一步获取当前版本号 const pkg JSON.parse(fs.readFileSync(package.json, utf-8)); const currentVersion pkg.version; // 第二步获取最新 release tag 以来的所有 commit function getCommitsSinceLastRelease() { try { // --oneline 输出格式: hash message // git tag --sort-creatordate 按创建时间倒序 const latestTag execSync( git tag --sort-creatordate | head -n 1, { encoding: utf-8 } ).trim(); if (!latestTag) { // 没有任何 release tag从第一个 commit 开始计算 return execSync(git log --oneline, { encoding: utf-8 }) .trim() .split(\n); } // 从最新 tag 到当前 HEAD 的 commit return execSync(git log --oneline ${latestTag}..HEAD, { encoding: utf-8, }) .trim() .split(\n) .filter(Boolean); } catch (error) { console.error(获取 Git log 失败, error); return []; } } // 第三步解析 Conventional Commits // RegExp 拆解 // ^(\w) → typefeat/fix/chore 等 // (?:\((.)\))? → 可选的 scope如 (Button) // (!)? → 可选的 BREAKING CHANGE 标记 ! // :\s(.) → 冒号空格后的 description const CONVENTIONAL_REGEX /^(\w)(?:\((.)\))?(!)?:\s(.)/; interface ParsedCommit { type: string; scope?: string; breaking: boolean; description: string; hash: string; } function parseCommits(commits: string[]): ParsedCommit[] { return commits .map((line) { const [hash, ...rest] line.split( ); const message rest.join( ); const match message.match(CONVENTIONAL_REGEX); if (!match) return null; return { hash, type: match[1], scope: match[2] || undefined, breaking: !!match[3], description: match[4], }; }) .filter((c): c is ParsedCommit c ! null); } /** * 判断是否包含 BREAKING CHANGE通过 footer 中的 BREAKING CHANGE: 标记 * git log --format%s%n%b--- 可以在输出中获取 body */ function hasBreakingChangeFooter(commits: string[]): boolean { const fullLog execSync( git log --format%B $(git tag --sort-creatordate | head -n 1)..HEAD, { encoding: utf-8 } ); return /BREAKING\s*CHANGE:/i.test(fullLog); } // 第四步计算目标版本号 function calculateNextVersion( current: string, parsed: ParsedCommit[], hasFooterBreaking: boolean ): string { let bumpType: major | minor | patch patch; // 优先级BREAKING feat fix if (parsed.some((c) c.breaking) || hasFooterBreaking) { bumpType major; } else if (parsed.some((c) c.type feat)) { bumpType minor; } // 只要有 fix 但没有 feat保持 patch默认值 return semver.inc(current, bumpType) || current; } // 第五步生成 Changelog function generateChangelog( version: string, parsed: ParsedCommit[] ): string { // 按类型分组 const breaking parsed.filter((c) c.breaking); const features parsed.filter((c) c.type feat !c.breaking); const fixes parsed.filter((c) c.type fix); const others parsed.filter( (c) ![feat, fix].includes(c.type) !c.breaking ); const lines: string[] []; const date new Date().toISOString().split(T)[0]; // YYYY-MM-DD lines.push(## [${version}] - ${date}); lines.push(); // 破坏性变更如果存在用 ⚠️ 标记引起关注 if (breaking.length 0) { lines.push(### ⚠️ BREAKING CHANGES); for (const c of breaking) { const hint c.scope ? **${c.scope}:** : ; lines.push(- ${hint}${c.description} (${c.hash.slice(0, 7)})); } lines.push(); } // 新功能 if (features.length 0) { lines.push(### Features); for (const c of features) { const hint c.scope ? **${c.scope}:** : ; lines.push(- ${hint}${c.description} (${c.hash.slice(0, 7)})); } lines.push(); } // Bug 修复 if (fixes.length 0) { lines.push(### Bug Fixes); for (const c of fixes) { const hint c.scope ? **${c.scope}:** : ; lines.push(- ${hint}${c.description} (${c.hash.slice(0, 7)})); } lines.push(); } // 其他文档、构建、CI 等 if (others.length 0) { lines.push(### Other Changes); for (const c of others) { lines.push(- **${c.type}:** ${c.description}); } lines.push(); } return lines.join(\n); } // 第六步主发布流程 async function release() { const commits getCommitsSinceLastRelease(); if (commits.length 0) { console.log(没有新的 commit跳开发布); return; } const parsed parseCommits(commits); const hasFooterBreaking hasBreakingChangeFooter(commits); const nextVersion calculateNextVersion( currentVersion, parsed, hasFooterBreaking ); console.log(当前版本: ${currentVersion}); console.log(目标版本: ${nextVersion}); console.log(Commit 数: ${parsed.length}); // 生成 Changelog 条目 const changelogEntry generateChangelog(nextVersion, parsed); // 将新条目插入 CHANGELOG.md 的顶部保留历史内容 const changelogPath CHANGELOG.md; const existingChangelog fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, utf-8) : # Changelog\n\n; fs.writeFileSync(changelogPath, changelogEntry \n existingChangelog); // 更新 package.json 中的版本号 pkg.version nextVersion; fs.writeFileSync(package.json, JSON.stringify(pkg, null, 2) \n); // 版本提交 打 Tag由 CI 平台或手动执行 console.log(\n请执行以下命令完成发布); console.log( git add package.json CHANGELOG.md); console.log( git commit -m chore(release): ${nextVersion}); console.log( git tag v${nextVersion}); console.log( git push git push --tags); } release().catch(console.error);四、版本管理的三种反模式反模式一RC 版本跳过 Changelog。很多团队认为 Release Candidate如v3.0.0-rc.1只是内部测试版不需要写 Changelog。但 RC 的早期使用者恰好是最需要准确变更信息的人——他们在 RC 阶段就接入并开始适配如果rc.1和rc.2之间偷偷删了一个 API对接方会在正式发布后才发现造成二次适配浪费。规则每一个 tag 都应该有对应的 Changelog 条目包括 RC、alpha、beta。反模式二手动编辑 Changelog。人写的 Changelog 往往变成两种极端——要么是本次更新修复了若干问题的糊弄文学要么是事无巨细的内部 commit message 逐行粘贴。前者没有信息量后者全是信息噪音。自动生成的 Changelog 确保每条变更都有type scope description的固定格式消费者能快速分类浏览。反模式三多包仓库Monorepo使用单一版本号。在一个包含lib/core、lib/charts、lib/forms三个包的 Monorepo 中如果charts包修复了一个图例对齐 Bug 就导致整个lib从 v2.3.0 跳到 v2.4.0core和forms的使用者会困惑——我什么都没变为什么升了 minor。正确方案是每个包独立版本号使用 Changesets 或 Lerna 的 Independent Mode只在根包的 Changelog 中聚合所有包的变更摘要。五、总结SemVer 的核心是「不向下兼容 → MAJOR」、「新增功能 → MINOR」、「Bug 修复 → PATCH」的铁三角映射。Conventional Commits 的feat/fix/BREAKING前缀直接决定版本号和 Changelog 分组。版本号升级的优先级BREAKING feat fix同一次发布取最高影响级别。git log 正则匹配是提取结构化提交信息的标准路径无需引入重量级工具链。Changelog 条目必须按破坏性/新功能/Bug修复/其他分组消费者靠分类快速定位影响。RC / alpha / beta 版本不能跳过 Changelog——早期使用者比稳定版用户更需要变更信息。手动编写的 Changelog 信息质量不稳定自动化可以保证格式统一和完备性。Monorepo 中每个子包应有独立的版本号根包的 Changelog 仅做聚合摘要。BREAKING CHANGE 的检测不能仅靠!前缀——git log的 body 中也可能声明 BREAKING CHANGE。版本管理与 Changelog 的底线原则让消费者在npm update之后三分钟内能判断自己是否需要适配。