commitlint 与 Changesets 提交元数据治理手册
代码是对的,历史为什么仍然会骗人
一次紧急修复合并后,主线只留下 update;几周后回查事故,没人能从历史判断这是兼容修复还是行为变更。另一个 Monorepo 修改了公共组件和三个依赖包,PR 描述写得很完整,却没有机器可读的包级影响。等到后续流程汇总版本时,维护者只能重新猜测当时的意图。
提交消息和变更声明解决的是两类不同问题:commitlint 检查一条 Git 消息是否符合团队约定;Changesets 把“哪些包受到面向使用者的影响、影响属于 major/minor/patch、应该怎样解释”保存为仓库文件。前者治理提交历史,后者保存开发阶段的发布意图。两者都不能根据代码自动判断兼容性,也不能替代评审。
真正的控制目标不是让消息看起来整齐,而是建立可追溯的不变量:最终进入主线的消息能够被解析,面向使用者的包变更有对应声明,机器人和人工例外可识别,CI 对最终历史再次验证。
先确定团队最终保留什么历史
commitlint 应校验最终进入主分支的消息。平台采用哪种合并策略,会直接改变校验对象:
| 合并策略 | 主线最终保留 | 必须治理的入口 |
|---|---|---|
| Merge commit | 分支 commits 与 merge commit | PR 内 commit 范围,加平台生成的 merge 消息策略 |
| Rebase merge | 重放后的每个 commit | PR 内全部 commits |
| Squash merge | 一个 squash commit | PR 标题或 squash 消息,同时保留必要变更声明 |
如果平台只保留 squash commit,只检查开发分支上临时消息没有意义;主线看到的是 PR 标题。反过来,团队保留每个 commit 时,只校验 PR 标题也会留下不可解析的历史。
在安装工具前写下团队契约:允许的 type、scope 来源、header 长度、breaking change 表达、revert/merge/机器人消息策略,以及哪些包变更必须提交 Changeset。契约先于工具,否则默认 preset 会被误当成业务事实。
commitlint 解析的是消息,不是代码语义
Conventional Commits 常见结构如下:
feat(api): add cursor pagination
Return a stable cursor for the order query.
BREAKING CHANGE: remove the legacy pageNumber response fieldtype 表示变更类别,scope 表示团队定义的影响域,subject 是简短摘要,body 解释上下文,footer 保存 breaking change、issue 或其他 trailer。feat 不必然等于 SemVer minor,fix 也不保证向后兼容;真实版本影响仍由公开契约、消费者行为和 Changeset 评审决定。
commitlint 的规则数组由三个位置组成:严重级别、适用条件和值。例如 [2, 'always', 100] 表示错误级规则,要求 header 长度始终不超过 100。0 关闭、1 警告、2 错误;只设置 warning 不会形成阻断。
安装 commitlint 并固定配置入口
在使用 Node 包管理器的仓库中,将 CLI 与共享规则同时加入开发依赖:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
npx commitlint --versionpnpm 对应:
pnpm add -D @commitlint/cli @commitlint/config-conventional
pnpm exec commitlint --version只有 CLI 没有规则时,commitlint 不知道团队契约。Node 24 改变了部分模块加载行为;没有 package.json 或模块类型不明确的仓库,使用 commitlint.config.mjs 可以避开 .js 配置被错误解释的问题。这一点来自 commitlint Getting started,升级 Node 或 commitlint 时要用真实配置做加载测试。
// commitlint.config.mjs
export default {
extends: ['@commitlint/config-conventional'],
defaultIgnores: true,
rules: {
'type-enum': [
2,
'always',
['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'test'],
],
'scope-enum': [2, 'always', ['api', 'web', 'worker', 'docs', 'deps']],
'header-max-length': [2, 'always', 100],
'subject-empty': [2, 'never'],
},
ignores: [(message) => message.startsWith('chore(bot): sync ')],
}extends 载入共享配置,rules 在其上覆盖团队约定,ignores 是显式例外函数,defaultIgnores 控制 commitlint 自带的 merge、revert、SemVer tag 等忽略逻辑。关闭默认忽略会让平台自动消息也进入规则判断,必须先收集真实样例;自定义 ignores 若过宽,例如按 message.includes('bot') 跳过,人工提交也可能伪装成机器人。
scope 应来自稳定的业务域或包边界,不要把每次新增目录都加入枚举。Monorepo 可以由脚本从 workspace 元数据生成 scope,但生成逻辑、排序和异常行为要进入测试;配置加载失败时必须非零退出,不能回退成“无规则”。
commit-msg Hook 必须读取 Git 交给它的消息文件
commitlint 的本地入口是 commit-msg,不是 pre-commit。Git 在这个阶段把消息文件路径作为第一个参数传入 Hook,commitlint 再通过 --edit 读取它。使用 Husky v9 时:
npm install --save-dev husky
npx husky init# .husky/commit-msg
npx --no -- commitlint --edit "$1"npx --no 禁止在本地依赖缺失时临时从网络安装同名包,避免开发者之间执行不同版本。Windows 团队也应让 Hook 保持 POSIX shell 兼容;Husky 由 Git 的 shell 环境执行,个人 PowerShell 路径不能写入共享脚本。
先用标准输入验证规则,再用真实 commit 验证 Hook:
printf '%s\n' 'feat(api): add cursor pagination' | npx commitlint --verbose
printf '%s\n' 'update' | npx commitlint --verbose第一条应退出 0;第二条应报告缺少 type、subject 等规则错误并退出非零。随后在临时分支暂存一个无害文件:
git switch -c lab/commit-message
printf '%s\n' 'metadata lab' > commitlint-lab.txt
git add commitlint-lab.txt
git commit -m "update"
git log -1 --oneline提交应被拒绝,git log 不应出现 update。再改用合格消息:
git commit -m "test(docs): verify commit message gate"
npx commitlint --last --verbose成功后删除实验分支和文件。不要在有未备份工作的分支上做故障注入。
本地 Hook 被绕过后,CI 必须抓住它
下面的反向实验故意绕过客户端 Hook:
printf '%s\n' 'bypass lab' > bypass-lab.txt
git add bypass-lab.txt
git commit --no-verify -m "update"
npx commitlint --last --verbosecommit 会被创建,但最后一条命令必须非零。这正是本地 Hook 只能提供快速反馈、不能充当强制边界的证据。完成实验后立即删除临时分支,不把坏消息推送到共享仓库。
PR 流水线要校验平台提供的 base/head SHA,而不是猜 HEAD~1:
git cat-file -e "$BASE_SHA^{commit}"
git cat-file -e "$HEAD_SHA^{commit}"
npx commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose官方 commitlint CI Setup要求获取足够历史;GitHub Actions 常用 fetch-depth: 0,GitLab 可设 GIT_DEPTH: 0。更严格的做法是使用平台事件提供的准确 SHA,再用 git cat-file 证明对象存在。若平台采用 squash merge,还要在合并规则中校验 PR 标题,因为它会成为最终 commit 消息。
机器人提交不能按作者邮箱一概跳过。依赖更新、代码生成、同步任务仍会进入主线,其消息应使用专用 type/scope 或精确 ignore,并由机器人身份、工作流文件与分支保护共同约束。攻击者不应仅凭伪造一个 chore(bot) 前缀获得免检。
Changesets 把开发阶段的变更意图保存下来
commitlint 能证明 feat(api) 的格式合法,却不知道 @acme/api-client 和 @acme/react 是否都需要面向使用者的说明。Changesets 用一个 Markdown 文件保存包名、SemVer 影响和摘要,让 PR 评审者在上下文仍然新鲜时审查意图。
安装并初始化 CLI:
npm install --save-dev @changesets/cli
npx changeset init
npx changeset --version初始化会创建 .changeset/config.json 和说明文件。一个开发阶段声明形如:
---
"@acme/api-client": minor
"@acme/react": patch
---
Add cursor pagination support and adapt the React query hook.文件名可以由 npx changeset 交互生成,也可以由受控工具创建;内容必须由贡献者审阅。摘要写消费者能够理解的行为变化,不能只写 issue 编号、内部实现或“update package”。
SemVer 选择应回到消费者契约:
patch:在既有契约内修复缺陷,消费者无需改变调用方式。minor:新增向后兼容能力,例如新增可选 API。major:删除、重命名、收紧输入、改变默认行为或其他不兼容变化。
数据库 schema、配置默认值、错误码、CLI 输出、CSS selector、事件 payload 和类型收窄都可能构成不兼容变化。是否选择 major 不能只看代码行数,也不能因为“内部包”就自动降级。
配置字段会改变 Monorepo 的判断边界
一个保守的开发阶段配置可以是:
{
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}这里最容易误配的不是 JSON 语法,而是字段背后的组织含义:
baseBranch 是变更比较基线。本地分支过旧或 CI 没有该引用,status --since 的判断会失真。commit: false 让 CLI 不替开发者自动提交文件,保留 index 审阅和签名流程。fixed 让一组包在后续版本计算中共同变更,适合同生命周期产品,而不是“依赖关系很多”的通用开关。
linked 约束相关包共享版本上界语义,但不等于任意一个包变化都强制发布全部包。updateInternalDependencies 影响内部依赖变化传播,需要用真实 workspace 图验证。ignore 是临时过渡工具。被忽略包与正常包出现在同一 Changeset、或影响内部依赖时,后续流程可能拒绝处理。
privatePackages 决定私有包的版本与 tag 行为,不能用来代替“这个改动是否需要团队内部变更说明”的治理决策。
这些配置同时影响后续版本计算,因此每次修改都要在临时工作区审查结果。开发阶段只提交和校验 Changeset 文件,不在开发分支执行 changeset version 或 changeset publish。版本文件、Changelog、Tag、Registry 与发布凭证由版本发布自动化手册接管。
用正反实验验证变更声明
在已经初始化 Changesets 的测试分支中,先改一个包但不添加声明:
git switch -c lab/changeset-intent
git cat-file -e 'origin/main^{commit}'
printf '\nexport const cursor = true;\n' >> packages/api-client/src/index.ts
npx changeset status --since=origin/maingit cat-file 失败时先获取并核对基线引用。Changesets 会根据 baseBranch 或 --since 比较变化;引用不存在、过旧或指向错误仓库状态时,CLI 会拒绝计算或给出失真的包集合,不能把这种工具故障当成“无须声明”。
团队若要求该类变更必须有 Changeset,CI 应把缺失状态转为失败。官方推荐使用 Changeset bot 或 changeset status;实际门禁还要结合仓库允许免声明的路径,避免文档、测试和 CI 调整被迫生成空洞版本说明。
运行交互命令,选择受影响包和 SemVer 类型:
npx changeset
npx changeset status --since=origin/main
git diff -- .changeset预期证据包括新增 .changeset/*.md、包名存在于当前 workspace、类型为 major/minor/patch、摘要描述消费者行为。status 能解析声明不代表语义正确,评审者还要比较代码、公共契约和依赖图。
再制造一个稳定失败:把 Changeset 中的包名改成不存在的 @acme/missing,运行:
npx changeset status --since=origin/mainCLI 应报告无法匹配包或无效 Changeset,并以非零状态结束。恢复真实包名后再次运行,证明门禁不是因为缓存偶然变绿。实验结束删除临时分支,不执行版本或发布命令。
什么变化需要声明,必须由仓库策略回答
“每个 PR 必须有 Changeset”会制造大量 patch: update tests 噪声;“由作者自觉添加”又容易漏掉真实消费者影响。更可执行的策略是先分类,再由路径和标签辅助判断:
| 变化 | 通常判断 | 评审证据 |
|---|---|---|
| 公共 API、CLI、配置契约、事件 schema | 通常需要 | 消费者行为与兼容性说明 |
| 用户可见缺陷修复 | 通常需要 | 修复前后行为与 patch 依据 |
| 内部重构且产物行为不变 | 可免 | 测试、产物 diff 或契约证明 |
| 仅测试、内部文档、CI 配置 | 多数可免 | 变更路径与无消费者影响说明 |
| 私有包 | 由内部消费者策略决定 | owner、依赖图与交付方式 |
免声明不能只靠空 Changeset。PR 模板可以要求选择“已添加 Changeset”或“无需 Changeset并说明原因”,CI 再结合 changed paths 检查。例外理由进入 PR 审计,不写进随机文件绕过机器人。
Monorepo 先画依赖图,再决定 fixed 和 linked
Monorepo 的难点不是一次选择多个包,而是变更会沿内部依赖传播。假设 @acme/react 依赖 @acme/api-client:api-client minor 变化后,react 是否需要声明,取决于它是否重新导出 API、依赖范围是否仍兼容、消费者是否必须同时升级。
fixed 适合必须锁步演进的一组包;它会扩大变化集合和维护成本。linked 更像版本协调约束,不是同步发布开关。ignore 只能解决短期阻塞,长期不交付的包应通过 private、仓库边界或专门策略表达。每次修改这些字段,都要准备至少四类样例:单包 patch、底层包 minor、跨包 breaking change、被 ignore/private 包变化。
包重命名和拆分尤其容易留下悬空声明。Changeset 文件引用的是包名,不是目录;移动目录但保持 package.json.name 不会自动更新历史意图。合并前运行 npx changeset status --since=<base>,并在评审中核对所有声明包仍存在。
提交消息和变更声明要分别接入 CI
一条稳健的元数据流水线至少有两个独立结果:commitlint 对最终提交范围给出消息校验,Changesets 对当前 PR 给出声明状态。不要把二者合成一个无法区分原因的 metadata failed。
set -eu
git cat-file -e "$BASE_SHA^{commit}"
git cat-file -e "$HEAD_SHA^{commit}"
npx commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
npx changeset status --since="$BASE_SHA"Changesets 的“是否缺少声明”还需要仓库策略层。常见实现是 Changeset bot 提示、CI 脚本比较受影响包,或由 PR 标签表达经批准的免声明。无论采用哪种方式,必须把 required、not-required、invalid 与 tool-failed 分开记录。
浅克隆、Fork 权限和 base 分支不同步是高频误报源。读取 Git 历史和 Changeset 文件不需要发布 Token;PR 元数据机器人只授予读取内容、写检查或评论所需的最小权限。来自 Fork 的代码不能获得高权限 Secret,也不应通过可修改脚本在特权上下文执行。
常见失败沿消息、历史和包图分层
配置存在却提示没有规则。 先执行 npx commitlint --print-config json,检查配置文件名、模块类型和 extends 依赖。Node 24 环境优先验证 .mjs。不要在加载失败时回退到无规则成功。
本地提交能通过,CI 报一批旧 commit。 base SHA 选择错误、浅克隆缺历史,或 merge-base 与平台事件含义不一致。打印 base/head、验证 commit 对象,再确认合并策略;不要靠不断扩大 --from 猜范围。
commit-msg 完全没有运行。 检查文件名必须是 commit-msg,再看 git config --show-origin --get core.hooksPath 和 npm run prepare。把 commitlint 挂在 pre-commit 读不到最终消息文件。
merge、revert 或版本消息被意外放过。 查看 defaultIgnores 与自定义 ignores。默认忽略是明确产品行为,不是 bug;团队要求校验这些消息时,应关闭或收窄并增加真实样例测试。
Changeset 明明存在,CI 仍说缺失。 检查 baseBranch、--since 引用、文件是否已进入 commit、包名是否与 workspace 一致,以及最近配置或忽略规则是否改变判断。先看 git diff "$BASE_SHA"..."$HEAD_SHA" -- .changeset,不要重复生成第二份声明。
每次 PR 都生成空洞 patch。 强制策略没有区分消费者影响。统计免声明和低信息摘要,调整路径分类、PR 模板和评审规则;数量不是治理质量。
内部依赖传播超出预期。 回查 fixed、linked、updateInternalDependencies、workspace 协议与真实依赖图。不要手工删除工具计算出的关联变化来追求更小集合,应先修正包边界或策略。
回滚和卸载不能破坏历史解释能力
移除 commitlint 时,按顺序删除 commit-msg 调用、配置与开发依赖,再清理项目脚本:
npm uninstall --save-dev @commitlint/cli @commitlint/config-conventional
git diff -- package.json package-lock.json .husky commitlint.config.mjs若 Husky 仍承载其他 Hook,不要删除整个 .husky/。回滚后用一个违规消息证明替代的服务端规则仍会阻断,否则这不是工具替换,而是控制消失。
移除 Changesets 前先盘点未合并和主线中的 .changeset/*.md。这些文件保存尚未消费的变更意图,直接删除会丢失审计信息。确认替代系统已经迁移包名、SemVer 类型、摘要和 PR 关联后,再卸载 CLI、删除初始化配置。任何版本计算或已发布状态的恢复都进入发布流程处理,不在开发分支用手工改版本号补救。
权限、供应链和长期维护
commitlint 配置可以加载 JavaScript、共享 preset 和自定义 parser;Changesets 的 changelog 或 commit 配置也可能加载模块。它们都是 CI 中执行的供应链代码,必须锁定版本、审查升级 diff、限制安装脚本,并由依赖扫描覆盖。动态生成 scope 或受影响包时,脚本输入只能来自可信仓库元数据,不能拼接未经转义的 PR 文本执行 shell。
元数据可能泄露内部项目名、客户名、漏洞细节和未公开路线图。公开仓库的 commit、Changeset 和 Changelog 都是永久记录,Secret 删除后仍存在 Git 历史。安全修复摘要应解释消费者动作而不披露可利用细节;真正的凭证一旦提交,必须立即吊销并按敏感信息响应流程处理,不能靠改写消息掩盖。
性能成本主要来自安装依赖、读取 Git 历史和大型 workspace 图。commit-msg 本地检查应保持秒级;需要网络或全仓分析的工作移到 CI。CI 缓存只能以 lockfile 和运行时版本为键,不能复用未知来源的可执行缓存。记录规则失败、工具故障、缺失声明、免声明原因、误报和 P95 耗时,才能判断门禁是在提高历史质量还是制造机械绕过。
架构师如何判断这套元数据链是否可信
可信链路有四层:开发者在最接近改动时写消息和变更声明;本地 Hook 提供快速反馈;CI 对最终 commit 范围和 PR 声明复核;分支保护让检查结果成为合并条件。任何一层都不能把工具退出 0 当成业务语义正确。
团队至少维护这些责任:规则 owner 决定 type/scope 与例外,包 owner 审查 SemVer 和消费者描述,平台 owner 保证 SHA 范围、权限与分支保护,发布 owner 消费已经审查的 Changeset。绕过记录要有原因、批准人、补检、到期时间;机器人例外要绑定身份和工作流,不按消息前缀放行。
版本策略变化时先回放历史样例:合格功能、兼容修复、breaking change、revert、merge、机器人、squash 标题、单包 patch、跨包 minor、包重命名、免声明文档改动。只有这些样例给出稳定结论,升级才具备可验收证据。
合并策略已确定,commitlint 校验的是最终进入主线的消息。type、scope、header、breaking change 与机器人例外来自团队契约。CLI、共享配置、parser 和 Changesets 依赖都由 lockfile 固定。
commit-msg 正确读取 $1,本地正反消息实验结果明确。--no-verify 反向实验能被 CI 的提交范围复核抓住。CI 使用平台 base/head SHA,并验证浅克隆所需对象存在。
面向使用者的包变化有 Changeset,免声明有可审查理由。Changeset 包名、SemVer 类型和摘要都由包 owner 审查。baseBranch、fixed、linked、内部依赖与 ignore 策略经过 Monorepo 样例验证。
开发分支不执行版本计算和发布命令,只交付可审查的变更意图。Fork、机器人和 CI 只获得元数据检查所需的最小权限。提交消息和 Changeset 不含 Secret、客户信息或不必要的漏洞细节。
工具故障、规则失败、缺失声明和批准例外能够分别审计。卸载或迁移不会删除未消费的变更意图,也不会让服务端门禁消失。
