pnpm 12.18.0 把本地 .tgz 接进了离线安装:先用 store add 预置,再跑 --offline --frozen-lockfile,三个依赖的 registry 下载数为 0。12.17 做同样准备仍报 ERR_PNPM_NO_OFFLINE_TARBALL。拿 Spring Boot 4.0.5 接上前端静态资源构建,测试也跑通了。

12.18.0 在 2026 年 11 月 5 日发布,GitHub Release 和官方发布说明都列了这条变化。它不是把项目依赖改成 file:,也不是只下载主包;锁文件里钉住的 registry 包,可以从同包同版的本地压缩包装进 store。

先看清这份离线流程的输入

准备阶段需要有网,或者由另一台机器提供压缩包。进入断网构建环境前,仓库里要有 package.json、pnpm-lock.yaml 和全部依赖的 .tgz。下面的小项目直接依赖 ansi-styles@4.3.0,它还带进两个传递依赖:

{
  "name": "boot-assets-offline-demo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "node scripts/build.js"
  },
  "dependencies": {
    "ansi-styles": "4.3.0"
  }
}

锁文件中的三个条目分别是 ansi-styles@4.3.0、color-convert@2.0.1、color-name@1.1.4。每个条目都有自己的 resolution.integrity。例如:

packages:
  ansi-styles@4.3.0:
    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
    engines: {node: '>=8'}

官方store add 文档写得很具体:离线安装会取锁文件所指 tarball 对应的包,包名、版本和完整性哈希都要匹配。12.15 起可选的 lockfile.integrityTarget: tar 记录未压缩 tar 的哈希;这次 store add 同样接受这种锁法。两种哈希口径的差别,前面的pnpm 12.15 锁文件实测已经展开过,这里不重复。

预置 store,再断网装

在项目目录执行。压缩包统一放在相邻的 tarballs 目录,store 单独指定,避免混进之前在线安装的缓存:

