首页
/ AIRI 单仓库的 pnpm CI/CD 实践:冻结锁文件、Store 缓存与 Monorepo 增量构建

AIRI 单仓库的 pnpm CI/CD 实践:冻结锁文件、Store 缓存与 Monorepo 增量构建

2026-09-05 16:27:42作者:昌雅子Ethen

本文基于 AIRI 仓库内置的 pnpm 技能文档 best-practices-ci.md,系统讲解 pnpm 在 CI/CD 环境中的最佳实践:GitHub Actions / GitLab CI / Docker 三类流水线的完整配置、关键安装标志位(--frozen-lockfile--prefer-offline--ignore-scripts)、Corepack 版本锁定,以及 Monorepo 下的增量构建策略。读完本文,你能将同一套 pnpm CI 方案迁移到自己仓库,并理解 AIRI 这类大型 Monorepo 是如何在真实流水线中落地这些实践的。

pnpm 的 CI 自动行为:先搞懂这三件事

在写任何 YAML 之前,先理解 pnpm 检测到 CI 环境(CI 环境变量等)后的三个自动行为,它们决定了后续所有配置的逻辑起点(引自文档开篇):

  1. 自动进入 frozen-lockfile 模式:CI 中 pnpm 默认不允许更新 pnpm-lock.yaml,锁文件与实际依赖不一致时安装直接失败。
  2. v11 起对不兼容锁文件直接报错:由更高主版本的 pnpm 生成的锁文件,低版本 pnpm 在 CI 中不再尝试重写,而是直接失败——这要求 CI 使用的 pnpm 版本必须与生成锁文件时的版本保持同一大版本
  3. 全局虚拟存储(global virtual store)在 CI 中被自动禁用:CI 环境没有可复用的预热缓存,不要指望跨构建复用的全局虚拟存储来加速 CI。

AIRI 仓库本身就是这条规则的实践者:根 package.json 中通过 "packageManager": "pnpm@11.24.0" 精确锁定 pnpm 11.x,而仓库根的 pnpm-lock.yaml 声明 lockfileVersion: '9.0'——锁文件版本与 pnpm 大版本严格对应。CI 工作流 .github/workflows/ci.yml 的每个 job 都通过 pnpm/setup@v2 行动显式指定 runtime,保证与本地 ​.tool-versions 中声明的 nodejs 26.7.0 版本一致,从工具链层面杜绝“CI 与锁文件生成环境不同步”的问题。

GitHub Actions

基本配置

文档给出的最简 CI 模板如下,覆盖了“安装 pnpm → 配置 Node 并开启缓存 → 冻结锁文件安装 → 测试 → 构建”的完整链路:

name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile   # or: pnpm ci
      - run: pnpm test
      - run: pnpm build

这里要区分两个等价但语义不同的命令:

  • pnpm install --frozen-lockfile:安装依赖,若锁文件需要更新则失败;
  • pnpm ci(别名 clean-installinstall-clean):等价于 pnpm clean + pnpm install --frozen-lockfile,即先清理 node_modules 再按锁文件全新安装,适合追求“完全可复现”的 CI 构建。

AIRI 的 ci.yml 采用了更新一代的 pnpm/setup@v2 action(合并了 pnpm 安装与 Node runtime 配置),并用 cache: true 开启内置缓存、install: false 把安装步骤留给显式的 pnpm install --frozen-lockfile,与模板中的模式一一对应。

Store 缓存:大型仓库的关键优化

对依赖庞大的项目,应额外缓存 pnpm 的内容寻址 store(全局包实体存放地)。文档推荐的写法是先用 pnpm store path 解析出 store 目录,再交给 actions/cache

- uses: pnpm/action-setup@v4
  with:
    version: 10

- name: Get pnpm store directory
  shell: bash
  run: |
    echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- uses: actions/cache@v4
  name: Setup pnpm cache
  with:
    path: ${{ env.STORE_PATH }}
    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-store-

- run: pnpm install --frozen-lockfile

