单仓(monorepo)的好处是共享代码、统一工具链、一次提交可原子地跨越多个包;坏处是这份「原子性」直接砸在 CI 上——如果每次提交都触发全仓构建与测试,一个 80 包的仓库会让每个 PR 都排队 40 分钟,开发者很快学会「先合了再说」。
单仓 CI 要回答的核心问题只有一个:这次改动到底影响了哪些包,哪些任务可以跳过。把这个问题答对,流水线才能从「全量」收缩成「增量」,CI 时间才会与改动规模挂钩,而不是与仓库规模挂钩。本文讲透受影响分析的原理与一整套可落地的单仓 CI 策略。
1. 单仓 CI 的核心矛盾
1.1 规模带来的三重放大
仓库规模放大三重成本:
1. 任务数量放大:N 个包 × M 个任务(build/test/lint)= N×M 个执行单元
2. 依赖深度放大:底层工具包一变,所有下游包全部失效
3. 队列放大:任务多了,runner 不够,等待时间超过执行时间
很多团队的第一反应是「加 runner」。但如果任务集合本身没有收缩,加 runner 只是把「执行时间」换成「排队时间 + 更高的账单」。
1.2 全量 vs 增量
| 策略 | 触发范围 | CI 时长特征 | 问题 |
|---|---|---|---|
| 全量 | 每次提交跑所有包 | 恒定,随仓库规模增长 | 时间税,开发者绕开 |
| 路径过滤 | 按目录规则粗筛 | 依赖目录结构 | 漏跑(跨包依赖未覆盖) |
| 受影响分析 | 按依赖图精确计算 | 随改动规模增长 | 需要可靠的依赖图 |
路径过滤(paths: / only: changes:)是很多团队的过渡方案,但它只认文件路径,不认依赖关系。一旦 libs/utils 被改,所有 import 它的包都该重跑,而路径过滤看不到这层关系——这是漏跑的经典来源。
1.3 依赖图是唯一可靠的依据
正确的做法是:构建一张包级依赖图,把「改动的文件」映射到「受影响的包集合」,再据此生成任务集合。这张图的来源有两种:
- 显式声明:每个包在清单文件里声明自己的依赖(
package.json的dependencies、go.mod的require、pom.xml的<dependency>)。 - 静态推断:扫描源码 import、模块引用,推断出包与包的边。
生产级方案通常两者结合:以清单声明为主,用静态分析补上隐式依赖。
2. 受影响分析的原理
2.1 从改动到受影响集合
输入:git diff 得到的改动文件列表
输出:需要重跑的任务集合
步骤:
1. 把改动文件归属到包(文件路径 → 包)
2. 在依赖图上做「反向可达」:找出所有(直接或间接)依赖这些包的包
3. 把这些包的对应任务加入待跑集合
4. 叠加「始终要跑」的任务(如全局 lint、契约测试)
反向可达是关键:依赖是「A 依赖 B」,那么 B 变了,A 就要重跑。如果只在正向方向遍历,会漏掉所有下游。
2.2 基线的选择
受影响分析必须有一个比较基线:相对谁算「改动」。
# 相对目标分支的合并基(merge base)——最常用
BASE=$(git merge-base HEAD origin/main)
# 相对 PR 的目标分支
nx affected -t build --base=origin/main --head=HEAD
# Turborepo 的等价写法
turbo run build --filter='...[origin/main]'
基线选择要点:
- 用 merge-base 而非分支 tip:分支 tip 会被别人后来的提交污染
- 合并队列里要用「队列前一个候选」作为基线,而非 main
- 主分支上的直接提交(hotfix)通常退化为全量,属预期
2.3 传递式失效
依赖图是有向无环图(DAG),改动的影响沿边传递。这带来一个常见现象:改一个底层包,全仓重算。
libs/design-system 被 30 个包依赖
→ 改动它 → 30 个包 + 它们的下游全部失效
→ 一次提交触发几乎全量 CI
缓解手段:
1. 把高频变动的代码上移为「叶子包」,减少依赖扇出
2. 对底层包引入版本化发布,下游按版本依赖而非源码依赖
3. 把底层包拆成更细粒度,避免「一处改动、全体重算」
3. 任务图与流水线编排
3.1 从包图到任务图
包级依赖图只说明「包与包」的关系,任务图还要表达「任务与任务」的依赖。两者叠加才构成可执行的 DAG。
包图: web → ui → tokens
任务图(每个包展开为 build/test/lint):
tokens#build → ui#build → web#build
web#build → web#test → web#e2e
^build 语义:先跑所有依赖包的 build,再跑本包 build
3.2 编排引擎的两种形态
| 形态 | 代表 | 特点 |
|---|---|---|
| CI 原生 DAG | GitHub Actions needs、GitLab needs | 表达力弱,任务多时 YAML 爆炸 |
| 外部编排器 | Nx、Turborepo、Bazel | 任务图由工具计算,CI 只负责触发 |
单仓规模一旦超过 ~15 个包,用 CI 原生 YAML 手写 DAG 会迅速失控:每加一个包就要改 YAML,任务间的依赖要手工维护。这时应该把任务图的职责交给编排器,CI 只做三件事:拉代码、跑编排器命令、上传结果。
# .github/workflows/ci.yml — CI 只做触发与汇总
name: ci
on:
pull_request:
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 受影响分析需要完整历史
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Run affected tasks
run: npx nx affected -t lint test build --base=origin/main --head=HEAD
fetch-depth: 0 是必须的:受影响分析要计算 merge-base,浅克隆(默认 depth=1)会导致基线缺失,退化成全量或直接报错。这一点与 GitHub Actions 单仓实践
的结论一致。
3.3 分级流水线
并非所有任务都值得在 PR 上跑。按「反馈速度」分级能显著降低平均等待。
L1(秒级,PR 必跑):lint、格式检查、类型检查、受影响包的单元测试
L2(分钟级,PR 必跑):受影响包的集成测试、构建产物
L3(十分钟级,合并后或 nightly):全量 E2E、跨包契约测试、性能基准
L4(小时级,定时):全量回归、安全扫描、依赖审计
把 L3/L4 从 PR 路径移走,PR 的 CI 时间会立刻下降一大截,而回归覆盖由主分支与定时任务兜底。
4. 并行度与资源分配
4.1 任务图的并行展开
编排器会把 DAG 中「所有前置已完成」的任务同时启动,受限于 --parallel 或 runner 并发数。
# Nx:限制并行任务数,避免打爆 runner
nx affected -t build --parallel=4
# Turborepo:设置并发度
turbo run build --concurrency=50%
并行的两个天花板:
1. 图结构:依赖链越长,可并行的宽度越小(关键路径决定下限)
2. 资源:runner 数量、CPU/内存、外部服务(如数据库)连接数
关键路径(critical path)是并行的下限:无论加多少 runner,一条 tokens → ui → web 的构建链都得串行跑完。压缩关键路径的办法是拆包、减少层数,而不是加机器。
4.2 大仓库的分片
当 affected 集合本身就很大(如发布分支的全量构建),可以把任务集切成多份分片,分发到多台机器。
# GitHub Actions 矩阵分片
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx nx affected -t test --base=origin/main --shard=${{ matrix.shard }}/4
分片粒度选择:
按包分片 → 天然均衡,但包大小不均时会倾斜
按任务分片 → 更均衡,但同一包的任务可能跨机器,制品传递变复杂
动态分片 → 由工具根据历史耗时分配(Nx DTE、Bazel 远程执行)
4.3 资源争用与隔离
单仓里多个任务并行时,最容易踩的是共享资源冲突:
| 共享资源 | 冲突表现 | 对策 |
|---|---|---|
| 本地端口 | 两个 E2E 抢 3000 端口 | 动态分配端口 |
| 测试数据库 | 互相污染数据 | 每任务独立 schema 或容器 |
| 构建缓存目录 | 并发写损坏 | 每任务独立 cache 目录 |
| 外部 API 配额 | 触发限流 | 用 mock 或共享 fixture |
5. 制品在包之间的传递
5.1 为什么不能重复构建
单仓的天然优势是「一次构建、多处复用」:libs/ui 构建一次,所有依赖它的包直接用产物,而不是各自重编译一遍源码。
反模式:每个下游任务都重新编译依赖包的源码
→ 同一个包被编译 N 次,缓存命中率再高也救不回
正模式:依赖包的构建产物作为「输入」传递给下游任务
→ 依赖包命中缓存 → 下游拿到确定性产物 → 下游也可命中缓存
5.2 制品的存放与寻址
# 方式一:工作区内直接共享(同机、同工作区)
# 编排器约定产物目录(dist/),下游从相对路径读取
# 方式二:CI 制品上传下载(跨 job/跨机器)
- uses: actions/upload-artifact@v4
with:
name: libs-ui-dist
path: libs/ui/dist
retention-days: 1
- uses: actions/download-artifact@v4
with: { name: libs-ui-dist, path: libs/ui/dist }
# 方式三:包管理器本地链接(pnpm workspace / npm workspaces)
# 下游通过 workspace: 协议引用,无需发布
跨 job 传递制品时,注意 upload-artifact 的默认压缩与保留策略,大产物会拖慢流水线。更细的制品管理与溯源可参考 制品管理与供应链溯源
中的做法。
5.3 缓存与制品的关系
缓存(cache):按输入哈希复用「任务输出」,是优化手段,命中与否不影响正确性
制品(artifact):构建的「正式产物」,要被打包、发布、部署,是交付物
区别:缓存可以随时清空重建,制品必须可追溯、可复现、有版本
6. 合并队列与主干保护
6.1 单仓为什么更需要合并队列
单仓的提交密度高,两个 PR 各自「基于旧 main 试跑绿」,先后合入后极易冲突或语义冲突(比如都改了同一个公共类型)。合并队列(Merge Queue)把待合并的 PR 串成一个序列,每个候选都在「最新 main + 前面已合入的改动」之上重新验证。
合并队列的行为:
1. PR 进入队列后,用「队列中前一个候选的结果」作为新基线
2. 在最新基线上重跑受影响分析 + 受影响任务
3. 通过则合入,失败则弹出并通知作者
单仓收益:主干始终保持「可发布」状态,避免「合完就红」
6.2 受影响分析在队列中的基线
这是最容易配错的地方:
错误做法:队列中的候选仍用 origin/main 作为基线
→ 看不到「前面已合入候选」带来的影响 → 漏跑
正确做法:用「队列中前一个候选的合并结果」作为基线
→ 每个候选都相对真实的上游状态计算影响
GitHub Merge Queue 会为每个候选构造一个临时的合并分支(gh-readonly-queue/...),CI 应基于该分支计算 diff,而不是基于 main。
6.3 主干保护规则
# 分支保护要点
- 要求 PR 通过「必需检查」(required checks)
- 必需检查应覆盖:受影响任务的聚合结果 + 全局门禁
- 禁止直接推送 main(含管理员)
- 要求分支最新(require branches up to date)或启用合并队列
7. 分阶段落地与常见坑
7.1 落地路径
阶段一:建立依赖图
- 统一包清单格式,确保依赖声明完整
- 用静态分析补齐隐式依赖,输出可视化图
阶段二:引入受影响分析
- CI 中改为 nx affected / turbo --filter
- fetch-depth: 0,正确计算 merge-base
阶段三:分级与编排
- L1/L2 进 PR,L3/L4 移到主分支与定时
- 用编排器替代手写 YAML DAG
阶段四:合并队列
- 启用合并队列,修正基线为「队列前一个候选」
阶段五:度量与收敛
- 上报命中率、CI 时长、漏跑率
7.2 度量指标
必看指标:
- CI 平均时长(P50/P90)
- 受影响任务命中率(affected 集合 / 全量集合)
- 漏跑率(合并后主分支失败的 PR 占比)
- 缓存命中率
- 排队等待时长 vs 执行时长
漏跑率是最关键的质量指标:如果为了提速把该跑的任务跳过了,省下的时间会以「主分支红灯 + 回滚」的形式加倍还回来。
7.3 常见坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 浅克隆 | 基线缺失,退化为全量 | fetch-depth: 0 |
| 依赖声明不全 | 漏跑下游 | 静态分析补边 + 定期全量兜底 |
| 基线用分支 tip | 受影响集合虚高 | 用 merge-base |
| 队列基线错误 | 队列里漏跑 | 用队列前一个候选作基线 |
| 无漏跑监控 | 提速但质量崩 | 监控主分支失败率 |
| 并行无上限 | runner 被打爆 | --parallel / --concurrency 限流 |
| 制品跨机丢失 | 下游找不到产物 | 显式上传下载或统一工作区 |
7.4 与构建缓存的分工
受影响分析负责「决定跑不跑」,构建缓存负责「跑了也能秒回」。两者是正交的优化:即便受影响集合算得准,如果缓存没配好,跑起来依然慢。缓存键与远程缓存的设计可参考 CI/CD 性能优化与构建缓存 的完整拆解。
小结
单仓 CI 的本质是用依赖图把「全量」收缩成「增量」。做到这一点需要四件事配合:一张可靠的包级依赖图、正确的基线(merge-base 或队列前一个候选)、一个把任务图交给编排器而非手写 YAML 的流水线,以及把长尾任务(E2E、回归、安全扫描)从 PR 路径移走的分级策略。
落地时先建图、再上受影响分析、最后接合并队列,每一步都要盯住「漏跑率」这个质量指标——单仓 CI 的目标不是「最快」,而是「既快又不漏」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。