$ pnpm store add --store-dir .ci-store ../tarballs/*.tgz
Progress: resolved 0, reused 0, downloaded 1, added 0
+ file:../tarballs/ansi-styles-4.3.0.tgz
+ file:../tarballs/color-convert-2.0.1.tgz
+ file:../tarballs/color-name-1.1.4.tgz

这一步只往 store 写包,不改 package.json。随后安装:

$ pnpm install --offline --frozen-lockfile --store-dir .ci-store
Progress: resolved 1, reused 0, downloaded 0, added 0
Packages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: .ci-store/v11
  Virtual store is at:             node_modules/.pnpm
Packages: +3
+++
Progress: resolved 3, reused 3, downloaded 0, added 3, done
Lockfile is up to date, resolution step is skipped

dependencies:
+ ansi-styles 4.3.0

Done in 32ms using pnpm v12.18.0

关键行是 downloaded 0, added 3。直接依赖只有一行,实际新增三个包,结合预置的三个 .tgz 和最终构建,可确认两个传递依赖也进了 node_modules。--frozen-lockfile 同时阻止安装过程重写依赖解析结果;本次安装前后的锁文件 SHA-256 都是 15907bf9…410eb。

空 store 不会凭空补齐,12.17 也不能照抄

同一个项目先不执行 store add,直接指向空 store,安装退出码为 1:

Progress: resolved 1, reused 0, downloaded 0, added 0
Error: ERR_PNPM_NO_OFFLINE_TARBALL

  × installing dependencies
  ╰─▶ Failed to fetch tarball for ansi-styles@4.3.0 from https://
      registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz in offline mode:
      snapshot not present in local store

只准备主包和 color-convert,故意漏掉 color-name,也会退出 1。这次报错准确落到漏掉的传递依赖:

Error: ERR_PNPM_NO_OFFLINE_TARBALL

  × installing dependencies
  ╰─▶ Failed to fetch tarball for color-name@1.1.4 from https://
      registry.npmjs.org/color-name/-/color-name-1.1.4.tgz in offline mode:
      snapshot not present in local store

所以压缩包集合必须覆盖完整依赖图,不能只按 package.json 的 dependencies 下载。拿锁文件逐项核对更稳。

新旧版对照也用各自的全新 store 和缓存。两边都先执行 store add,再跑同一条离线安装。12.18.0 成功,12.17.0 退出 1:

Progress: resolved 1, reused 0, downloaded 0, added 0
Error: ERR_PNPM_NO_OFFLINE_TARBALL

  × installing dependencies
  ╰─▶ Failed to fetch tarball for ansi-styles@4.3.0 from https://
      registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz in offline mode:
      snapshot not present in local store

这说明差别不只是机器里原先有没有缓存。旧版虽然接受本地 tarball 参数,但这次 registry 锁文件离线安装并没有复用出相同结果。

全新缓存还会遇到元数据校验

第一次把 store 和 pnpm 元数据缓存都清空时,离线安装没有直接成功,而是停在供应链策略校验,报 ERR_PNPM_META_FETCH_FAIL 和 ERR_PNPM_NO_OFFLINE_META。原因是默认的 trustLockfile: false 会对锁文件重新执行 minimumReleaseAge 和 trustPolicy 校验,需要 registry 元数据。

本次把锁文件当作经过审查、随受信提交进入构建环境的输入,在项目根目录增加:

# pnpm-workspace.yaml
trustLockfile: true

再清空 store 和缓存重跑,三包离线安装成功。这个设置只跳过那趟锁文件供应链复检,不跳过包内容与锁文件 integrity 的匹配。把主包 index.js 加一行再重新压成同名 .tgz,预置后安装仍退出 1,返回 ERR_PNPM_NO_OFFLINE_TARBALL。

但它确实降低了策略检查强度。官方设置说明明确提醒:外部协作者能改锁文件时保持 false。不能断网取得校验元数据时,不要默默打开它;要把锁文件审查和受信输入边界先做好。

接到 Spring Boot 静态资源构建

用一个真实的小构建验证产物。Node 20、Java 21、Spring Boot 4.0.5,前端构建脚本从刚离线安装的 node_modules 里读 ansi-styles,写到 Boot 的静态资源类路径:

const fs = require('node:fs');
const ansi = require('ansi-styles');
const banner = `${ansi.green.open}Spring Boot assets ready${ansi.green.close}`;
fs.mkdirSync('target/classes/static', { recursive: true });
fs.writeFileSync('target/classes/static/banner.txt', banner + '\n');
console.log(banner);

执行 pnpm run build,输出原样是:

$ node scripts/build.js
Spring Boot assets ready

控制台实际还带有 ANSI 绿色控制码,生成文件里同样保留。Boot 测试不另造文件,直接从 classpath:/static/banner.txt 读取:

@SpringBootTest
class OfflineAssetsTest {
  @Autowired ResourceLoader resources;

  @Test
  void servesOfflineBuiltAssetFromClasspath() throws Exception {
    Resource asset = resources.getResource("classpath:/static/banner.txt");
    assertThat(asset.exists()).isTrue();
    assertThat(asset.getContentAsString(StandardCharsets.UTF_8))
      .contains("Spring Boot assets ready");
  }
}

mvn test 的结果:

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time:  3.315 s

到这里,离线安装不是只停在 node_modules 看着齐全。它产出的静态资源已经进入 Boot 类路径并通过断言。

适合放进哪条构建流水线

这条流程适合把“下载”和“执行安装”分开:联网阶段按锁文件收集全部 .tgz,审查后交给隔离构建环境;隔离阶段只带仓库、压缩包和 pnpm,不再访问 registry。已有内网制品库的团队,也可以把压缩包准备步骤放进制品归档,而不是让每台 CI Runner 各自在线补 store。

实施时盯住四件事:pnpm 升到 12.18.0;压缩包覆盖所有传递依赖;锁文件和 tarball 的包名、版本、integrity 对得上;是否开启 trustLockfile 按锁文件信任边界决定。少任何一项,离线构建都可能变成一次很靠缓存的“碰运气”。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。