缓存 key 以 pnpm-lock.yaml 的哈希为核心:锁文件不变时精确命中;变更时通过 restore-keys 前缀回退到旧缓存部分复用。

信任边界提醒(引自文档原文): 只在可信任务之间缓存/恢复 pnpm store 与缓存目录。一个不可信任务可写入的 store,绝不能被可信任务复用——store 属于 pnpm 的信任域,缓存污染等价于供应链攻击面。

在 AIRI 中,缓存实际上由两层组成:一是 pnpm store(pnpm/setup@v2cache: true 自动处理),二是 Turborepo 的构建缓存。ci.yml 中专门有一步用 actions/cache@v5 缓存 .turbo 目录,key 为 ${{ runner.os }}-turbo-${{ github.sha }} 并按前缀回退。两者配合时,CI 的耗时大头(装依赖 + 重复构建)都被压缩。

矩阵测试(Matrix Testing)

文档给出的跨 OS × 跨 Node 版本矩阵模板:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

AIRI 的 build-test job 是矩阵思路的另一种应用维度——按应用切分而非按 OS 切分:ci.ymlstrategy.matrix.include 中列出了 stage-webstage-tamagotchistage-tamagotchi-godotui-transitionsui-loading-screens 五个条目,每个条目绑定一条构建命令(如 pnpm -F @proj-airi/stage-web run build),让不同产物的构建并行且互不阻塞。这种“一个矩阵单元 = 一个可交付产物”的划分方式,与本文后面的 Monorepo 增量策略一脉相承。

GitLab CI

文档提供的 GitLab 完整配置(node 镜像 + corepack 激活 + store 目录缓存 + 三阶段流水线):

image: node:20

stages:
  - install
  - test
  - build

variables:
  PNPM_HOME: /root/.local/share/pnpm
  PATH: $PNPM_HOME:$PATH

before_script:
  - corepack enable
  - corepack prepare pnpm@latest --activate

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .pnpm-store

install:
  stage: install
  script:
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile

test:
  stage: test
  script:
    - pnpm test

build:
  stage: build
  script:
    - pnpm build

两个值得注意的实现细节:

  • 通过 pnpm config set store-dir .pnpm-store 把 store 移到工作区内的 .pnpm-store 目录,使其可被 GitLab 的 cache.paths 捕获(GitLab 缓存的是路径而非环境目录);
  • corepack prepare pnpm@latest --activate 是模板写法,实际使用时建议替换为精确版本(如 pnpm@11.24.0),与“CI pnpm 版本须与锁文件生成方一致”的原则一致。

Docker

PATH 变更(v11,引自文档原文): 自 pnpm v11 起,全局 pnpm 二进制位于 $PNPM_HOME/bin。在 Docker 中应设置 ENV PATH="$PNPM_HOME/bin:$PATH"(而非旧式的 $PNPM_HOME)。另外官方镜像 ghcr.io/pnpm/pnpm:<version>(Debian slim、仅含 pnpm)可用于自选 Node 的场景,通过 pnpm runtime set node <ver> -gdevEngines.runtime 指定 Node。

多阶段构建

文档给出的通用多阶段模板——先按“清单文件优先”的顺序 COPY 以最大化层缓存,构建阶段与运行阶段分离:

# Build stage
FROM node:24-slim AS builder

# Enable corepack for pnpm
RUN corepack enable

WORKDIR /app

