pnpm 版本切换拒绝损坏版本:从packageManager钉住到ERR_PNPM_BROKEN_PNPM_RELEASE的完整机制
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
本指南以 pnpm 仓库的变更记录 refuse-broken-release-on-version-switch.md 为核心,结合 TypeScript 版(pnpm11)与 Rust 版(pnpm/crates)的源码、错误定义与测试用例,系统讲解:当项目通过
packageManager或devEngines.packageManager将 pnpm 钉在一个损坏的版本上时,新版 pnpm 如何在版本切换环节提前拒绝、如何给出可执行的修复指引,以及为什么"已在运行的损坏版本"不会被拒绝。读完你将掌握该错误码的含义、触发条件、hint 文案背后的设计取舍,并能依据源码定位到对应的检查函数与测试。
一、这条变更记录讲的是什么
关联文档(一个 changeset 文件)的原文非常凝练:
A project pinned to a broken pnpm release via
packageManagerordevEngines.packageManagernow reports which release is broken and what to do about it, instead of failing inside the installer.pnpm self-updatealready refused these releases; the version switch does too.
拆解这句话,可以提炼出三个核心事实:
- 触发入口有两个:项目的
packageManager字段(package.json中的packageManager: "pnpm@x.y.z")与devEngines.packageManager(devEngines字段中的包管理器钉住声明); - 行为变化:在此之前,切到损坏版本会"在安装器内部失败"(failing inside the installer)——即已经下载、解压、安装到一半才报错,用户只看到一个底层安装错误;现在则在切换动作发生之前就明确报告"哪个版本损坏、该怎么办";
- 对称性:
pnpm self-update早已拒绝这些版本(拒绝逻辑由 installPnpm.ts 中的assertReleaseIsInstallable提供),本变更把同一道防线延伸到了版本切换(version switch)路径。
从源码看,这一变更同时在两条代码线上落地:TypeScript 版 CLI 的 switchCliVersion.ts 与 Rust 版 CLI 的 execute.rs,两侧共享完全一致的错误码与提示语义。
二、什么是"损坏版本":BROKEN_RELEASES的判定依据
"损坏"不是一个形容词,而是一份明确的版本黑名单。在 TS 版中,定义位于 installPnpm.ts:
/** * Versions whose `@pnpm/exe` shipped platform packages with no binary, so it * cannot run. Keyed by version, not package: the pin is shared but the wrapper * is not, so a developer on the JS `pnpm` — which does run at these versions — * would otherwise pin one and break every teammate on `@pnpm/exe`. */ const BROKEN_RELEASES: ReadonlySet<string> = new Set(['11.12.0', '11.13.0']) /** * Whether `version` can be installed at all — false for the * {@link BROKEN_RELEASES}. For callers that pick a version rather than being * handed one, and so can choose another instead of failing. */ export function isReleaseInstallable (version: string): boolean { return !BROKEN_RELEASES.has(version) }Rust 版的对应实现位于 install_pnpm.rs,用matches!表达同一份名单:
pub(crate) fn is_release_installable(version: &str) -> bool { !matches!(version, "11.12.0" | "11.13.0") }2.1 判定以"版本"为单位,而非"包名"
注释中的一句值得展开:"the pin is shared but the wrapper is not"(钉住是共享的,但包装器不是)。
@pnpm/exe是携带平台原生二进制的 pnpm 分发形式(SEA 单文件可执行),而pnpm(JS 版)是另一套包装器。在11.12.0、11.13.0这两个版本上,@pnpm/exe发布的平台包没有附带可执行二进制,因此通过@pnpm/exe安装后无法运行。
关键设计在于:黑名单以版本号为准,而不是以包名为准。因为packageManager/devEngines.packageManager的钉住是项目级共享的——一个项目钉住pnpm@11.12.0,团队里使用 JS 版pnpm的成员可能恰好能跑起来,但使用@pnpm/exe的成员会在安装后无法运行。如果黑名单只拦@pnpm/exe这一个包名,就会产生"同项目、同 pin、不同结局"的割裂体验。按版本拦截,则无论走哪个包装器,都会被统一拒绝。
2.2 两个入口的语义差异
packageManager: "pnpm@11.12.0":直接声明"本项目使用 pnpm 的哪个版本";devEngines.packageManager: { name: "pnpm", version: "11.12.0", onFail: "download" }:由devEngines协议描述包管理器约束,onFail: "download"表示不满足时自动下载目标版本(见 switchCliVersion.test.ts 中测试构造的wantedPackageManager)。
两条入口最终都会汇入同一个"期望版本"(wanted version)解析流程,因此本变更同时覆盖了它们。
三、拒绝时的错误报告:错误码、文案与 hint
拒绝不是抛出含糊的内部错误,而是带PnpmError错误码与可操作 hint 的明确诊断。TS 版核心函数在 installPnpm.ts:
/** Throws when `version` is one of the {@link BROKEN_RELEASES}. */ export function assertReleaseIsInstallable (version: string): void { if (isReleaseInstallable(version)) return throw new PnpmError( 'BROKEN_PNPM_RELEASE', `pnpm v${version} is a broken release and cannot be installed`, { hint: 'Its "@pnpm/exe" build shipped without a binary and does not run. Even where it does run, pinning it would break everyone on the project who uses "@pnpm/exe", because the pin is shared. Choose another version, or run "pnpm self-update latest".', } ) }Rust 版的错误定义在 self_update.rs:
#[display("pnpm v{version} is a broken release and cannot be installed")] #[diagnostic( code(ERR_PNPM_BROKEN_PNPM_RELEASE), help( r#"Its "@pnpm/exe" build shipped without a binary and does not run. Even where it does run, pinning it would break everyone on the project who uses "@pnpm/exe", because the pin is shared. Choose another version, or run "pnpm self-update latest"."# ) )] BrokenPnpmRelease { version: String },3.1 错误信息的三层结构
| 组成 | 内容 | 作用 |
|---|---|---|
| 错误码 | ERR_PNPM_BROKEN_PNPM_RELEASE(TS 侧PnpmError的 code,Rust 侧diagnostic的 code) | 供 CI、脚本与工具链稳定匹配,不必解析文案 |
| 主消息 | pnpm v11.12.0 is a broken release and cannot be installed | 明确指出哪一个版本损坏,满足 changeset 中 "reports which release is broken" 的要求 |
| hint | @pnpm/exe构建缺少二进制、共享 pin 会波及队友、可选操作 | 满足 "what to do about it",给出两条出路 |
hint 中给出的两条出路,正好对应两个修复方向:
- 换一个可用的版本:重新钉住项目中的
packageManager/devEngines.packageManager到非黑名单版本(如11.13.1、12.0.0等); pnpm self-update latest:把你本机的 pnpm 更新到最新发布版本,摆脱损坏版本。
注意ERR_PNPM_BROKEN_PNPM_RELEASE与 self_update.rs 中的ERR_PNPM_BROKEN_PNPM_INSTALL是两个不同的错误码:后者描述"安装完成后发现装好的版本无法运行"(安装器内部失败的后置防线),前者描述"安装之前就判定该版本不可安装"。本变更让版本切换路径不再走到后者那种"装完才发现"的局面。
四、版本切换中的检查位置:switchCliVersion的调用链
拒绝动作发生在版本切换流程的哪个节点,决定了它能否真正避免"在安装器内部失败"。看 switchCliVersion.ts 的完整流程:
解析期望版本 wantedVersion(来自 packageManager / devEngines) │ ▼ 读取 env lockfile(pnpm-lock.yaml 中已记录的解析结果) │ ▼ 需要时从 registry 解析确切的版本号(resolvePackageManagerIntegrities) │ ▼ ★ 若解析出的版本 === 当前正在运行的版本 → 直接返回(不拒绝!) │ ▼ ★ assertReleaseIsInstallable(pmVersion) ← 本变更的核心检查点 │ ▼ 通过 → assertPackageManagerLockfileUsesRegistryResolutions(校验 lockfile 解析形态) │ ▼ installPnpmToStore → 用 spawn.sync 启动新版本的 pnpm,转发全部参数对应代码位于 switchCliVersion.ts:
// If the wanted version matches the current version, no switch needed. // Skip install-to-store entirely — we're already running this version. if (pmVersion === packageManager.version) { await storeToUse?.ctrl.close() return } // Deliberately after the check above: switching to a broken release is // refused, but running one already installed is not. Someone whose pnpm is a // broken release still needs it to work well enough to move off it. try { assertReleaseIsInstallable(pmVersion) } catch (err: unknown) { await storeToUse?.ctrl.close() throw err }4.1 检查点的精确位置
可以看到,检查被刻意放在了**"与当前版本相同则提前返回"之后**、"安装到 store / spawn 子进程"之前。这个顺序是有意为之的:
- 位于安装之前 → 损坏版本永远不会被下载和安装,不会出现"装到一半炸掉"或"装完才发现跑不了"的中间态;
- 位于"相同版本提前返回"之后 → 见下一节的设计取舍。
Rust 版在 execute.rs 的install_switch_target中同样在进入安装前调用assert_release_is_installable(&version)?,且先判断version == PNPM_VERSION再检查,与 TS 版顺序完全一致。
4.2 为什么"正在运行的损坏版本"不拒绝
这是本变更最容易引起疑问的设计点,注释解释得很清楚:
Someone whose pnpm is a broken release still needs it to work well enough to move off it.
如果某位开发者的 pnpm已经是11.12.0这个损坏版本(例如在版本发布当天通过其他途径装上,或团队此前已钉住),而项目又恰好钉住这个版本——此时版本切换的目标版本就等于正在运行的版本,switchCliVersion会直接返回而不触发检查。原因:
- 这台机器上的 pnpm确实能跑(否则用户根本无法执行命令),它处于"能运行"的状态,虽然属于损坏发布的分发缺陷,但当前实例可用;
- 如果在这里拒绝,用户将陷入死锁:项目钉住损坏版本 → 版本切换拒绝 → 用户永远无法用 pnpm 执行任何命令来修改
package.json或升级自己。
也就是说,切换(需要下载安装新副本)时拒绝损坏版本,保持现状(已安装且正在运行)时不拒绝。这是"fail loudly"与"don't strand the user"之间的平衡。
对应的测试用例精确锁定了这一语义,见 switchCliVersion.test.ts:
// The refusal must not strand anyone: a developer whose pnpm *is* a broken // release still needs it to run, or they have nothing to move off it with. test('does not refuse a broken release that is already the running version', async () => { mockPackageManager.version = '11.12.0' // ... wantedPackageManager 钉住 11.12.0 ... await expect(switchCliVersion(config, context)).resolves.toBeUndefined() expect(installPnpmToStore).not.toHaveBeenCalled() })五、测试如何锁住这个行为
仓库为这条变更配备了双端(TS 与 Rust)测试,可以从测试断言反推行为规格。
5.1 TS 版:拒绝、放行与不困住用户
switchCliVersion.test.ts 的核心用例:
test('refuses to switch to a broken release instead of failing inside the installer', async () => { const context = { rootProjectManifestDir: '/project', wantedPackageManager: { name: 'pnpm', version: '11.12.0', fromDevEngines: true, onFail: 'download' }, } as unknown as ConfigContext readEnvLockfile.mockResolvedValue(envLockfileFor('11.12.0')) const exit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never) try { await expect(switchCliVersion(config, context)).rejects.toThrow(/11\.12\.0 is a broken release/) } finally { exit.mockRestore() } expect(installPnpmToStore).not.toHaveBeenCalled() })注意两个断言点:
rejects.toThrow(/11\.12\.0 is a broken release/)—— 断言拒绝且明确点名了损坏版本号,这正是 changeset 中 "reports which release is broken" 的测试落点;expect(installPnpmToStore).not.toHaveBeenCalled()—— 断言安装器从未被调用,验证"不是失败在安装器内部,而是在安装之前就拒绝"。
配套用例还覆盖了反向行为(switchCliVersion.test.ts):切换到非损坏版本11.13.1时正常走完installPnpmToStore与spawnSync全流程,证明该检查不会误伤正常版本。
5.2selfUpdate侧与错误码的专项测试
版本切换复用的assertReleaseIsInstallable本身,在 self-update 的测试中也有完整覆盖(selfUpdate.test.ts):
test.each(['11.12.0', '11.13.0'])逐一验证黑名单版本全部抛出/pnpm v.+ is a broken release and cannot be installed/;- 专门断言错误码为
ERR_PNPM_BROKEN_PNPM_RELEASE且 hint 包含 "pin is shared"(共享钉住的团队影响语义); - 反向用例验证
11.11.0、11.13.1、12.0.0等版本允许安装。
5.3 Rust 版测试
pnpm/crates/cli/src/cli_args/self_update/install_pnpm/tests.rs 中:
fn assert_release_is_installable_refuses_the_broken_releases() { // ... let err = assert_release_is_installable(version).unwrap_err(); assert!(err.to_string().contains("broken release"), "{err}"); }以及assert_release_is_installable_allows_every_other_release(允许其余所有版本),与 TS 版测试构成一一对应的双端规格。
六、同族防线:pnpm init与self-update中的一致策略
值得说明的是,这一"拒绝损坏版本"的策略并非孤例,而是贯穿 pnpm 的多个"选版本"入口。理解这些同族防线,有助于把本变更放进整体设计语境:
| 入口 | 文件 | 策略 |
|---|---|---|
| 版本切换(本变更) | switchCliVersion.ts / execute.rs | 切换到黑名单版本 → 拒绝 |
| self-update | self_update.rs | 更新到黑名单版本 → 拒绝(早于本变更已有) |
pnpm init钉住最新版 | init.ts | 解析latest得到黑名单版本时,不把它写进新项目的packageManager钉住,回退到当前版本 |
init.ts 中的注释与本文主题一脉相承:
A broken release is refused for the reason the pin exists at all: it is shared, so pinning one the running wrapper happens to survive would still break every teammate on the other wrapper.
即:钉住是共享的(shared pin)这一事实,是"拒绝损坏版本"策略跨所有入口统一的根本原因——你个人也许侥幸能跑,但你的整个团队都会因为同一个packageManager声明而受损。
七、实战:遇到ERR_PNPM_BROKEN_PNPM_RELEASE该怎么办
当你在项目根目录执行任意 pnpm 命令,报错形如:
ERR_PNPM_BROKEN_PNPM_RELEASE pnpm v11.12.0 is a broken release and cannot be installed Its "@pnpm/exe" build shipped without a binary and does not run. Even where it does run, pinning it would break everyone on the project who uses "@pnpm/exe", because the pin is shared. Choose another version, or run "pnpm self-update latest".按如下顺序处置:
- 查看钉住声明:检查根目录
package.json中的packageManager字段(如"packageManager": "pnpm@11.12.0")以及devEngines.packageManager块; - 升级钉住的版本:将版本改为黑名单之外的可用版本(测试确认的可用版本包括
11.11.0、11.13.1、12.0.0),例如改为"packageManager": "pnpm@11.13.1"后重新运行命令触发切换; - 或升级本机 pnpm:执行
pnpm self-update latest,将本机 CLI 提升到最新发布版(该命令自带拒绝降级与拒绝损坏版本的策略,参见 SelfUpdateArgs 中 "Defaults to thelatestdist-tag (which refuses to downgrade)" 的说明); - 如果你当前运行的恰好就是损坏版本:不会触发该错误(见 4.2 节),请直接执行
pnpm self-update latest离开它。
八、小结
本变更以极小的代码面(一份黑名单 + 一个断言函数)完成了行为契约的升级:版本切换路径从"装到一半失败"变为"装之前就拒绝并给出指引"。其设计要点可归纳为三条:
- 提前失败(fail early):检查点位于安装之前,损坏版本永远不会进入下载与安装流程,这是对"instead of failing inside the installer"的直接实现;
- 按版本而非按包名判定:因为
packageManager钉住是项目级共享的,@pnpm/exe与 JSpnpm两个包装器必须行为一致; - 拒绝不困住用户:正在运行的损坏版本不受影响,保证用户始终有工具去完成自我修复。
该行为由 TS(switchCliVersion.test.ts / selfUpdate.test.ts)与 Rust(tests.rs)两侧测试共同锁定,错误码ERR_PNPM_BROKEN_PNPM_RELEASE在 installPnpm.ts 与 self_update.rs 中定义一致——无论你使用的是 TS 版还是 Rust 版 pnpm,行为契约完全相同。
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考