当几十个独立应用与共享库共存于同一个仓库时,最直观的 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: paths | dorny/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]
| 维度 | Nx | Turborepo |
|---|---|---|
| 依赖图来源 | 显式 project.json + 代码分析 | 包管理器 workspace 依赖 |
| affected 命令 | nx affected --target=... | turbo run ... --filter=... |
| 远程缓存 | Nx Cloud(付费/自托管) | Turbo Remote Cache |
| 任务缓存 | 按输入哈希缓存 | 按输入哈希缓存 |
| 适用生态 | 前端 + 全栈大型 Monorepo | pnpm/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 缓存优化完全指南 — monorepo 多语言缓存隔离
- GitHub Actions 矩阵构建策略 — 动态矩阵与并发控制
- GitHub Actions 可复用工作流 — 跨项目复用 CI 模板
- Monorepo 工程化最佳实践 — pnpm workspace 与 Turborepo 选型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。