# Copy package files first for layer caching
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/*/package.json ./packages/

# Install dependencies
RUN pnpm install --frozen-lockfile

# Copy source and build
COPY . .
RUN pnpm build

# Production stage
FROM node:20-slim AS runner

RUN corepack enable
WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./

# Production install
RUN pnpm install --frozen-lockfile --prod

CMD ["node", "dist/index.js"]

模板中“先 COPY 清单文件、后 COPY 源码”的顺序是层缓存技巧的核心:只要 package.json / pnpm-lock.yaml / pnpm-workspace.yaml 不变,pnpm install --frozen-lockfile 这一层就命中缓存。

AIRI 仓库 apps/stage-web/Dockerfile 提供了另一个真实变体:它没有走“清单优先”的 COPY 拆分,而是用 BuildKit 的 cache mount 把 pnpm store 挂到构建缓存层中,跨构建复用了依赖实体:

RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
    pnpm install --frozen-lockfile

两种写法(清单文件层缓存 vs BuildKit cache mount)目标相同——让 store 层面的下载/解压只发生一次——可按 CI 平台能力选用。stage-web 的构建阶段还会串起三个产物:pnpm -F @proj-airi/stage-web run buildpnpm -F @proj-airi/docs run build:basepnpm -F @proj-airi/stage-ui run story:build,最后由 nginx:stable-alpine 作为 production stage 托管静态资源(Dockerfile)。

Monorepo 优化

针对 Monorepo 的镜像构建,文档强调保持目录结构地 COPY 各子包的 package.json,再用 --filter 只构建目标包:

FROM node:20-slim AS builder
RUN corepack enable
WORKDIR /app

# Copy workspace config
COPY pnpm-lock.yaml pnpm-workspace.yaml ./

# Copy all package.json files maintaining structure
COPY packages/core/package.json ./packages/core/
COPY packages/api/package.json ./packages/api/

# Install all dependencies
RUN pnpm install --frozen-lockfile

# Copy source
COPY . .

# Build specific package
RUN pnpm --filter @myorg/api build

注意 pnpm install 仍需在 workspace 根执行一次(单锁文件模型决定 Monorepo 不能按子包单独安装),--filter 只用于收敛构建/运行范围。AIRI 的所有脚本都遵循这一模式,例如根 package.json"dev": "pnpm -r -F @proj-airi/stage-web dev""build:packages": "turbo run build -F=\"./packages/*\"

关键 CI 标志位详解

--frozen-lockfile:CI 的默认纪律

永远在 CI 中使用。pnpm-lock.yamlpackage.json 声明不一致、需要更新时直接失败,把“锁文件过期”这类问题拦在合并之前,而不是让 CI 默默改写锁文件:

pnpm install --frozen-lockfile

AIRI 的 CI 中每条 job(lint / build-test / unit-test / typecheck)都显式执行 pnpm install --frozen-lockfileci.yml),没有依赖 pnpm 的 CI 自动检测,属于双保险。

--prefer-offline

优先使用本地 store 中已有的包实体,仅在缺失时访问网络——与 store 缓存配合后,命中缓存的构建几乎不产生外网请求:

pnpm install --frozen-lockfile --prefer-offline

--ignore-scripts

跳过所有生命周期脚本以加速安装,但需谨慎使用(文档原话:use cautiously):部分依赖(native 模块、需要 postinstall 拉取二进制的包)依赖脚本才能工作。

pnpm install --frozen-lockfile --ignore-scripts

在 AIRI 这类含大量 native/编译型依赖的仓库中,更精细的控制粒度是 pnpm-workspace.yaml 中的 allowBuilds 映射(v11 取代了旧版的 onlyBuiltDependencies/neverBuiltDependencies):pnpm-workspace.yaml 逐包声明了哪些依赖允许执行构建脚本(如 esbuild: trueelectron: truecanvas: true),哪些显式禁用(better-sqlite3: false),未列出的包默认视为未审核而被阻塞。这比一刀切的 --ignore-scripts 既更快(只跑必要的脚本)也更安全(未审核包一律不跑)。

另外注意根包自身的 postinstall 不受 allowBuilds 管辖:AIRI 根 package.json 声明了 "postinstall": "pnpm exec simple-git-hooks && pnpm run build:packages",因此 CI 中 pnpm install 结束时实际上已经触发过一轮包构建——这也是为什么工作流在 install 之后仍会显式再跑 pnpm run build:packages 来保证构建状态可控。

Corepack 集成:把 pnpm 版本写进仓库

文档推荐的版本锁定方式是把 packageManager 写进 package.json,任何环境只需 corepack enable 即可获得正确版本:

// package.json
{
  "packageManager": "pnpm@10.0.0"
}
# GitHub Actions
- run: corepack enable
- run: pnpm install --frozen-lockfile

AIRI 的根 package.json 正是这样写的:"packageManager": "pnpm@11.24.0",精确到补丁版本。文档还补充了三个进阶选项:

  • 范围式锁定:使用 devEngines.packageManager(可写版本范围),解析出的具体版本会记录在锁文件中;
  • 外部版本管理豁免:若 pnpm 版本由 asdf / mise / Volta 等外部工具管理,可在 pnpm-workspace.yaml 中设置 pmOnFail: ignore 跳过 pin 检查;
  • 一次性切换pnpm with current <cmd> 可在不改变全局状态的前提下用当前锁定版本执行单条命令。

Monorepo CI 策略:只构建变化的部分

AIRI 的 workspace 范围由 pnpm-workspace.yamlpackages 字段声明(packages/**apps/**plugins/**server/**engines/** 等九个通配,并排除 !**/dist/**),全仓库共享一个锁文件。依赖图上的构建编排则交给 Turborepo:turbo.jsonbuild 任务声明了 dependsOn: ["^build"](先构建上游依赖包)与 outputs: ["dist/**"](构建产物参与缓存)。

策略一:基于过滤的增量构建

...[origin/main] 过滤语法选出相对基准分支有变化的包,再交给 turbo 按依赖图传播:

- name: Build changed packages
  run: |
    pnpm --filter "...[origin/main]" build

这与 AIRI 根 package.jsonturbo run build -F="./packages/*" -F="./apps/*" -F="./server/**" 的全量入口互补——PR 场景走增量过滤,发布/夜间构建走全量。

策略二:按包拆分的并行 job

文档给出了“先检测变化、再矩阵展开”的完整 GitHub Actions 方案:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.changes.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: changes
        run: |
          echo "packages=$(pnpm --filter '...[origin/main]' list --json | jq -c '[.[].name]')" >> $GITHUB_OUTPUT

  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.packages != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect-changes.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter ${{ matrix.package }} test

关键细节:fetch-depth: 0 是必需的(过滤基线需要完整 git 历史);detect-changes 的输出经 fromJson 展开为矩阵维度;if 条件保证无变化时测试 job 整体跳过。AIRI 的 check-provenance job(ci.yml)则展示了同一仓库中另一类“检测先行”job——用 danielroe/provenance-action 校验锁文件变更的溯源完整性,与文档中“store 缓存的信任域”提醒同属供应链安全范畴。

最佳实践总结

文档的六条核心结论,以及它们在 AIRI 仓库中的对应落点:

  1. CI 使用 pnpm ci--frozen-lockfileci.yml 每个 job 显式执行 pnpm install --frozen-lockfile
  2. 缓存 pnpm store(仅限可信 job 之间)pnpm/setup@v2cache: true + .turbo 缓存双层叠加;
  3. CI 的 pnpm 大版本必须与锁文件生成方一致 → 根 package.json 锁定 pnpm@11.24.0pnpm-lock.yamllockfileVersion: '9.0',本地 ​.tool-versions 固定 Node 26.7.0 与 CI runtime 对齐;
  4. 在 package.json 中锁定 packageManager(或 devEngines.packageManager → 已按 pnpm@11.24.0 精确锁定;
  5. Monorepo 用 --filter 只构建变化的部分turbo.jsondependsOn/outputs 声明 + 根脚本中大量 pnpm -F 用法;
  6. Docker 多阶段构建,PATH 指向 $PNPM_HOME/binapps/stage-web/Dockerfile 的 build/production 双阶段 + BuildKit store 缓存挂载。

配套的仓库证据还包括:pnpm-workspace.yaml 中的 overrides(如 axios: npm:feaxios@^0.0.23)、patches/ 目录下的 patchedDependenciesminimumReleaseAge: 4320 的发布年龄限制,这些配置共同构成了“frozen-lockfile 之下锁文件仍然可信”的完整链条——CI 实践不是孤立的 YAML 技巧,而是与仓库级依赖治理策略共同生效的一套体系。

登录后查看全文
热门项目推荐
相关项目推荐