Monorepo 工程化 CI/CD:路径过滤、依赖图与缓存隔离

深度解析 Monorepo 仓库在 GitHub Actions 上的 CI/CD 工程化方案,涵盖 paths/paths-ignore 变更过滤、依赖图构建与 affected 检测、NX/Turborepo 集成、跨项目缓存隔离、并行 job 拓扑编排以及单仓库多应用的安全发布,帮助团队将上千次 commit 的 CI 时间降低 80% 以上。

当几十个独立应用与共享库共存于同一个仓库时,最直观的 CI 方案——每次 push 全量构建——会在提交量增长后迅速走向崩溃。本文系统讲解 Monorepo 仓库在 GitHub Actions 上的工程化方案:从 paths 变更过滤到依赖图构建与 affected 检测,从 NX/Turborepo 的任务编排到跨项目的缓存隔离,最终形成一套「只构建受影响部分」的高效流水线。


一、Monorepo 对 CI/CD 的三大挑战

1.1 挑战全景

挑战表现后果
全量构建风暴每次 commit 构建所有应用构建时间随仓库膨胀线性增长
依赖耦合共享库变更影响多个下游应用难以判断哪些任务需要重跑
缓存串扰多语言、多包管理器共存缓存键冲突、命中率低下

这三个问题如果不解决,Monorepo 带来的「代码复用、原子提交、统一工具链」优势就会被 CI 的巨大开销吞噬。核心解法是让 CI 从「全量」走向「精准」:只构建变更所影响的项目,并让未变更项目的缓存可以复用。

1.2 分层决策模型

一个健康的 Monorepo CI 应该包含四个层级:

┌─────────────────────────────────────────────┐
│ L4 发布层:按 affected 结果定向发布到多环境   │
├─────────────────────────────────────────────┤
│ L3 集成层:合并测试报告、端到端验证、门禁     │
├─────────────────────────────────────────────┤
│ L2 任务层:NX/Turbo 依赖图驱动受影响任务执行   │
├─────────────────────────────────────────────┤
│ L1 过滤层:paths 变更检测,只调度相关 job      │
└─────────────────────────────────────────────┘

二、路径过滤:paths 与 paths-ignore

2.1 基础语法

GitHub Actions 原生支持在触发器上按文件路径过滤,这是最廉价的第一层防护:

name: Docs Only
on:
  pull_request:
    paths:
      - 'docs/**'
      - 'README.md'
      - '!docs/api/**'          # 排除特定子目录

上例中,仅当 PR 改动 docs/ 或 README.md(且不命中 docs/api/)时才触发。paths 内的 ! 前缀表示排除,顺序敏感:GitHub 按顺序匹配,最后一条匹配决定结果。

2.2 paths-ignore 的陷阱

on:
  push:
    paths-ignore:
      - '**/*.md'
      - '.github/**'

一句话:paths-ignore 是「除这些之外全部触发」,更适合屏蔽文档/配置类改动;但它无法表达「只要改动了 A 或 B 就触发」这类精确逻辑,复杂场景应交给 dorny/paths-filter。

当 paths-ignore 只匹配到被忽略文件时,job 会显示为 skipped 而不是成功——这常让分支保护规则困惑。建议在文档类改动上显式跳过,而不是依赖 paths-ignore 的语义。

2.3 更精确的 dorny/paths-filter

paths 关键字只能整体决定「触发或不触发」,无法按项目粒度路由。dorny/paths-filter 可以在单次 checkout 后计算每个子项目的变更状态:

name: Monorepo CI
on:
  pull_request:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
      shared: ${{ steps.filter.outputs.shared }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # 需要完整历史计算变更
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'apps/api/**'
            web:
              - 'apps/web/**'
            shared:
              - 'packages/shared/**'

  api-build:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "构建 API 应用"
能力on: pathsdorny/paths-filter
决定是否触发✅✅
按子项目粒度路由❌✅
输出矩阵/布尔值❌✅(outputs)
支持 workflow_dispatch 时的强制无✅(list-files、手动置真)

三、依赖图构建与 affected 检测

3.1 什么是 affected

在 Monorepo 中,packageA 依赖 packageB。当 packageB 变更时,仅构建 packageB 是不够的——packageA 也必须重新构建测试。affected 集合 = 直接变更的项目 + 所有传递依赖它们的项目。这是 Nx 与 Turborepo 的核心能力。

变更: packages/shared/ 中的 utils.ts
受影响的构建目标: shared → api → web(传递依赖链)

3.2 手动计算 affected

不引入任何框架时,可以借助 git diff 与 node --require 简单推导,但维护成本极高:

# 找出自 main 合并点以来变更的文件所属项目
CHANGED=$(git diff --name-only origin/main...HEAD \
  | awk -F/ '{print $1"/"$2}' | sort -u)

# 对每个变更项目执行其专属脚本
for pkg in $CHANGED; do
  if [ -f "$pkg/package.json" ]; then
    (cd "$pkg" && npm test)
  fi
done

一句话:手写依赖遍历只适合两三层的小仓库;一旦出现共享库 → 工具链 → 应用的多级依赖,就必须交给 Nx 或 Turborepo 这类带完整项目图的工具。


四、NX/Turborepo 集成

4.1 Nx Affected 工作流

Nx 维护完整的项目依赖图,并提供 nx affected 命令。集成到 GitHub Actions 的关键是 base 与 head 的选择——通常用合并目标分支的最新提交作为 base:

jobs:
  affected-tests:
    runs-on: ubuntu-latest
    env:
      NX_BASE: origin/main
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - run: npx nx affected --target=test --base=$NX_BASE --parallel=3
      - run: npx nx affected --target=build --base=$NX_BASE

--base=origin/main 指定对比基线;--parallel=3 控制并发;Nx 会根据项目图自动跳过不受影响的项目,并复用分布式缓存。

4.2 Turborepo Filter 工作流

Turborepo 使用 git 变更扫描 --filter,...[HEAD^] 表示「与上一次提交的差异」:

jobs:
  turbo-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      # 只对 PR 引入变更的任务包及其依赖任务执行
      - run: npx turbo run build test --filter=...[origin/main]
维度NxTurborepo
依赖图来源显式 project.json + 代码分析包管理器 workspace 依赖
affected 命令nx affected --target=...turbo run ... --filter=...
远程缓存Nx Cloud(付费/自托管)Turbo Remote Cache
任务缓存按输入哈希缓存按输入哈希缓存
适用生态前端 + 全栈大型 Monorepopnpm/yarn/npm workspace

4.3 在 GitHub Actions 上共享远程缓存

无论 Nx 还是 Turborepo,远程缓存都能让 CI 命中「其他 job / 其他分支」已构建的产物,将时间再降一个量级:

- name: Configure Nx Cloud token
  run: echo "NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}" >> "$GITHUB_ENV"

五、跨项目缓存隔离

5.1 为什么不能共用一个缓存键

Monorepo 中 frontend 用 npm、backend 用 Maven、移动端用 CocoaPods,各自的缓存内容互不兼容。即使同是 npm,不同 workspace 的 node_modules 混用也会因依赖版本不同而损坏。缓存键必须按「子系统」隔离。

5.2 按目录隔离的缓存键设计

steps:
  - uses: actions/cache@v4
    with:
      path: |
        apps/web/node_modules
        ~/.npm
      key: ${{ runner.os }}-web-npm-${{ hashFiles('apps/web/package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-web-npm-

  - uses: actions/cache@v4
    with:
      path: |
        packages/shared/node_modules
      key: ${{ runner.os }}-shared-npm-${{ hashFiles('packages/shared/package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-shared-npm-

一句话:缓存键命名规范 {os}-{subsystem}-{tool}-{hashFiles(lock)},子系统前缀(web/shared/backend)保证各项目缓存互不污染。

5.3 根目录级依赖 vs 子项目依赖

现代 Monorepo 通常采用根目录统一锁文件(pnpm workspace 或 npm hoisting)。此时单个 hashFiles('pnpm-lock.yaml') 即可覆盖全部依赖:

- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/cache@v4
  with:
    path: |
      node_modules
      .turbo
    key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-
- run: pnpm install --frozen-lockfile

六、并行 job 拓扑与依赖排序

6.1 用 needs 编排流水线阶段

Monorepo 流水线常分为「变更检测 → 构建 → 测试 → 发布」四层。needs 让 job 形成有向无环图(DAG),GitHub Actions 会自动并行无依赖的 job:

jobs:
  changes:
    # ...dorny/paths-filter 输出 api/web/shared 变更状态

  build-api:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "构建 api"

  build-web:
    needs: changes
    if: needs.changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "构建 web"

  e2e:
    needs: [build-api, build-web]   # 两个构建完成后才进入 E2E
    runs-on: ubuntu-latest
    steps:
      - run: echo "端到端验证"

6.2 矩阵 + affected 组合

当被 affected 命中的项目数量不确定时,可用矩阵动态展开。dorny/paths-filter 的 list-files 输出可直接喂给矩阵:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.filter.outputs.api_files_json }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          list-files: json
          filters: |
            api:
              - 'apps/api/**'

  build-changed-api:
    needs: changes
    runs-on: ubuntu-latest
    strategy:
      matrix:
        file: ${{ fromJson(needs.changes.outputs.matrix) }}
    steps:
      - run: echo "构建变更文件 ${{ matrix.file }}"

一句话:needs 决定 DAG 拓扑,if: needs.*.outputs.* 决定条件调度,fromJson 将变更清单展开为矩阵——三者组合即可表达几乎任何 Monorepo 流水线。


七、单仓库多应用发布

7.1 按 affected 结果定向发布

发布层必须复用 build 层的 affected 判定,避免「全量重建 + 全量发布」:

jobs:
  deploy-api:
    needs: [changes, build-api]
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    environment:
      name: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy-api.sh

7.2 多应用发布策略对比

策略适用场景风险推荐
单 job 顺序发布应用少、依赖强单点失败阻塞全局小仓库
多 job 并行发布应用独立、无共享资源并发操作共享数据库/网关中仓库
队列 + 门禁发布生产多环境、审计要求编排复杂大仓库/企业

7.3 语义化版本与发布节奏

Monorepo 通常用 Changesets 统一管理版本,CI 侧在 main 分支合并后触发版本生成:

name: Release
on:
  push:
    branches: [main]
    paths:
      - '.changeset/**'
      - 'packages/**'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - uses: changesets/action@v1
        with:
          publish: npm run publish:packages
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

八、常见陷阱与最佳实践

8.1 陷阱清单

陷阱症状对策
fetch-depth: 1 无历史affected 计算错误、git diff 空设置 fetch-depth: 0 或 ref 指定 base
全量构建仍被触发缓存命中率低、时长未下降检查 paths 是否覆盖所有子项目目录
缓存键不含子系统前缀跨项目缓存污染、构建结果不一致按 {os}-{subsystem}-{tool}-{hash} 命名
发布 job 未复用 affected未变更应用也被发布发布层 if: needs.changes.outputs.* 守卫
paths-ignore 误伤必要 job关键 job 被跳过优先用 paths 正向列举 + paths-filter

8.2 一套可落地的 Monorepo CI 检查清单

  • checkout 使用 fetch-depth: 0
  • 变更检测独立成 job,输出变更矩阵
  • Nx/Turbo 用 affected/--filter 驱动任务
  • 缓存键包含子系统前缀与锁文件哈希
  • 构建/测试/发布三层均受 affected 守卫
  • 发布层配置 environment 与审批门禁
  • 远程缓存(Nx Cloud / Turbo Remote Cache)已启用

总结

Monorepo 的 CI 工程化本质上是「信息降噪」:用路径过滤砍掉无关触发,用依赖图收敛受影响范围,用缓存隔离消除重复开销,用 DAG 编排保证正确顺序。

维度关键手段收益
触发层paths + dorny/paths-filter无关 commit 不触发构建
任务层Nx affected / Turborepo --filter只执行受影响任务
缓存层子系统前缀键 + 远程缓存跨分支/跨 job 复用产物
编排层needs DAG + 矩阵展开并行最大化、依赖正确
发布层affected 守卫 + 环境门禁定向安全发布

一句话:在 Monorepo 中,能用「变更范围」解决的问题,就不要用「全量构建」来解决——路径过滤是门槛,依赖图是引擎,缓存隔离是燃料。


延伸阅读:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 移动端 CI/CD:Flutter/iOS/Android 构建与签名
  2. 环境保护与部署门禁:环境规则、审批与 CD 流程
  3. 工作流安全加固与供应链防御