CI 的三件套
pnpm install --frozen-lockfile严格按 lockfile,不升版本- 缓存
~/.local/share/pnpm/store,重跑时不重下 - 缓存
node_modules(有条件),跳过 link 步骤
GitHub Actions 标准模板
# .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # changed-since 需要 - uses: pnpm/action-setup@v4 with: version: 9 run_install: false - uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm' # 自动缓存 pnpm store - run: pnpm install --frozen-lockfile - run: pnpm -r lint - run: pnpm -r test - run: pnpm -r build
setup-node 的 cache: 'pnpm'
v4+ 支持,自动根据 pnpm-lock.yaml 的哈希当作 cache key——命中率高。以前要手写 actions/cache,现在一行搞定。
v4+ 支持,自动根据 pnpm-lock.yaml 的哈希当作 cache key——命中率高。以前要手写 actions/cache,现在一行搞定。
手写缓存(更细的控制)
- name: Get pnpm store directory id: pnpm-cache shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - uses: actions/cache@v4 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store-
适合单独管理 store 缓存或多 workflow 共享。
--frozen-lockfile 必不可少
pnpm install --frozen-lockfile # 如果 package.json 和 lockfile 不一致 → 直接报错退出 # 防止 CI 偷偷升级依赖
本地开发不用这个——否则 pnpm add 改完 package.json 再 install 就装不上。CI/生产必用。
只装生产依赖
pnpm install --prod pnpm install --production # 同义 # 跳过 devDependencies,镜像更小 pnpm install --prod --frozen-lockfile # CI 生产镜像推荐
deploy:为部署单独打包
pnpm deploy --filter=@myorg/web /tmp/deploy-web # 把 @myorg/web 和它的依赖"实体化"到独立目录 # 目标目录是自包含的,可直接 tar 打包部署
pnpm deploy 把 workspace 内部的 symlink 依赖变成真实文件,生成可独立运行的目录——对 Docker/k8s 部署尤其有价值。
Docker 多阶段 + BuildKit 缓存
# syntax=docker/dockerfile:1.7 FROM node:20-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app # ---- 依赖层(变化最少,缓存最久) FROM base AS deps COPY pnpm-lock.yaml package.json ./ COPY pnpm-workspace.yaml ./ COPY apps/web/package.json ./apps/web/ COPY packages/*/package.json ./packages/*/ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile # ---- build 层 FROM deps AS build COPY . . RUN pnpm -F @myorg/web build # ---- 只保留生产依赖 → 部署用 FROM base AS runtime COPY --from=build /app/apps/web/.next ./.next COPY --from=build /app/apps/web/package.json ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --prod --frozen-lockfile EXPOSE 3000 CMD ["node", ".next/standalone/server.js"]
关键点:
--mount=type=cache,id=pnpm,target=/pnpm/store:BuildKit 缓存跨构建持久,首次 2 分钟,再次 10 秒- 先 copy lockfile + 所有 package.json,再 install——这层只在依赖变时失效
- copy 源码在 install 后面,代码改动不触发重装
deploy 子包到 Docker
FROM base AS build COPY . . RUN pnpm install --frozen-lockfile RUN pnpm -F @myorg/web build RUN pnpm deploy --filter=@myorg/web --prod /out FROM node:20-alpine AS runtime WORKDIR /app COPY --from=build /out . CMD ["node", "dist/server.js"]
pnpm deploy 把该子包的运行时依赖实体化到 /out——不需要拷贝整个 monorepo,镜像瘦身显著。
使用 Turborepo 做任务缓存
// turbo.json { "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, "test": { "dependsOn": ["build"], "outputs": [] } } }
pnpm turbo run build # Turbo 根据输入文件哈希缓存构建产物 # 第二次跑:未改动的包直接从缓存拷贝,0 构建 pnpm turbo run build --filter="...[HEAD^1]" # 配合 filter,只构建改动影响到的包
远程缓存
# GitHub Actions - name: Build run: pnpm turbo build env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: myorg
Turbo 的远程缓存把构建产物上传到 Vercel/自建 s3——同事在本地拉 PR 分支,跑 build 直接下载你 CI 的缓存,30 秒出结果。
monorepo-friendly Dockerfile(完整)
# syntax=docker/dockerfile:1.7 FROM node:20-alpine AS base RUN corepack enable WORKDIR /app FROM base AS pruner RUN npm i -g turbo COPY . . RUN turbo prune @myorg/web --docker # 生成 /app/out/{json,full},只含 web 及其依赖 FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile COPY --from=pruner /app/out/full/ . RUN pnpm turbo run build --filter=@myorg/web FROM base AS runner COPY --from=installer /app/apps/web/.next/standalone ./ EXPOSE 3000 CMD ["node", "apps/web/server.js"]
turbo prune 剔除所有不相关的 package,镜像构建上下文从 800MB 降到 80MB——Docker 层缓存命中率大幅提升。
registry 配置:国内镜像 + token
# .npmrc(项目根) registry=https://registry.npmmirror.com/ @myorg:registry=https://npm.pkg.github.com/ //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# CI 设置 token - env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm install --frozen-lockfile
不同 scope 走不同 registry——公司私有包走 GitHub Packages,开源包走公共镜像。
监控 install 耗时
pnpm install --reporter=ndjson > install.log # ndjson 每行一个 JSON 事件,便于分析 time pnpm install # real 0m12.3s ← 记录下来定期对比
CI 时间变长常因 store cache 失效——定期看日志里的「downloading」条目数。
常见 CI 问题
ERR_PNPM_OUTDATED_LOCKFILE
package.json 改了但 lockfile 没更新——本地跑
pnpm install 重新生成 lockfile 并提交。ETIMEDOUT / ECONNRESET
registry 网络不稳——配国内镜像,或加
fetch-retries=5、fetch-timeout=60000。ENOSPC(磁盘满)
CI runner 磁盘小——删 node_modules 再装,或用 self-hosted runner 扩容。
缓存命中率低
cache key 里应该只 hash
pnpm-lock.yaml,别带 package.json 之类频繁变的。本章小结
- CI:
setup-node自带cache: 'pnpm'、--frozen-lockfile必用 - Docker:BuildKit
--mount=type=cache持久化 store,构建速度 4 倍 pnpm deploy实体化 workspace 依赖,镜像自包含turbo prune+pnpm install+ Turbo 远程缓存 = 真正的秒级 CI.npmrc配多 registry、私包 token、超时重试——生产必调