发版管线中途 prune devDeps,下游 check 集体失联
给桌面 App 搭 release 自动化脚本时,我做了一个「看起来很合理」的设计:每个发版子脚本(macOS dmg、Windows exe、Web 镜像)都自带前置检查门禁,避免直接调子脚本时绕过测试。同时顶层编排脚本在 step 0a 也跑了一次完整的 check:all。
有天全量发版,step 0a 绿了,mac dmg 也推上去了,到 step 3 Windows 子脚本直接崩:sh: vitest: command not found。明明刚才 check 还通过……
现象
[0a] npm run check:all ✓ vitest / eslint / tsc 全绿
[1] chromium tarball ✓
[2] release-mac (dmg build) ✓ mac dmg 推送成功,版本号已 bump
[3] release-windows ✗ exit 1
→ npm run check:all
→ sh: vitest: command not found
第一反应是"vitest 没装",npm install 一跑确实通了。但下次发版又坏——循环往复。
根因
mac dmg build 脚本内部有一步 npm --prefix desktop/sidecar prune --omit=dev,目的是给最终 bundle 瘦身,把开发依赖从 sidecar 子包里剪掉。
这步在 step 2 成功完成后,desktop/sidecar/node_modules/.bin/vitest(以及 eslint、tsc、playwright 等所有 devDep CLI)就物理消失了。
step 3 的 Windows 子脚本随即调用同款 check:all,而 check:all 里有针对 sidecar 的 npm --prefix desktop/sidecar test,于是 vitest 找不到。
npm --prefix <子包> prune --omit=dev 会真实删除该子包的 devDep 二进制。在 monorepo workspace 里,这个子包的 .bin 同时也是其他脚本依赖的 CLI 来源。上游 build 步骤跑完,下游 check 就再也找不到 vitest / eslint / tsc——即使这些工具明明在 package.json 里声明着。
三个常见误判:
- "vitest 没了 = 没装" →
npm install治标,下次还坏 - "check 是 flaky" → 加 retry 也永远通不过,因为二进制真的不在了
- "上游成功了 = 一切 OK" → 版本号已 bump、mac 包已推送,状态卡半截,下次重跑还得再 bump 浪费版本号
正解
根因修复(一行 diff):编排脚本调用下游子步骤时统一传 --skip-check。
- const winArgs = ['scripts/release-windows.mjs', args.code]
+ const winArgs = ['scripts/release-windows.mjs', args.code, '--skip-check']
if (args.skipBuild) winArgs.push('--skip-build')子脚本内部的 check 门禁是给单独调用准备的,被编排器串联时已经有 step 0a 兜底,重复跑是冗余设计。编排器必须显式传 skip 信号,子脚本必须支持接收它。
紧急恢复(不重 bump):当版本号已 bump、上游步骤已完成时,直接补跑缺失子步骤即可,不要重跑整个编排:
npm run release:windows -- <code> --skip-check
npm run release:web -- <code> --skip-check设计原则:
- 每个子脚本「自带 check 门禁」是好设计,防止单独调用时绕过验证
- 编排器「step 0 跑一次 check:all」是好设计,前置把关
- 两者组合时,编排器调子脚本必须显式传
--skip-check,否则同款检查在管道里重复跑 = 设计错位
验证三件事:
grep -n "skip-check" scripts/release-*.mjs确认每个子步骤调用都带了参数- 全量跑一次编排,step 3+ 不再卡 check
- 单独调子脚本(不带
--skip-check)仍然触发检查——门禁没被破坏
一句话外卖
编排器里的「每步自带检查」和「step 0 全局检查」是互斥职责——选一个做 SSOT,另一个在被串联时必须跳过,否则上游的 prune / clean 副作用会把下游的工具链一并带走。