pnpm --frozen-lockfile 失败 fallback install 的时间漂移雪崩

某客户机 Win Server 上 BPM 流程表单点保存炸,根因不在表单库本身,在构建链路允许 frozen-lockfile 失败时静默回退到无锁 pnpm install。通用,不限于具体库。

故障表现

客户机装出来三个跨包套件跨 minor 错配,BPM 表单点保存控制台两条 Uncaught (in promise) 异常,无 POST 请求。

另一台 Linux 构建机上同 fork 同 master 同 pnpm 8.15.9 装出来是上一周的版本组合,实测保存正常。

同样 pnpm 版本装出不同精确版本——单看 pnpm 没问题,问题在输入。

根因链(4 环)

1. package.json 顶层 ^x.y.z 范围依赖跨包套件

"@scope/designer": "^3.2.6",
"@scope/element-ui": "^3.2.11"

^ 是 npm 生态默认,本身不是 bug——约定是”package.json 写范围 + lock 锁精确”。

2. 上游 fork 带来的 lock 是 pnpm 9.x 写的(lockfileVersion 9.0)

上游开发者机器用什么 pnpm,push 出来的 lock 就什么版本。本身不是 bug——上游有上游的环境约定。

3. 构建机 pnpm 钉死在 8.15.9(lockfileVersion 6.0)

win-fetch-runtime.ps1 硬编码下载 pnpm-win-x64.exe v8.15.9。pnpm 8.x 看不懂 lockfileVersion 9.0 → --frozen-lockfile 必然失败。

这一环开始失稳——但还能救:让构建在这里停下来报错就行。

4. win-build.ps1 静默 fallback 到 pnpm install(关键雷管)

Invoke-Native { & $PNPM_EXE install --frozen-lockfile }
if ($LASTEXITCODE -ne 0) {
    Info 'frozen-lockfile 失败,回退到普通 install'   # ← 这就是雷管
    Invoke-Native { & $PNPM_EXE install }
    if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败" }
}

失败回退到无 lock 约束的 install,pnpm 联网问 npm registry”当下满足 ^3.2.6 的最新是啥”——时间敏感

时间点npm registry 当下回答
两周前(首装)designer 3.4.0、element-ui 3.2.41、wangeditor 3.2.14
本周(再装)designer 3.5.0、element-ui 3.3.1、wangeditor 3.3.0

上游作者在两个日期之间发了一波升级。同一台机器同一个 pnpm 同一个 package.json,装出不同精确版本——时间漂移就这么进来的。

首装那次意外没炸:designer 3.4.0 在自己 package.json 内部把 element-ui 钉死成 3.2.41(精确,不是范围),顶层 ^3.2.11 也解析到 3.2.41——意外达成一致,没出双版本。

再装那次炸:designer 3.5.0 内部钉 element-ui 3.3.1,顶层 ^3.2.11 独立解析可能解到 3.2.x 最新(不是 3.3.1)——顶层有一份 3.2.x、designer 拿一份 3.3.1,同名两版本,运行时撞结构差异抛错。

关键证据(诊断现场用)

  • node_modules/.modules.yaml 顶部记录 packageManager: pnpm@8.15.9 + 文件写入时间 → 反推哪次 install 干的、用什么 pnpm
  • 工作区 pnpm-lock.yaml 头一行 lockfileVersion: '6.0' vs git HEAD 的 '9.0' → 工作区 lock 是 install 重写的(否则不会变)
  • 产物里 grep -arhoE "@scope/[a-z-]+ v[0-9]+\.[0-9]+\.[0-9]+" 可以从 bundle banner 抓到精确版本

修法(4 层防线,缺一不可)

文件 / 字段作用
1. pnpm 版本绑定package.json packageManager: "pnpm@8.15.9+sha512..."corepack 强制拉对应 pnpm,lock 永远跟 pnpm 兼容
2. lock 锁精确依赖pnpm-lock.yaml 与 packageManager 同 major保证 install 锁定精确版本
3. frozen 失败 fail-fast构建脚本(win-build.ps1lock 不可用时直接 exit,不允许静默 fallback
4. 跨包套件顶层精确化package.json 去掉跨包套件的 ^多一层防御,即使 1-3 都失守,套件 minor 不会独立漂

实操片段

{
  "packageManager": "pnpm@8.15.9+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81"
}

sha512 后缀让 corepack 验证完整性。corepack enable 环境(Node 16.13+ 默认带)读这字段会自动下载并切换到指定 pnpm。

构建脚本 fail-fast 模板(PowerShell):

Invoke-Native { & $PNPM_EXE install --frozen-lockfile }
if ($LASTEXITCODE -ne 0) {
    $pnpmVer = Invoke-Native { (& $PNPM_EXE -v 2>&1 | Out-String).Trim() }
    $lockVer = (Get-Content "$FrontendRepo\pnpm-lock.yaml" -TotalCount 1).Trim()
    Die @"
frozen-lockfile 失败. pnpm=$pnpmVer lock=$lockVer
拒绝 fallback 到无锁 install. 排查:
1. lockfileVersion 与 pnpm 大版本不匹配 -> 改 packageManager 字段拉对应 pnpm
2. lock 没重新生成 -> 联网开发机跑 pnpm install 重生成
3. 私有 registry / 网络不通
"@
}

shell 版同理:set -e + 不写 fallback 分支即可。

反模式 1:全 pin

诱惑:既然 ^ 会漂,全 pin 不就稳了?

不要:

  • npm 生态约定是”package.json 写范围 + lock 锁精确”,改约定等于跟生态对着干
  • 每次升级都得改 package.json,即使是 patch 修复
  • 真正的问题不在 ^ 本身,在”lock 不生效时 ^ 才被求值”那条路径——堵了路径,^ 永远不被求值,跟 pin 等效

只对”跨包套件顶层”做精确化(兄弟包内部钉死版本的那种),作为多一层防御。不必全 pin。

反模式 2:只 pin 不堵 fallback

诱惑:lock 已经锁了 designer:3.4.0,装出来应该是 3.4.0 啊?

只有 --frozen-lockfile 成功时才严格按 lock 装。一旦走 pnpm install(无 frozen),pnpm 优先满足 package.json 顶层 spec,lock 只是 hint,会被改写。

所以 lock 锁的版本只有在”frozen 永远生效”时才管用。第 3 层 fail-fast 就是保这个。没第 3 层,第 2 层等于摆设

反模式 3:遇到 frozen 失败的项目就 fallback

很多 yarn / pnpm 教程 / CI 模板写”frozen 失败 → install 兜底”,看起来稳健,实际是把”构建可重现性”换成”构建一定能跑完”。

构建一定能跑完不是目标,构建装出来跟你 6 个月前装出来字节级一致才是目标。fallback 静默换掉版本,产物再也不可复现——这就是这次 BPM 保存事故的根因。