Chapter 09

CI 跑得比本地还快

远程缓存 + filter + prune 三件套拼到 CI 里,是 Turbo 真正发光的场景:PR CI 10 分钟降到 30 秒,Vercel 部署跳过没改的 app,Changesets 自动发版。本章把一份完整的生产 workflow 拆开看。

完整 GitHub Actions workflow

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
      TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_SIGNATURE_KEY }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0           # 必须!--filter=[origin/main] 需要历史

      - uses: pnpm/action-setup@v4

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

      - run: pnpm install --frozen-lockfile

      - name: Build / Test / Lint
        run: pnpm turbo run build test lint --filter="...[origin/main]"

关键配置点

fetch-depth: 0
checkout 默认只拉一层——changed-since filter 对比不了。0 = 全量历史。
TURBO_TOKEN / TURBO_TEAM
远程缓存认证。TEAM 是 Vercel team slug,放 vars(非 secret)里方便查看。
concurrency + cancel-in-progress
新 push 取消旧 run——避免老 PR 的缓存污染,也省 CI 额度。
pnpm install --frozen-lockfile
CI 要求 lockfile 严格匹配——防止意外升级依赖导致 hash 不对。

PR 分支只读缓存

env:
  TURBO_REMOTE_CACHE_READ_ONLY: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}

PR 只读,main push 才写——防止 PR 的中间构建污染主缓存。

按任务拆 job

jobs:
  build:
    steps:
      - run: pnpm turbo run build --filter="...[origin/main]"
  test:
    steps:
      - run: pnpm turbo run test --filter="...[origin/main]"
  lint:
    steps:
      - run: pnpm turbo run lint --filter="...[origin/main]"

三个 job 并行跑——每个 job 独立用远程缓存,失败互不影响。比单 job 跑 build test lint 快。

独立 job 的缓存协作
第一个 job 跑 build,产物上传远程缓存;第二个 job 跑 test(依赖 build)——直接从远程缓存拉产物,不用自己再 build。Turbo 协议让多 job 天然共享。

turbo-ignore:Vercel 部署跳过

// vercel.json
{
  "ignoreCommand": "npx turbo-ignore"
}
# Vercel 构建前会运行这个命令
# 非 0 退出 → 跳过部署
# 0 退出 → 继续部署

npx turbo-ignore
# 1. 读当前项目的 package.json name
# 2. 跑 turbo run build --dry-run --filter=...[HEAD^1]
# 3. 当前 project 没在任务列表 → 跳过

多 app 仓库的 Vercel 配置

apps/
├── web/            ← Vercel Project A
├── docs/           ← Vercel Project B
└── admin/          ← Vercel Project C

每个 Vercel Project:
  - Root Directory: apps/web(或 docs / admin)
  - Build Command: cd ../.. && turbo run build --filter=@myorg/web
  - Install Command: cd ../.. && pnpm install --frozen-lockfile
  - Ignored Build Step: npx turbo-ignore

push 一次,三个 project 各自决定要不要部署:

改 packages/ui → web + docs 受影响 → 这两个部署;admin 不部署
改 apps/admin/src → 只 admin 部署
改 README.md → 三个都不部署(inputs 排除 .md)

Changesets + Turbo 发版

# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - uses: changesets/action@v1
        with:
          publish: pnpm release        # 跑 package.json 的 release script
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NPM_CONFIG_PROVENANCE: true
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
// 根 package.json
{
  "scripts": {
    "release": "turbo run build --filter=./packages/* && changeset publish"
  }
}

流程:

  1. PR 附带 changeset 文件(pnpm changeset 交互生成)
  2. merge 到 main → release workflow 触发
  3. changesets/action 先开一个 "Version Packages" PR,累积版本号
  4. merge 那个 PR → changesets publish 发到 npm
  5. 发版前 turbo run build(可能命中 PR 留下的缓存,几秒完成)

Nightly 构建 warm 缓存

on:
  schedule:
    - cron: '0 3 * * *'       # 每天 3AM

jobs:
  warm-cache:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install
      - run: pnpm turbo run build        # 全 build,填远程缓存

有规律的 nightly:第二天开发者 clone/checkout 新分支,直接命中 → 本地 0 build 时间。大团队常见。

CI 时长监控

- name: Build with summary
  run: pnpm turbo run build --summarize

- name: Upload summary
  uses: actions/upload-artifact@v4
  with:
    name: turbo-summary
    path: .turbo/runs/
// 分析脚本
const fs = require('fs');
const files = fs.readdirSync('.turbo/runs');
const latest = JSON.parse(fs.readFileSync(`.turbo/runs/${files[0]}`));

const hitRate = latest.executionSummary.cached / latest.executionSummary.attempted;
console.log(`Cache hit rate: ${(hitRate * 100).toFixed(1)}%`);

把 hit 率发到 DataDog / Grafana,低于 80% 触发告警——可能有人在乱改 globalDependencies。

self-hosted runner

runs-on: [self-hosted, linux, x64]

自建 runner:.turbo/cache 持久化在 runner 上——本地缓存都不用再跑。加上远程缓存双保险,命中率拉满。

Vercel 上游部署

GitHub push
  ├── GitHub Actions: 跑 ci.yml(test/lint/build)
  └── Vercel: 自动拉代码部署
       → ignoreCommand 判断要不要部署
       → 需要部署时,自己也跑 turbo run build
       → 和 GH Actions 共享 remote cache
       → 通常直接命中 → 部署 < 30s
Vercel 和 GH Actions 的缓存互通
关联同一 Vercel team slug,GH Actions 构建产物上传后,Vercel 部署拉同一缓存——你的 CI 跑完 build,Vercel 秒部署。省双倍构建时间。

不同 env 的构建

jobs:
  build-staging:
    env:
      NEXT_PUBLIC_API_URL: https://staging-api.example.com
    steps:
      - run: pnpm turbo run build --filter=@myorg/web

  build-production:
    env:
      NEXT_PUBLIC_API_URL: https://api.example.com
    steps:
      - run: pnpm turbo run build --filter=@myorg/web

env 不同 → hash 不同 → 两个产物各自缓存。staging 部署命中 staging 缓存,production 命中 production 缓存——互不干扰。

实战时间对比

阶段无 TurboTurbo + Remote Cache
CI 冷跑(全 miss)6 分钟6 分钟(首次构建缓存)
CI PR 改 1 文件6 分钟40 秒
CI rerun 同一 commit6 分钟30 秒
Vercel 部署5 分钟30 秒(复用 CI)
开发者 git pull + build2 分钟10 秒

Turborepo 配置 checklist

✓ fetch-depth: 0
changed-since filter 的前提
✓ TURBO_TOKEN + TURBO_TEAM
远程缓存认证
✓ TURBO_REMOTE_CACHE_SIGNATURE_KEY
缓存投毒保护
✓ frozen-lockfile
pnpm install 严格模式
✓ concurrency group
取消旧 run
✓ --filter="...[origin/main]"
只跑受影响的
✓ turbo-ignore in vercel.json
跳过无关部署

本章小结