changesets publish 命令深度解析:发布流程、返回值与 2FA 处理机制
【免费下载链接】changesets🦋 A tool to manage versioning and changelogs with a focus on monorepos项目地址: https://gitcode.com/gh_mirrors/ch/changesets
导读
changeset publish是 changesets 发布流水线的最后一环,负责把changeset version生成的版本变更真正发布到 npm 等 registry,并为发布成功的包创建 git tag。本文以 publish 命令的官方说明 为骨架,结合 命令实现源码 与对应测试,完整讲解{PM}.publish()的四种返回值、基于拓扑分块(topological chunk)的五步发布流程,以及 TTY 环境下 OTP/2FA 的顺序发布与批量发布切换机制。读完本文,你将能理解 changesets 在何种场景下顺序发布、何时切回批量模式、如何处理failed:needs-2fa与failed:already-published,并能在 CI 与非交互环境中正确配置发布。
命令概览:publish 是什么
命令形态与适用时机
changeset publish的经典调用方式为:
changeset publish [--otp={token}]按照 docs/command-line-options.md 的说明,该命令会进入每个包,检查其package.json中的版本是否已发布到 npm;若未发布则执行npm publish。如果项目使用 pnpm,changesets 会自动检测并改用pnpm publish;使用 Yarn Berry 时则调用yarn npm publish。
两条重要的使用约束:
- 假设最后一次提交就是发布提交:
version与publish之间不应再有新的提交,二者拆分为两个命令正是为了让用户在真正发布前有机会检查变更是否正确。 - 发布后需要手动推送 tag:publish 会在本地生成 git tag,但不会自动推送远端,官方推荐发布完成后执行
git push --follow-tags。
命令本身还支持两个常用参数:
--otp={token}:直接提供 npm 一次性密码(one-time password)。未提供时 CLI 会提示输入。--tag TAGNAME:为发布的包指定 npm dist-tag 而非默认的latest,适合测试/验证用途,常与 snapshot 发布配合使用。更多细节可参考 docs/snapshot-releases.md。
发布前的准备
在调用publish之前,需要先经过changeset add(收集变更集)与changeset version(更新版本号与 CHANGELOG)两个阶段,并将版本提交合入主干分支。publish只负责"把已经定好版本的包发出去",它本身不修改任何版本文件。
{PM}.publish()的四种返回值
publish 命令 README 首先定义了发布动作的返回值契约。这里的{PM}是 PackageManager 的抽象——在源码中对应 PublishTool 接口,其publish方法统一返回PublishResult。四种取值分别是:
| 返回值 | 含义 | 附加字段 |
|---|---|---|
published | 发布成功,版本已推送到 registry | 无 |
failed | 通用失败(如 E403 权限拒绝、E404 包不存在等) | code?、message? |
failed:already-published | 该版本已存在于 registry,视为"已发布"而非错误 | code? |
failed:needs-2fa | 需要双因素认证(OTP/交互式登录)才能继续 | authUrl?、doneUrl? |
从类型定义看,PublishResult是一个判别联合(discriminated union),基类固定携带name、version、result三个字段(types.ts)。其中failed:needs-2fa是唯一带结构化恢复信息的失败类型:authUrl与doneUrl由 npm v11 及以上版本在 EOTP 错误中返回,用于描述"在何处完成认证"以及"认证完成后跳转到哪里"(npm.ts)。
三个包管理器对返回值的归一化细节略有不同:
- npm:通过
npm publish --json解析输出,识别EOTP(需要 2FA)、ENEEDAUTH(未认证)以及"cannot publish over the previously published"(已发布)等错误码(npm.ts)。 - pnpm:识别
ERR_PNPM_OTP_NON_INTERACTIVE(非交互式 OTP 缺失)与E403/E404,且 pnpm 10 及以下版本会把 registry 错误委托给 npm 的错误处理逻辑(pnpm.ts)。 - yarn:通过 Yarn Berry 的 reporter 输出流解析错误,
YN0033或消息中包含 otp/authentication 关键字时归类为failed:needs-2fa(yarn.ts)。
export type PublishResult = | PublishResultSuccess // { result: "published" } | PublishResultFailed // { result: "failed", code?, message? } | PublishResultAlreadyPublished // { result: "failed:already-published", code? } | PublishResultFailedNeeds2fa // { result: "failed:needs-2fa", code?, message?, authUrl?, doneUrl? }这段类型声明(lib/types.ts)是整个发布状态机的数据基础:主流程对每个结果做isPublishSuccessful/isPublishFailure判断(lib/common.ts),isPublishFailure的实现是result.result.startsWith("failed"),因此后三种"失败"值都会被纳入统一的失败统计。
Publish Flow:五步发布流程
README 将发布流程归纳为五个步骤,源码中的publish函数(index.ts)逐条落实了这五步。先看整体骨架:
export async function publish(options?: PublishOptions) { // 1. 解析参数、读取包信息与配置 const packages = await getPackages(cwd); const packagesByName = new Map(packages.packages.map((pkg) => [pkg.packageJson.name, pkg])); const publishTool = await getPublishTool(packages); // 按包管理器选择 npm/pnpm/yarn const config = await readConfig(packages); // 2. 生成发布计划(按依赖图拓扑分块) const plan = artifactDir ? await readPlanFile(path.join(artifactDir, "publish-plan.json")) : await getPublishPlan(packages.rootDir, config, { tag: releaseTag }); // 3. 逐 chunk 发布:顺序发布 + 批量发布 + 2FA 恢复 publishChunks: for (const chunk of plan) { // ... sequential / bulk 双模式 } // 4. 汇总结果、输出报告、创建 git tag if (successfulNpmPublishes.length !== 0) { /* 成功列表 */ } if (unsuccessfulNpmPublishes.length !== 0) { log.error(/* 失败列表 */); throw new ExitError(1); } }下面按 README 的五步逐一展开。
第一步:按拓扑分块(chunk)处理发布计划
发布计划由 getPublishPlan.ts 生成,本质是一个二维数组:外层是发布顺序,内层是同一批次(chunk)内可以并发的包。
export type PublishPlan = ReadonlyArray< ReadonlyArray<PublishReleaseEntry | TagReleaseEntry> >;分块算法(sortReleases,见 getPublishPlan.ts)基于依赖图(getDependentsGraph)构建发布图,再用graphSequencer求出拓扑序:
- 依赖者(dependent)必须在被依赖者之后发布,例如
pkg-a依赖pkg-b,则pkg-b所在的 chunk 先于pkg-a所在 chunk。 - 图中存在环时不会直接报错,而是打印循环依赖警告后继续(
getPublishPlan.ts第 262-268 行)。 - 计划中的条目分为两类:
publish(真正要发到 registry 的包)与tag-only(仅打 git tag 的私有包,由config.privatePackages.tag开启后纳入计划)。
测试 publishes release chunks sequentially 验证了这一点:pkg-a依赖pkg-b时,发布调用顺序必须是pkg-b先、pkg-a后,且打 tag 的顺序与之对应。
注意:发布计划基于注册表查询结果(
npm info/pnpm info/yarn npm info)计算哪些包尚未发布。源码注释明确指出,由于 registry 的具体解析被委托给各包管理器 CLI,计划本身不记录 registry 地址,因此publish-plan与publish两阶段依赖配置的一致性(getPublishPlan.ts)。
第二步:TTY 下先顺序发布,直到一次非交互发布成功
这一步的关键判断在 index.ts:
let otpCode = publishTool.getOtpCode(options?.otp); // in TTY mode the first publish "checks" if the publish process requires interactive auth or not // on CI everything has to be configured in a way that allows automation so we can go straight to bulk publishing // similarly, when OTP is provided we can go straight to bulk publishing as well let sequential = process.stdin.isTTY && otpCode == null;即只有同时满足"标准输入是 TTY"且"没有预先提供 OTP"两个条件时,才会进入顺序发布模式。这是整个发布策略的出发点:
- 在 TTY 下,第一个包会以**非交互(
--json)**方式尝试发布,以此"探测"当前环境是否需要交互式认证。 - 若探测成功(
published),说明当前会话具备自动化发布能力,立即切换到批量模式(见第三步)。 - 若探测返回
failed:needs-2fa,则进入交互式重试:丢弃可能已失效的 OTP,用stdio: "inherit"重新发布,把终端交还给用户完成 2FA 认证。
关于 OTP 的生命周期,源码有两处关键规则(对应 README 第 2 步的注释):
- 已提供的 OTP 在其有效期内会被复用:
otpCode会透传给后续每次发布调用(--otp参数),因此一次认证可覆盖多个包的发布。 - 交互式重试前必须丢弃 OTP:见 index.ts——进入
while (result.result === "failed:needs-2fa")循环后首先执行otpCode = null,避免把已拒绝的 OTP 传给后续发布。
源码中还贴心地做了交互提示:当总发布数 ≥ 2 且触发 2FA 时,会建议用户勾选 npm 的"skip 2fa for 5 minutes"选项,避免每个包都重复认证(index.ts)。
此外,README 第 2 步还规定了两种"继续顺序发布"的情形:
- 交互式发布成功:说明当前会话只能通过交互方式认证,继续逐包顺序发布(每包都会占用终端)。
failed:already-published:该版本已存在,既不算成功也不算硬失败,跳过该包继续下一个。
第三步:批量发布当前 chunk 的其余包
当顺序探测成功(或提供了 OTP、或非 TTY)后,sequential置为false,流程进入批量发布分支bulkPublishPackages(index.ts):
const publishPromises = publishQueue.map(async (item) => { const pkg = packagesByName.get(item.release.name)!; const result = await npmPublishQueue.add(() => publishTool.publish({ pkg, release: item.release, tarballPath: artifactDir ? resolve(artifactDir, item.release.tarball!.path) : null, interactive: false, // 批量发布一律非交互 otpCode, // 复用探测阶段证明有效的 OTP }), ); onResult?.(result); return { release: item.release, result }; }); return Promise.all(publishPromises);批量发布的要点:
- 同一 chunk 内的包并发发布,但受
npmPublishQueue限制。该队列的并发上限定义在 lib/common.ts:NPM_PUBLISH_CONCURRENCY_LIMIT = 10,即发布操作最多同时进行 10 个;而npm info之类的 registry 查询走另一个队列NPM_REQUEST_CONCURRENCY_LIMIT = 40。这样既保证吞吐,又避免瞬间打爆 registry。 - 成功与已发布的包直接收尾:
published计入successfulNpmPublishes,failed:already-published不再处理。 - 通用失败会阻断后续 chunk:批量结果中若出现
failed(硬失败),会把失败包计入unsuccessfulNpmPublishes并break publishChunks,整个发布流程立即终止。测试 stops publishing after a failed chunk 验证了这一点——一个 chunk 失败后,后续 chunk 不再执行。 - 2FA 失败的恢复条件:只有当同一批中没有硬失败时,
failed:needs-2fa的包才会被重新放回队列、切回顺序模式逐个交互恢复(index.ts)。混合出现通用失败与 2FA 失败时,全部按失败上报并停止发布——源码注释解释了这个设计:与硬失败混合时再做恢复会使流程复杂化,因此只允许"全部失败都可恢复"时恢复。对应测试见 does not recover 2FA failures when the same bulk publish has a hard failure。
恢复队列还有一个细节:重新进入顺序模式后,otpCode会被置为null(index.ts),因为批量发布已证明当前 OTP 缺失或失效。测试 returns to sequential publishing when an OTP becomes invalid during bulk publishing 完整覆盖了这条链路:前 5 次调用携带--otp expired,第 6 次(交互恢复)不带 OTP 且stdio: inherit,之后的批量调用不再带 OTP。
第四步:非 TTY(CI)下直接进入批量模式
README 第 4 步说明:在非 TTY 环境中,直接从批量模式开始,并将 2FA 失败当作普通失败上报。这对应sequential初始值计算中的process.stdin.isTTY判断——CI 中 stdin 不是 TTY,也没有人工交互的可能,因此:
- 所有包一次性进入
bulkPublishPackages并发发布。 failed:needs-2fa无法触发交互恢复,按不可恢复失败处理,与硬失败一样终止发布。- 即便 chunk 中已有失败,CI 模式下仍会尝试该 chunk 内的所有包(而不是像 TTY 顺序模式那样遇硬失败立即中断),测试 attempts every package in a failing non-TTY chunk 验证了该行为。
这解释了为什么 CI 上的变化集发布通常要求配置好 registry token 或 OTP 环境变量——一切认证都必须预先配置为可自动化。
第五步:汇总结果并创建 git tag
发布循环结束后,进入收尾阶段(index.ts):
输出成功列表:若
successfulNpmPublishes非空,按包名排序展示name@version,进度条停止。输出失败列表:若
unsuccessfulNpmPublishes非空,用红色展示每个失败包的code与message(如E403: failed)。创建 git tag:为两类条目打 tag——
- 发布成功的包(
pkg-a@1.0.0形式); - 计划中的
tag-only条目(私有包)。
关键点在于:即使是发生失败、中断发布的 chunk,其中已成功的发布和 tag-only 条目也会被打 tag(README 第 5 步最后一句)。测试 tags tag-only releases from a failing chunk 验证了这一点——
pkg-a发布失败后,私有包pkg-b@1.0.0的 tag 依然被创建。但失败 chunk 之后(依赖它的)tag-only 条目不会被标记(测试 does not tag tag-only releases after a failing chunk)。- 发布成功的包(
失败即退出非零:存在任何失败时
throw new ExitError(1),供 CI 捕获。
git tag 的创建由 git-tag/utils.ts 的createGitTags完成,已存在的 tag 会被跳过(测试 renders existing tags for successful publishes)。tag 事件也可以通过--output以 NDJSON 流式写出,每行形如{"type":"git-tag","tag":"pkg-a@1.0.0","packageName":"pkg-a"}。
PublishOptions 完整参数表
publish函数接受的选项定义在 index.ts:
| 参数 | 类型 | 默认值 | 作用 |
|---|---|---|---|
cwd | string | process.cwd() | 仓库根目录,所有包发现与配置读取的基准 |
otp | string | 无 | npm 一次性密码,优先级低于环境变量读取逻辑(getOtpCode会依次检查参数、NPM_CONFIG_OTP、npm_config_otp) |
tag | string | latest | 发布使用的 npm dist-tag |
fromPackDir | string | 无 | 从changeset pack生成的产物目录发布(artifact 模式) |
output | string | 无 | 将 git-tag 等事件以 NDJSON 写入指定文件 |
gitTag | boolean | true | 是否创建 git tag,可关闭 |
参数间的相互约束
源码中有两处显式校验:
- artifact 模式与自定义 tag 互斥(index.ts):
fromPackDir与tag同时提供时报错"Releasing under custom tag is not allowed in artifact mode."。 - pre 模式与自定义 tag 互斥(index.ts):处于 pre-release 模式时指定
tag会报错,提示先执行changeset pre exit。对应测试见 in pre state should report error if the tag option is used in pre release。
另外,在 pre 模式或自定义 tag 场景下,publish 会打印非 latest tag 警告(showNonLatestTagWarning):pre 模式中,从未正式发布过的包会发布到latest,其余发布到preState.tag(index.ts)。这一"only-pre"判定逻辑位于 getPublishPlan.ts:当已发布版本全部是 pre tag 且dist-tags.latest存在时,判定为only-pre,首次正式发布会落到latest而非 pre tag。
发布计划生成:谁进入计划,谁被跳过
为了让第五步的"分块"与"tag-only"概念落地,有必要看一下发布计划的来源。getPublishPlan的完整流程(getPublishPlan.ts)是:
- 发现未发布包(
getUnpublishedPackages):遍历所有非私有包,通过npm info <name> --json(无 latest dist-tag 时回退查询精确版本)判断本地版本是否已在 registry。本地版本不在已发布版本列表中的包进入发布列表,同时打印"These packages will be published"预览与已发布计数。 - 收集 tag-only 私有包(
getUntaggedPrivatePackages):当配置privatePackages.tag: true时,将未被 git tag 覆盖的私有包作为tag-only条目加入计划。 - 过滤规则:
config.ignore中列出的包(shouldSkipPackage)不进入计划;私有包(private: true)默认不发布。测试 does not tag ignored private packages 验证了 ignore 生效时连 tag 查询都不会执行。 - 拓扑排序分块:合并两类条目后交给
sortReleases生成最终计划。
如果计划为空(没有任何未发布包也没有待打 tag 的私有包),publish 会打印No unpublished projects to publish.并直接返回(index.ts)。
关键实现细节与底层原理
为什么 cwd 如此重要
npm 适配层在发布前解析.npmrc时对工作目录有明确讲究(npm.ts):npm 9 之前调用 npm 必须从仓库根目录并以嵌套包为目标,才能正确解析根.npmrc;npm 9 之后.npmrc查找已具备 workspace 感知能力,因此现在直接从包目录本身调用 npm publish。同时 npm 会从环境变量中剥离NPM_CONFIG_OTP/npm_config_otp(sanitizeEnv),防止遗留 OTP 污染发布(npm.ts),pnpm 适配层还会额外剥离PNPM_CONFIG_OTP(pnpm.ts)。
publishConfig 支持
publishConfig.registry:npm 侧会为 scoped 包生成--@scope:registry=与--registry=覆盖参数,保证npm info查询走与发布一致的 registry(npm.ts)。publishConfig.directory:npm 原生不支持该字段,changesets 继承自 Lerna 的做法是手动解析——将目标目录作为位置参数传给npm publish;但使用它时必须预先构建好该目录,因为 changesets 只是委托包管理器发布,不会重新实现 pack+publish(npm.ts)。pnpm 原生支持该字段(pnpm.ts);Yarn 则明确不支持,遇到时直接返回失败(yarn.ts)。
2FA 的进程内处理预留
源码中有一段被注释掉的逻辑(index.ts):当 npm v11 返回authUrl/doneUrl时,理论上可以在进程内完成 2FA 引导(TODO 标注为后续 PR 实现),目前实际走的是"以stdio: inherit重新发布、把终端交给用户"的交互式回退路径。
进度条与输出
发布过程使用@clack/prompts的progress组件显示Publishing packages (N/M)进度,2FA 交互前会暂停进度条,恢复后重新启动(index.ts)。最终结果通过formatPackageList按包名排序展示,失败条目附上code与message。
测试验证矩阵
publish 命令测试(共 742 行)覆盖了本文涉及的核心行为,可作为理解发布状态机的对照:
| 测试用例 | 验证点 |
|---|---|
| publishes release chunks sequentially | 依赖顺序:pkg-b先于pkg-a发布并打 tag |
| stops publishing after a failed chunk | 通用失败立即终止,后续 chunk 不再发布 |
| attempts every package in a failing non-TTY chunk | 非 TTY 下 chunk 内全部尝试,失败照常上报 |
| does not recover 2FA failures when the same bulk publish has a hard failure | 混合失败不恢复,直接停止 |
| returns to sequential publishing when an OTP becomes invalid during bulk publishing | OTP 失效 → 顺序交互恢复 → 恢复批量,且不再复用旧 OTP |
| tags tag-only releases from a failing chunk | 失败 chunk 中已成功/私有包仍打 tag |
| does not tag tag-only releases after a failing chunk | 失败 chunk 之后的 tag-only 条目不打 tag |
| does not tag ignored private packages | ignore 配置生效时连 tag 都不打 |
| rejects custom tags when publishing from a pack directory | artifact 模式与自定义 tag 互斥 |
总结与实战要点
把 README 的发布流程与源码对照后,可以提炼出以下可直接用于实践的要点:
- TTY 本地发布:未提供 OTP 时,第一个包先非交互探测,成功后自动切批量(并发 ≤ 10);遇到 2FA 则交互认证,认证通过后继续批量。
- CI 发布:务必预先配置 registry token / OTP(环境变量),2FA 失败会被当作普通失败上报并终止;包管理器可被自动检测(npm/pnpm/yarn,Yarn Classic 不支持)。
- 失败语义:
failed:already-published不是错误,是"幂等已达成";只有failed才是硬失败;failed:needs-2fa在 TTY 下可恢复。 - tag 语义:git tag 为每个成功发布包与 tag-only 私有包创建,失败 chunk 内的已完成条目仍会打 tag;发布后记得
git push --follow-tags。 - 约束记住三条:
version与publish之间不要提交;pre 模式与 artifact 模式下禁止自定义 tag;publishConfig.directory需要预先构建产物。
如需进一步了解发布在多包仓库中的注意事项、pre-release 与 snapshot 模式,可继续阅读仓库中的 problems-publishing-in-monorepos.md、prereleases.md 与 snapshot-releases.md。
【免费下载链接】changesets🦋 A tool to manage versioning and changelogs with a focus on monorepos项目地址: https://gitcode.com/gh_mirrors/ch/changesets
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考