ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Changesets实战:Monorepo版本管理与自动发布方案

Changesets实战:Monorepo版本管理与自动发布方案 在维护开源包和工具库的这些年里我几乎每天都在跟版本管理这四个字较劲。手动改package.json里的版本号、写完代码再回头补 changelog、发布前纠结到底是patch还是minor——这套流程在只有一个仓库、两三个包的时候还能勉强应付一旦上了 monorepo、仓库里躺着十几个互相依赖的包每次发版都像在走钢丝。而 Changesets 就是我在踩了无数次坑之后最终留下来并一直在用的版本管理方案。这篇文章我会从设计思路到实操落地掰开揉碎讲清楚它到底解决了什么问题、怎么用、以及那些文档里不会写但你会真实遇到的坑。1. 内容整体设计与核心思路1.1 版本管理到底难在哪里先说说我自己的经历。早期维护一个工具库每次发布前都要手动做三件事改版本号、更新 changelog、打 tag。听起来不复杂但实际执行起来全是问题。第一版本号改错。主版本、次版本、补丁版本的语义规则大家背得滚瓜烂熟但真到了这个 PR 是加了新功能还是修了 bug的判定环节人脑很容易双标。同一个功能有人觉得是minor有人觉得是patch最后谁嗓门大听谁的。第二changelog 靠回忆。发版那天才想起来要写 changelog于是开始翻 commit 记录。而大部分 commit message 写得跟天书一样什么 fix、refactor、update根本看不出来到底改了什么更别提交替用户梳理哪些是破坏性变更。第三monorepo 场景下彻底失控。我维护过一个使用 pnpm workspace 的项目仓库里有 8 个包其中 3 个包互相依赖。发布 A 包时改了它依赖的 B 包版本却忘了检查 C 包是否也需要同步升级。结果是用户在装 C 包时拉到了升级前版本的 B线上出现了诡异的兼容问题。这些问题的本质在于版本管理不是发布那一刻的动作而是从代码提交那一刻就应该开始的持续过程。Changesets 解决的就是过程的记录与决策的沉淀。1.2 Changesets 的核心设计哲学Changesets 的设计思路和传统方案有一个根本区别传统方案是我告诉你我改了什么你帮我生成版本号Changesets 是你描述这次改动的影响我根据描述决定版本怎么变。具体来说它要求开发者在每个 PR 里附带一个 changeset 文件。这个文件不是随便写个数字而是包含三件事这次改动涉及哪些包、版本变化的类型patch、minor、major、以及给用户看的变更说明。多个 changeset 可以汇总发布也可以独立发布全看你的发布节奏。这套设计带来的直接好处是决策前置。提交代码的时候你刚刚写完这个功能对它的影响面记忆最清晰。而此时写下这是 breaking change比发布前翻 commit 去回忆要准确得多。而且因为 changeset 文件是跟着 PR 一起 review 的团队里其他人也能在代码评审阶段就参与版本决策的讨论而不是等到发版那天才来吵。这个机制还间接解决了一个问题——changelog 不再需要写。Changesets 会在changeset version阶段自动把你的变更说明合并进项目的 CHANGELOG.md格式统一、分发到位。你只需要在写 changeset 时把话说清楚剩下的排版工作交给工具。1.3 为什么不用语义化版本的自动化推导有的团队会用 commit message 的规范来自动推导下一个版本号比如 conventional commits semantic-release。这套方案我试过在简单项目里体验确实顺滑但在 monorepo 里会碰上两个大问题。第一个问题是多包版本联动的不确定性。monorepo 里一个 commit 可能同时改了多个包如果用 commit message 推导版本你很难在单个 commit 的 message 里精确表达A 包是 minor、B 包是 breaking、C 包不变这种组合。硬要表达也不是不行但 commit message 会变得非常啰嗦而且解析规则稍微一复杂就容易出错。第二个问题是发布粒度偏粗。语义化提交方案往往倾向于一个 commit 对应一个版本但在实际开发中某个功能可能需要拆成 5 个 commit 才能完成或者一个 PR 里既有新功能又有修复。用 commit 推导版本灵活性远不如单独维护一个 changeset 文件来得高。所以我的结论是自动化程度最高的方案不一定是最适合团队的方案。Changesets 提供了恰到好处的自动化——版本变更的记录自动化了但决策依然留给人。这种平衡在实际工程里往往更可持续因为版本号的语义本质上是一种公共契约它应该经过人的确认而不是完全交给规则。2. 核心机制与关键技术拆解2.1 changeset 文件到底是什么你可能已经在很多开源仓库里见过.changeset目录里面的 markdown 文件常常以young-pandas-raise.md这种随机风格命名。这就是 changeset 文件它看起来像这样--- scope/package-a: minor scope/package-b: patch --- 这是一个新功能支持了某某参数用法如下...三段式结构YAML 前置信息描述哪些包需要版本变更以及变更级别正文描述给用户看的变更说明。文件名的随机后缀纯粹是为了避免同名冲突没有实际语义。这里有个细节值得展开并不是所有包都要出现在 changeset 文件里。如果你的某个改动只涉及 A 包那 changeset 里就只写 A 包。B 包虽然没有出现在 changeset 里但如果发布了Changesets 会根据内部依赖关系判断它是否需要连带更新版本这个机制我在后面会详细讲。2.2 changeset add 命令的实际执行过程实际开发中不会手动创建这些 markdown 文件而是通过命令生成。在项目根目录执行npx changeset或者使用 pnpmpnpm changeset这个命令会启动一个交互式问答。先让你选择本次变更涉及哪些包支持多选然后逐个询问每个包的版本变更级别最后打开一个编辑器让你写变更说明。整个过程结束后会在.changeset目录下生成一个新的 markdown 文件。有些朋友第一次用会觉得这个过程繁琐毕竟它强制你停下来想清楚。但我发现坚持用一段时间后这个繁琐反而是它的价值所在——它把版本决策这个原本发生在发版日、耗时且容易出错的动作成功拆解成了日常提交时一个轻量的习惯。每条变更的影响面清清楚楚发版变成了一次点击按钮就能完成的执行动作。2.3 版本策略配置fixed 和 linked默认情况下Changesets 把每个包当作独立的版本线来控制。但在 monorepo 中很多团队其实使用 fixed 模式——所有包保持相同的版本号发布时一起发。最典型的例子就是 React 或 Vue 这类框架一个仓库里虽然拆分了多个包但对外版本号必须保持一致。配置方式是在.changeset/config.json中声明{ fixed: [ [scope/pkg-a, scope/pkg-b] ] }凡是出现在同一个数组里的包版本号会被绑定在一起。其中任何一个包产生了major变化其他包也会跟着升major任何一个包发版其他包也会在同一个版本更新中一起发。这样就不会出现主包升级到 2.0副包还停在 1.4的尴尬状态。linked模式就温和一些。它只保证包之间的版本号保持在相同的刻度上——比如某个数组里的包都是1.2.x那就必须都是1.2.x但不需要完全相同的版本号。它更适合那些没有互相依赖但希望版本节奏保持一致的兄弟包。我个人的经验是如果包之间没有硬依赖关系尽量避免使用fixed不然每个包的版本号都会被木桶效应拖着走一个包的频繁改动会让所有包都跟着发版产生大量无意义的 changelog 记录。2.4 内部依赖声明的自动更新逻辑monorepo 中最烦人的一个问题是A 包依赖 B 包B 包升了版本A 包的package.json里scope/b: ^1.0.0这个声明是否要跟着变Changesets 的答案是默认情况下来个自动更新。在changeset version执行时工具会检查所有受影响的包之间的依赖关系如果某个包的依赖也发生了版本变更会在该包的package.json中自动把这个依赖的版本范围更新到新版本。这个行为由配置项updateInternalDependencies控制默认值是patch意思是只要内部依赖的补丁版本升级了就同步更新声明。这里有三种行为级别patch补丁版升级就更新、minor次版本升级才更新、none永远不自动更新。在 workspace 协议workspace:*下pnpm 和 Changesets 配合得非常好生成发布后的版本声明时会将workspace:*替换为实际版本号。这个自动更新机制解决了 monorepo 里的老大难问题。以前我手动处理这个问题时经常忘了更新某个下游包的依赖声明导致用户拿到的是过时的依赖版本。现在这个环节完全自动化了发布出去的包依赖声明永远是准确的。2.5 changelog 的生成与定制Changesets 自带一套 changelog 生成逻辑会读取 changeset 中 markdown 部分的文字内容再附带上版本号和日期最终生成 CHANGELOG.md。默认的格式长这样# scope/pkg-a ## 1.2.0 ### Minor Changes - 支持了某某参数用法如下...如果你对格式不满意可以通过配置changelog字段指向一个自定义的生成函数。这个函数接收两个参数——changesets 数组和 options 对象返回一段字符串作为追加到 changelog 中的内容。我见过有的团队把 changelog 生成函数直接对接自己的文档系统这样版本发布后文档自动更新整个链路彻底贯通。也有人用它做自动化通知——在 changelog 里插入特定的标记CI 发布后通过 webhook 推送到钉钉或飞书群。这块的玩法非常多全看你的想象力。3. 实操过程从零到一完整走一遍3.1 安装和初始化配置如果你的项目还没有安装 Changesets第一步是安装pnpm add -D changesets/cli然后初始化npx changeset init这个命令会在项目下生成.changeset目录里面包含一个config.json文件和一个README.md文件。默认的config.json长这样{ $schema: https://unpkg.com/changesets/config3.0.0/schema.json, changelog: changesets/cli/changelog, commit: false, fixed: [], linked: [], access: restricted, baseBranch: main, updateInternalDependencies: patch, ignore: [] }有几个配置项需要解释一下changelog指定 changelog 生成逻辑。默认使用changesets/cli内置的生成器。如果你想用自定义的生成器这里改成模块路径即可。commit设置为true时执行changeset version会自动生成 git commit。我通常设为false因为我希望 commit 是手动控制而不是工具代劳。团队协作时尤其谨慎自动化生成的 commit message 往往不够直观。access发布到 npm 的访问级别。如果是公开包设为public如果是私有包保持restricted。baseBranch告诉 Changesets 你的主分支是哪条。这关系到changeset status --since-master这类命令的比较基准。3.2 开发过程中创建和积累 changeset项目初始化完成之后日常开发流程就变成了这样拿到需求 → 创建功能分支 → 写代码 → 提交代码前执行pnpm changeset→ 回答几个问题 → 把生成的 markdown 文件一起提交。这里我踩过一个有意思的坑。最初引入 Changesets 的时候团队成员经常忘记执行pnpm changeset结果到了发版日发现这周改了 20 个文件但.changeset目录里一个 changeset 都没新增。版本信息严重缺失又走上了临时翻 commit 回忆的老路。后来我总结出一个规律把changeset命令放进 PR 的检查清单里最好直接写进仓库的 CONTRIBUTING.md 文档。如果你用的是 GitHub还能在 CI 里加一步检查——用changesets/action配合changeset status命令如果当前 PR 没有附带 changeset 文件就让 CI 直接报错。这一步非常有效我现在维护的项目几乎不会出现缺少 changeset 的情况。对于临时修改、补丁之类的没必要写 changeset的想法我的建议是只要这个改动会影响包的使用者你就写。无论是修了个 typo 还是调整了样式对用户来说都是行为变化。唯一不需要写 changeset 的情况就是纯内部重构、测试代码变更这类用户完全感知不到的改动。3.3 发版流程changeset version 的幕后逻辑当你的改动积累到差不多该发一版的时候执行pnpm changeset version这个命令会做几件重要的事。第一它会读取.changeset目录下所有未被消费的 changeset 文件。第二它会根据这些文件里记录的版本变更级别结合当前包的版本号计算出每个包的新版本号。第三它会更新所有受影响包的package.json、生成或追加 CHANGELOG.md 内容并在必要情况下更新内部依赖声明。第四它会删除这些已经被消费掉的 changeset 文件——本质上是把 changeset 的信息沉淀进了 changelog。这个动作完成后建议手动跑一下全量测试和构建确认版本更新之后代码一切正常。然后把这些变更提交并推送。此时如果你配置了 CI 发布流程发布时机是 release 分支的代码被合并进主干之后而不是版本命令执行之后。版本命令只负责把各种信息落实到文件里真正的发布动作靠后续的changeset publish命令。3.4 发布过程changeset publish 的注意事项执行发布命令pnpm changeset publish这个命令等价于先对每个需要发布的包执行一次npm publish然后为每个包在 git 上打 tag。它会跳过那些没有版本变化的包只发布真正有更新的包。发布过程中容易踩的坑主要有三个。第一个坑npm registry 的访问权限配置。如果你的包是 scoped 包比如scope/pkg-a默认情况下 npm 会把它当作私有包处理发布要求登录且包存在权限控制。如果你要发公开包config.json里的access要设为public并且需要配置 npm 的 token 环境变量如NODE_AUTH_TOKEN。在 CI 里这步经常被漏掉导致 publish 时报 403。第二个坑包的publishConfig字段。建议在每个包的package.json里显式声明{ publishConfig: { access: public, registry: https://registry.npmjs.org/ } }这样可以避免因为环境变量缺失或 npm 配置残留导致发布到错误的 registry。我之前就遇到过因为电脑上残留了淘宝镜像源配置发布时把包推到了镜像源上花了半天才排查明白。第三个坑tag 的一致性。每发布一个包Changesets 都会打一个形如scope/pkg-a1.2.0的 tag。如果你的发布流程中还有其他打 tag 的逻辑注意别给同一个版本重复打 tag否则会在 git 历史里留下混乱的标记。3.5 monorepo 场景下的完整配置案例这里给出一份我在 pnpm monorepo 项目中使用的完整配置可以直接参考。项目结构packages/ core/ # 核心逻辑 react/ # React 封装 vue/ # Vue 封装 utils/ # 工具集.changeset/config.json{ $schema: https://unpkg.com/changesets/config3.0.0/schema.json, changelog: changesets/cli/changelog, commit: false, fixed: [], linked: [], access: restricted, baseBranch: main, updateInternalDependencies: patch, ignore: [] }其中packages/core是被packages/react和packages/vue依赖的基础包。core发布后如果版本有变化react和vue的依赖声明会在changeset version时自动更新。这个配置适合大多数 monorepo 项目。如果你的团队希望核心包和其他框架封装保持版本一致可以把react和vue放进linked数组让它们的版本刻度保持一致反之如果你希望完全独立决策每个包的版本号就保持默认的独立模式。3.6 通过 CI 自动化整个流程人工发版的失误率再低也架不住次数多能交给 CI 的就别用手点。我现在的项目用的是 GitHub Actions配合官方提供的 changesets/action 核心流程是这样的main 分支的每次 push 都会触发一个 workflow运行一次changeset version如果发现.changeset目录里有未消费的 changeset 文件就创建一个名为changeset-release/main的 PR。这个 PR 里包含更新后的版本号、changelog 和内部依赖声明。团队 review 这个 PR 并合并后再触发发布 workflow 执行pnpm changeset publish。这套流程有个好处版本变化和发布动作天然分离。版本更新的 PR 专门负责告诉仓库接下来版本应该变成什么合并发布则负责把版本落到实处。中间插入了人工 review 环节相当于给发版操作加了一道安全闸。发布 workflow 的publish脚本大概是这样的name: Release on: push: branches: - main jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - uses: pnpm/action-setupv2 - uses: actions/setup-nodev4 with: node-version: 20 registry-url: https://registry.npmjs.org/ - run: pnpm install --frozen-lockfile - run: pnpm changeset publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}这里特别要留意registry-url的配置。在 CI 中如果不显式指定 npm registry 地址而是依赖.npmrc文件或环境变量很容易出现 403 或 EPublish 错误。我在本地测试时遇到过由于全局.npmrc配置了私有镜像源CI 里又忘了覆盖发布时包被推到了镜像源上等发现时已经产生了脏状态只能删除包重新发非常被动。3.7 快照发布让预发布环境也能拿到新版本多分支协作时你可能希望测试环境或其他下游团队能拿到某个功能分支的最新版本而不是等正式发布。Changesets 提供了快照发布功能snapshot release允许你从任意分支发布带特殊后缀的预发布版本。pnpm changeset version --snapshot pnpm changeset publish --tag beta这个举动有多重要呢在你正式发版之前下游团队可以先用上1.2.0-beta-20240910这种版本进行集成验证发现问题及时反馈而不是等到正式版发布后才发现兼容性问题。值得注意的是快照发布打上的 git tag 会带上快照标识不会污染正式版本号。我维护的组件库目前就采用这个策略效果非常好下游业务方的反馈周期从月缩短到了天。这里有一个快照发布参数速查表格方便你快速了解不同的用法参数组合效果适用场景version --snapshotpublish --tag beta生成1.2.0-beta-commit预发布版本功能分支集成验证version --snapshot --snapshot-prerelease-template自定义快照版本格式如1.2.0-canary自定义预发布命名规则不加--snapshot常规版本更新正式发布流程使用快照发布后用户需要显式安装带betatag 的包才能在测试项目里获取该版本。正式发布时主流程会忽略这些快照版本正常走latesttag。我建议所有长期维护的库都建立快照发布机制它是一个低成本高回报的协作方式。4. 常见问题与排查技巧实录4.1 忘记创建 changeset 文件这是团队落地 Changesets 时最常见的失败模式。代码写完了PR 也提交了就是没执行pnpm changeset导致发版时版本信息缺失。如果项目已经用了 GitHub Actions可以在 CI 中加入一个检查步骤- uses: actions/checkoutv4 with: fetch-depth: 0 - run: pnpm changeset status --since-origin/main其中--since-origin/main表示比较当前分支和 main 分支的差异。如果检测到有改动涉及已发布的包但没有对应的 changeset 文件这个命令会以非零退出码结束CI 就会失败。4.2 一个 PR 里写了多个 changeset 文件有人会问一个 PR 能否创建多个 changeset可以而且并不少见。比如一个 PR 里同时做了一个功能增强和版本重构你希望分开记录那么在执行pnpm changeset时选择不同的包、写不同的说明即可工具会为每次交互生成独立的 markdown 文件。但要注意如果你在同一个 PR 里为同一个包创建了多个 changeset在changeset version时这些 changeset 会被合并为同一次版本变更。比如两个 changeset 都声明patch级别最终只会计算一次版本升级不会叠加出两个版本号。4.3 版本升级后发现变更级别错了有朋友遇到过这个问题发版后发现某个变更声明成了major但实际只是minor这个时候版本号已经写进了 changelog 和 package.json还能改吗能改但要趁早。只要还没执行changeset publish你完全可以直接改 package.json 里的版本号和 CHANGELOG.md 的内容把错误的版本修正后重新提交。如果已经发布了你就只能发布一个新的正确版本比如对版本2.0.0发布2.0.1修复错误声明。这是版本管理里的覆水难收所以执行changeset version时最好停下来确认一下所有变更级别是否符合预期。4.4 依赖声明被更新成了workspace:*怎么办这个坑主要出现在 pnpm 用户中。pnpm 的 workspace 协议允许包之间使用workspace:*作为依赖声明开发和构建时 pnpm 会自动解析到本地 workspace 的包。但发布时npm 要求包里的package.json必须是合法的语义化版本声明不能有workspace:*。Changesets 之所以能和 pnpm 无缝配合是因为它在发布时会把workspace:*替换为对应的具体版本号。但有的时候这个替换没有生效原因通常是 Changesets 版本过旧或者你的package.json里写的是workspace:^1.0.0这样的范围而非精确匹配。我的处理经验是依赖声明统一写workspace:*避免在 workspace 协议里使用精确版本或范围版本。Changesets 在处理workspace:*时最稳妥会自动替换为新版本号而workspace:^1.0.0这类写法在不同版本的 pnpm 和 Changesets 组合下行为不完全一致。4.5 发布顺序的问题先发依赖还是先发被依赖方在 monorepo 中包之间存在依赖关系是很正常的。发布时Changesets 的处理逻辑是先遍历所有需要更新的包找到没有内部依赖或者依赖均已满足的包先行发布再发布依赖了它们的包。这个顺序由工具内部自动计算你通常不需要手动干预。但有一种情况需要注意如果你的发布流程不是用 Changesets 命令而是自己写的脚本那就要确保发布顺序正确。我曾经见过一个项目发布脚本里直接遍历目录逐个npm publish结果某个包先于它依赖的包发布npm 检查时发现依赖版本不存在直接报错中断。4.6 私下发布但没打 tagChangesets 发布时默认会给 git 打 tag。但如果你的 CI 配置出问题发布成功了而 tag 没打上就会导致 git 仓库和 npm 上的版本记录不一致。排查方式是检查 npm 上每个版本的全局标签npm dist-tag ls scope/pkg-a然后对比 git tag 列表。不一致时执行npx changesets tag可以补打 tag。这个命令只做一件事——为当前 package.json 里的版本创建对应的 git tag。4.7 注意 prerelease 模式的使用边界Changesets 提供了对 prerelease预发布版本的原生支持。在.changeset/pre.json文件中声明mode: pre后后续的changeset version会生成1.0.0-beta.0这类版本号而不是常规版本。要退出预发布模式则把mode改为exit再次运行changeset version。这里有一个重要的坑在 pre 模式下创建的 changeset它的版本变更级别会被追加到预发布序列中而不是主版本序列。如果你在 pre 模式中积累了多个 changeset退出 pre 模式后这些 changeset 已经被消耗掉了主版本号不会自动带上它们的变更。因此如果你打算在 pre 模式结束后发一个版本包含全部累积变更记得在退出前确认好 changeset 的累积情况必要时提前合并。4.8 常见的 CI 配置细节最后整理几个 CI 配置中容易忽视的细节fetch-depth: 0changeset 计算版本变化时需要完整的 git 历史浅克隆会导致它看不到某些 commit 的变化。使用 pnpm 时发布前最好跑一次pnpm install --frozen-lockfile避免 lockfile 被意外修改。CI 中执行changeset version时如果出现找到了一个没有对应 changeset 的包这类错误说明该包在 package.json 中声明了版本变化但没有可用的 changeset 来解释变化通常是手动改了版本号导致的。NODE_AUTH_TOKEN在 npm publish 时是必需的且 token 需要有对目标包写入权限。如果你用的是 GitHub Actions 的actions/setup-node记得在with里配置registry-url。5. 从实际项目中总结的几条经验最后说几点我在真实项目中获得的体会这些未必写在官方文档里但对实践很有帮助。第一把 changeset 视作 commit 的一部分。不要把它当成流程负担而是把它当成一条需要随代码一起提交的元数据。当它成为习惯后你会发现版本发布变成了一件非常轻松的事。执行version命令后生成的 changelog 内容质量远高于你发版当天用十分钟赶制出来的说明。第二在团队引入 changeset 时先从小的模块试运行。不必急于要求所有仓库一次性迁移。我在一个大型 monorepo 里推行时把 Changesets 先应用在一个不承担核心发布责任的子包上跑通流程、建立信任之后再逐步扩大到核心包和全量包。流程变更最怕一步到位小步快跑往往更容易落地。第三不要过度依赖自动化。changeset 虽然帮你记录变更、生成版本号但这个改动算不算 breaking change这个问题的答案永远需要人的判断。我在不少项目里见过因为偷懒把major一律写成patch的情况这在维护公共库时是非常危险的习惯——用户会因为没有足够显眼的版本升级提示错过重要的兼容性说明。后续如果你在实践过程中遇到了我这里没提到的问题可以顺着changeset status --verbose的提示信息、changelog 里的上下文以及.changeset目录里的历史记录一点点去排查。版本管理这件事工具能帮你承担的已经越来越多了但最终的判断力始终在自己的团队手里。
返回列表