做 monorepo 项目的人,迟早都会遇到同一个噩梦:版本发布。我记得有次给内部组件库加了个小功能,改完代码之后,光改几个包的version字段和 CHANGELOG 就花了大半个小时。结果发布没几分钟,下游项目就报错说找不到某个版本——原因也很低级,某个中间层包的package.json里依赖范围写错了。这种事靠人工几乎没法根治。后来我把版本管理切到了 Changesets,再也没手工改过版本号。如果你也在维护多包仓库,或者团队对版本发布这件事越来越头疼,这篇文章就是给你写的。我尽量把原理、配置、发布链路和踩过的坑一次讲透。
1. 版本管理这件事,为什么在工程化之后变得这么难
1.1 从一次发布会事故讲起
先说我那次事故的完整过程。我们当时维护一个三个包的小型组件库:core提供基础逻辑,ui依赖core,business依赖ui。需求是给按钮加一个 loading 状态,涉及core的一个 props 类型修改和ui按钮组件实现。
按照旧的流程,我需要做三件事:第一,判断哪几个包需要发新版本;第二,给每个包选一个合适的版本号;第三,更新每个包的package.json和相关 CHANGELOG。听起来不难,但实际操作中漏掉依赖关系是家常便饭。比如这次我改了core,发了1.2.3,然后去改ui时忘了把ui对core的依赖范围从^1.2.0升到^1.2.3,结果业务包理论上能拿到新逻辑,实际发布顺序又不稳定,下游安装时偶尔装到旧版core,行为就飘了。
这类问题的根源不是版本号难算,而是版本发布本质上是对"变更影响范围"的判定。你改了一行代码,它属于fix还是feat?它会传导到哪些依赖方?这些信息在改动发生时最清楚,等代码合进主干再来反推,信息早就损耗了。
1.2 为什么 commit message 驱动方案覆盖不了 monorepo
有朋友会问:还有 semantic-release 这类工具,看 commit message 自动定版本,不是更省事吗?它在单包仓库里确实好用,因为一次 commit 就是一次发布单元。但在 monorepo 里,一次 commit 可能同时改了core和business,也可能只是改了docs目录,你怎么区分?靠 git diff 检测文件路径是个办法,但 diff 只能告诉你"哪些文件变了",不能告诉你"这是新功能还是修复",更不能告诉你维护者是否打算发布。
Lerna 早期的方案是基于 diff 自动判断发包范围,省事是省事,但也带来一个问题:有时候只是挪了个 README,它也会认为包有变更要发布;有时候一行重构改动了公共 API,但 diff 粒度太粗,它又没法自动判断该发 major 还是 patch。说白了,版本决策是一个意图问题,不能完全靠机器从代码差异里猜。
1.3 Changesets 把版本决策前移到改动发生时
Changesets 的核心思路很朴素:与其事后靠工具猜,不如在每次改动发生时,让开发者亲口声明"我这次影响了哪些包、影响是什么级别"。它把这个声明做成一个叫变更集(changeset)的小文件,放在仓库里跟着代码一起提交。等攒够一批变更,发布时由工程化流程自动算出所有包的新版本号、自动改package.json、自动生成 CHANGELOG、自动按依赖顺序发布。
这套思路的变化看起来很轻,实际把"版本管理"从一件靠记忆和纪律的事,变成了一个有明确输入、有自动输出的流水线。这也是我标题里强调"高效"的原因——效率提升不是省掉了声明这一步,而是把最容易出错的版本计算、依赖传导、发布顺序全部交给了机器,让人的精力只花在"我的改动属于什么级别"这一件机器确实替代不了的事上。
2. 变更集文件拆解:一张小卡片怎么驱动整条发布链路
2.1 一个 changeset 文件长什么样
changeset init之后,仓库里会多一个.changeset/目录。每次你创建变更集,里面就会多一个 markdown 文件,文件名是随机的单词组合,比如.changeset/wise-foxes-join.md。内容分两部分:开头是 YAML 格式的配置区,下面是 markdown 格式的说明文字。
--- "@my-org/renderer": minor "@my-org/theme": patch --- 新增按需渲染模式,同时修复按钮组件在暗色主题下的焦点样式问题。YAML 区里每一行的意思是:某个包在这次变更中应该 bump 到什么级别。下面的 markdown 正文是给 CHANGELOG 用的描述,通常写清楚"改了啥、为什么改、使用者需要注意什么"。变更集文件的本质是把版本变更的意图以文件形式沉淀下来,它可以进 code review,可以进 git 历史,可以在合并时被自动消费。
2.2 三种版本级别怎么选
Changesets 只认patch、minor、major三个级别,规则和语义化版本规范一致。我这里给团队的建议是:
| 级别 | 适用场景 | 典型例子 |
|---|---|---|
patch | 修复问题,行为保持不变 | 修了一个边界条件的 bug |
minor | 向后兼容的新能力 | 新增一个 API、增加一个可选参数 |
major | 破坏性变更 | 删除接口、改变返回类型、升级底层大版本 |
有个容易混淆的点:如果你同时改了 API 和行为,保守起见按更高一级算。比如给某个组件加了一个默认导出,但与此同时删掉了一个旧导出,整个包应该算major,因为用了旧导出的用户升级后直接编译失败。变更集声明得越准确,后面发布越顺利,最怕的是为了省事统统写patch,结果某次破坏性变更以patch形式发出去,坑了一整条依赖链。
2.3 version 命令背后的依赖推导
变更集文件攒在仓库里,等你要发版时运行changeset version,它会做几件事:
- 读取
.changeset/下所有变更集文件,汇总出每个包应该达到的版本号; - 重写所有涉及包的
package.json版本号; - 更新内部依赖的版本范围,这是最值钱的一步;
- 按变更集正文生成或更新 CHANGELOG.md;
- 删除已经消费掉的变更集文件。
重点是第三点。还拿ui依赖core举例:core发了minor,但ui之前依赖的是^1.2.0,如果只改core不改ui的依赖范围,用户安装ui的新版本时可能不会自动带上新的core。Changesets 会自动把ui对core的依赖范围也提升,这个行为由配置项updateInternalDependencies控制,默认是"patch",含义是"即使依赖方只是内部依赖改了 patch,依赖方也要跟着 bump patch"。这样能保证同一批发布的包之间,依赖范围一定是对得上号的。
2.4 fixed 与 linked:让一批包同呼吸
如果一组包之间存在强耦合,比如组件库本体和它的类型定义包,用户几乎总是成套安装,那你可以用fixed把它们绑在一起:
{ "fixed": [["@my-org/renderer", "@my-org/theme", "@my-org/utils"]] }配置之后,只要组内任何一个包需要发新版本,整组包都会统一 bump 到一个相同的版本号,并且同一次发布。这解决了"组件库主包升了 minor、配套主题包没升,用户装下去组合混乱"的问题。fixed是我个人用得最多也最推荐的模式,语义简单,结果可预期。
linked是另一种分组方式,适合"彼此需要共享大版本边界,但允许独立发布节奏"的场景。实际团队里用linked的情况很少,我建议你在没完全理解它的行为差异之前,先用fixed把需求表达清楚。配置写错导致的后果比不配更严重,因为它会直接干预一批包的版本号计算。
3. 从零搭建:pnpm workspace + Changesets 的初始化全流程
3.1 项目结构准备
先说清楚,Changesets 不是 monorepo 专属,单包仓库也完全可以用,只是 monorepo 场景最能体现它的价值。下面我用 pnpm workspace 演示,这也是目前集成体验最好的组合。
mkdir my-monorepo && cd my-monorepo pnpm init touch pnpm-workspace.yamlpnpm-workspace.yaml至少写:
packages: - "packages/*"然后创建两个示例包,packages/utils和packages/renderer,其中renderer依赖utils。packages/renderer/package.json里的依赖这样写:
{ "name": "@my-org/renderer", "version": "0.1.0", "dependencies": { "@my-org/utils": "workspace:*" } }根目录的package.json建议加一行"private": true,避免根包被当成可发布包误发。
3.2 安装并初始化 Changesets
pnpm add -D -w @changesets/cli pnpm changeset init第一条命令在 workspace 根安装 CLI,-w代表把依赖装到根节点。第二条命令会创建.changeset/config.json和.changeset/README.md。README 是给团队看的规范说明,config 是控制发布行为的核心。
3.3 config.json 逐项理解
初始化生成的配置是全默认的,我建议你尽早把它改成适合自己仓库的样子,下面是一份我常用的模板:
{ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": "@changesets/changelog-github", "commit": false, "fixed": [["@my-org/renderer", "@my-org/theme", "@my-org/utils"]], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["@my-org/docs"], "privatePackages": { "version": true, "tag": false } }逐项说:
changelog:生成 CHANGELOG 的策略。默认值会生成普通 markdown,可以换成@changesets/changelog-github让它附带 PR 链接和贡献者信息,也可以指向你自己写的一个模块。commit:如果为true,changeset version之后会自动帮你 git commit,省一步手动提交;我一般建议刚开始用false,让操作者自己 review 一遍改动再提交,可控性更强。access:npm 发布时的访问级别。私有包填restricted,开源包一定填public。如果你没有 npm 私有仓库权限,默认的restricted会在发布时直接报 404。baseBranch:工具计算"哪些包有变更"时的基准分支,默认是master,现在绝大多数仓库已经切到main,记得改。updateInternalDependencies:如前面所说,控制内部依赖更新时依赖方跟不跟着 bump,保持默认"patch"是安全的选择。ignore:完全不参与版本管理的包列表,适合放文档站、脚手架配置等不需要发版的包。privatePackages:控制 private 包的行为。version: true表示 private 包也会跟着更新版本号,但不参与发布;tag: false表示发布时不为它们打 git tag。
3.4 交互式创建第一个变更集
配置写好后,创建第一次变更集直接运行:
pnpm changesetCLI 会进入交互模式,大致流程是:
- 列出当前 git 工作区里有改动的包,让你用空格勾选哪些包要纳入本次变更集;
- 对每个选中的包,选择
patch、minor还是major; - 输入一段变更说明,也就是后面 CHANGELOG 里的内容。
全部走完后,.changeset/目录里会多出一个随机命名的 md 文件。我有一次手滑输错了说明,想把文件删掉重新生成,直接rm掉那个文件再跑一次pnpm changeset就行,变更集文件是离散的,删了不影响其他任何东西。
这里有个经验:不太建议完全手写变更集文件。虽然格式简单,但 YAML 区里的包名必须和package.json里的name完全一致,手写容易漏引号或拼错;而且 CLI 会自动列出工作区有变更的包,手写还得自己回忆改了哪些包。让工具生成,人只负责选和写说明,出错率低很多。
4. 发布链路解析:version、publish 与 CI 自动化的正确姿势
4.1 changeset version:一次按下,完成四件事
批量发布前,先要合并且整理变更集。在主干分支上运行:
pnpm changeset version这一步会把你攒下的所有变更集一次性"消费"掉。我每次跑这条命令都会先git status看看工作区干不干净,因为如果有未提交的改动混在一起,后面生成的版本号和你预期对不上时,排查起来很痛苦。
跑完后你会看到一批文件变化:被选中的包package.json版本号更新了,CHANGELOG.md 里多了对应的条目,内部的workspace:*依赖范围被写成了具体的版本号,.changeset/下的变更集文件被删掉了。此时版本更新已经完成,但还没有发布到 npm,你可以 review 一遍改动,确认无误后提交。
4.2 publish 的包管理器检测与发布顺序
接下来发布:
pnpm changeset publishchangeset会自动检测你仓库用的是哪种包管理器(看 lockfile),如果识别到pnpm-lock.yaml,会走pnpm publish的逻辑,从而正确处理workspace:*协议的替换。发布顺序按依赖拓扑排序——先发布依赖的底层包,再发布上层包,避免出现"A 包发布时引用的 B 包新版本还没上 registry"的情况。发布成功的包还会被打上形如@my-org/renderer@1.1.0的 git tag,方便后续追溯哪个版本对应哪次发布。
只有版本号确实变化的包才会执行推送,没变更的包会被跳过,所以这条命令在批量发布多个包时是幂等且安全的。
4.3 用 GitHub Actions 搭自动发布工作流
手工跑version和publish虽然可行,但团队协作里很容易出"有人忘了跑、有人跑完忘了推"的问题。我推荐把它完全交给 CI,GitHub Actions 配合官方changesets/action基本是标准答案:
name: Release on: push: branches: [main] permissions: contents: write pull-requests: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install - name: Create Release Pull Request or Publish uses: changesets/action@v1 with: publish: pnpm changeset publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}这个工作流的妙处在于它把"发布版本"变成一次 PR 流程,而不是某个人在本地执行的神秘操作。当有人把变更集文件合入main后,action 会自动打开一个名为Version Packages的 PR,里面带着版本号更新和 CHANGELOG 变化;团队 review 这个 PR 后合并,action 随即触发changeset publish真正发布。整个过程每个环节都有记录、有 review、可回溯。
注意fetch-depth: 0一定要加,changeset需要足够的 git 历史来判断变更集合与 baseBranch 的关系。
4.4 预发布与快照版本
正式发版之外,还有一个高频需求:给某个 feature 分支发一个测试版本,让下游团队提前联调。Changesets 提供了快照版本能力:
pnpm changeset version --snapshot pnpm changeset publish --tag next第一行会根据当前变更集生成带分支信息和时间戳的预发布版本号,第二行把这些版本发布到 npm 的nexttag 下,不会污染latest。消费方通过pnpm add package@next就能装到最新测试版。等测试通过,再走正常流程合入主干发正式版,正式版会自然覆盖掉 alpha/beta 的标记。
我用这个功能的经验是:别把快照发布接到 CI 默认流程里,只在需要时手动触发一个专门的工作流,否则仓库里会飘满一堆过期版本号,看着很乱。
5. 团队协作中的配置细节:排除、检查与 changelog 定制
5.1 哪些包该排除在发布之外
刚开始用 Changesets 的团队容易犯一个错:所有包都开放发版,结果docs包、examples包、scripts配置包全被发布到了 npm。正确的做法是把这些包在package.json里设为"private": true,或者在 config 的ignore里显式声明。这两个手段的区别是:
private: true的包永远不会被 npm publish,这是防御手段;ignore里的包连版本号都不会被 Changesets 管理,适合那些"版本号跟着仓库走就行"的不发布包。
我的一般建议:能设 private 的设 private,并且不要让ignore影响太多包。如果你把一个实际上会发版的包误加了 ignore,后面只能手动改配置再补一次发布,比较麻烦。
5.2 把 changeset 检查嵌进 PR 合入流程
比发布自动化更值得投入的是"约束每个 PR 必须带变更集"。GitHub 机器人changesets-bot会在 PR 里自动提醒"这个 PR 缺少 changeset 文件",也可以在 CI 里手动检查:
pnpm changeset status --since=origin/main这句命令会对比当前分支与origin/main的差异,如果没有检测到影响变更的变更集,它返回非零退出码,CI 就会失败。我团队里直接把这条命令放在了 PR 的 CI 第一步,规则是"但凡改了packages/下的代码,就必须带变更集"。刚开始同事觉得多此一举,后来发过两次漏发变更集导致线上版本不一致的事故,大家都理解了这条规则的价值。
5.3 changelog 定制:让发布说明更可读
默认的 CHANGELOG 格式能用,但内容基本等于变更集正文的拼接。如果你想让它更丰富,可以换用@changesets/changelog-github——它会把变更集正文、对应 PR 链接、作者信息一起整合进去。也可以自己写一个 changelog 模块,需要导出一个固定的函数结构:
// custom-changelog.cjs module.exports = { getReleaseLine: async (changeset, type, options) => { const [firstLine, ...futureLines] = changeset.summary.split("\n"); return `- ${firstLine} (${type})`; }, getDependencyReleaseLine: async (changesets, dependenciesUpdated, options) => { return `- 更新依赖版本:${dependenciesUpdated .map((dep) => dep.name) .join(", ")}`; }, };然后在 config 里把changelog指到这个文件:
{ "changelog": "./custom-changelog.cjs" }自定义的边界是把内容生成逻辑封装好,保持函数纯粹,不要在里面做网络请求或读文件。变更集文件本身是 markdown,你在getReleaseLine里完全可以把第一段变成粗体标题,后面的段落当作列表补充,生成的内容自由度很高。
5.4 发布分支策略与版本规则的协同
最后聊一下 Changesets 和分支策略怎么配合。常见做法是主干分支出minor/major能力发布,release 分支只接受patch修复。这个策略可以放在 code review 时人工约束,也可以在 CI 里检查:release 分支上运行的changeset status如果发现major级别的变更集,直接报错。
要保证这套规则跑得顺,变更集的粒度很重要。我见过同事一个 PR 里塞了五六个变更集文件,等于把一个功能拆成了五次版本声明,review 的时候没法判断每个变更集对应哪段代码。更好的做法是一个 PR 尽量只带一个变更集,如果确实一个 PR 改了多个包,就在一个变更集文件里把它们都列在 YAML 区。这样发布说明和代码变更的对应关系是清晰的,将来查问题也容易定位。
6. 踩坑记录:从失败案例里总结出的使用习惯
6.1 pnpm 发布时 workspace 协议替换失败
这是 pnpm 用户最容易踩的坑。workspace:*协议在pnpm publish时会自动替换成实际版本,但如果项目里的packageManager字段或 corepack 约束和当前执行环境不一致,pnpm 10 会直接拦下来,报错信息看着像权限问题,其实是因为它启动了package-manager-strict校验。遇到这种情况,检查根目录package.json里的packageManager是否写明版本号,或者在.npmrc里关闭严格模式:
package-manager-strict=false另外发布完成后,我习惯跑一下pnpm pack看产物内容,确认产物里的dependencies没有残留workspace:*。如果发布了带有workspace:*的包到 npm,下游安装时 pnpm 压根不认识这个协议,直接安装失败,而且这种问题往往要到下游才能暴露,修复成本非常高。
6.2 多人并行开发时 changeset 文件冲突
多人同时开发不同功能,各自生成了变更集文件,合并分支时经常出现两个随机文件名的 md 文件同时被创建的情况。Git 处理这种冲突通常是两个文件都保留,不太会冲突;真正会冲突的是两个人都改了同一个包的package.json版本,然后又在各自分支跑了changeset version。前者只需要合并时注意别丢文件,后者比较麻烦。
我的处理习惯是:变更集文件和代码在同一分支合入,changeset version只允许在主干或发布分支上执行,不要在功能分支上提前消费变更集。功能分支合入时如果发现版本号冲突,优先重新生成本地变更集而不是手动改版本号,因为手动改版本号绕过了 Changesets 的版本计算,容易留下一堆"版本跳变"的历史。
6.3 发布后版本被跳过:dist-tag 的坑
还有一次事故印象很深:我们通过changeset publish --tag next发了一个预发布版,但某位同事为了给一个下游紧急修复,手动执行npm publish并把--tag latest发到了正式版本号上,结果仓库里正式版本直接跳过了 CI 的流程,导致latest上多了一个没有 CHANGELOG、没有 git tag 的版本。后来好几个包依赖它,排查时完全没有上下文。
从那以后,我定的规矩是:所有包必须走 Changesets 发布,不提供手动发布权限。npm 的 dist-tag 变化很容易产生历史混乱,一旦出现多个 tag 指向同一个版本,后续dependabot和人工 review 都没法确认到底哪个是"真版本"。
6.4 发布后及时消费变更集文件
最后一个习惯我会特别叮嘱团队:发布之后尽快把变更集文件和 CHANGELOG 提交推送到主干,不要留在本地过夜。变更新集文件在开发时是"输入",在发布后就是"已消费垃圾",留着反而会让下次changeset status的检查结果变得难以解读。如果你在 CI 里用了自动发布,这一步会自动完成;如果是手动发布,记得把 version 命令产生的改动 push 回远端。
我个人在用了两年多 Changesets 之后的体感是:它并没有让版本管理从"需要人操心"变成"完全不用操心",而是把人的注意力从"版本号算术"转移到了"变更语义判断"上。后者才是真正需要人来做的决策。所以在项目早期就引入它,成本很低,收益会随着包数量和协作者数量的增长越来越明显。如果你的团队还在手工维护版本号和 CHANGELOG,我建议直接照上面这套流程试一次,跑通一次发布之后,你应该就不会想回去了。