Monorepo 变更感知 CI:Git、Turborepo 与方案比较

This article is extracted from the chat log with AI. Please identify it with caution.

说明:本文由 Codex 根据作者提供的主题与公开资料辅助生成,属于 AI 生成/整理内容,非作者原创。请读者自行甄别并交叉验证。资料核验于 2026-08-26。

一个 Monorepo 同时包含多个可独立部署的应用时,常见需求是:某次提交只修改了一个应用,就只部署这个应用;修改了共享包,则部署所有真正受到影响的应用。

这不是 Git、GitHub Actions 或 Turborepo 单独完成的。完整链路通常由三层协作:

层次提供的信息不负责什么
Git两个版本之间改了哪些文件不理解应用、包和部署单元
Monorepo 任务图工具文件属于哪个包、哪些包依赖它、哪些任务受影响不管理云端权限和部署审批
CI 平台提供事件、Runner、Job、Secret、Environment 和部署状态默认不理解工作区依赖图

一句话概括:Git 提供变更事实,Turborepo 一类工具计算影响范围,GitHub Actions 一类 CI 平台执行部署。

1. “Changed”与“Affected”不是一回事#

假设仓库包含三个 Workspace:

apps/
  checkout-web/
  admin-web/
packages/
  shared-ui/

其中 checkout-web 依赖 shared-uiadmin-web 不依赖它。

  • 修改 apps/checkout-web/**:直接变化的包和受影响的应用都是 checkout-web
  • 修改 packages/shared-ui/**:直接变化的包是 shared-ui,受影响的应用还包括 checkout-web
  • admin-web 没有依赖这两个包,不应被部署。

如果只运行:

git diff --name-only <base> <head>

Git 可以列出变化文件,但不会回答“哪个应用依赖 shared-ui”。这部分必须由声明过的 Workspace 依赖图或人工映射补上。Git git diff 文档

2. Turborepo 如何计算受影响任务#

Turborepo 同时使用:

  1. Git 历史:找出基线之后发生变化的文件。
  2. Package Graph:根据 Workspace 的 package.json 依赖关系连接各个包。
  3. Task Graph:根据 turbo.json 中的任务和 dependsOn 推导任务关系。

一个适合机器读取的通用命令是:

turbo run deploy --filter="...[<base-sha>]" --dry=json

各部分含义如下:

  • turbo run deploy:规划名为 deploy 的任务。
  • [<base-sha>]:选择从指定 Git 基线到当前工作树发生变化的包。
  • 前缀 ...:把这些变化包的反向依赖者一起纳入。例如共享包变化时,依赖它的应用也会进入计划。
  • --dry=json:不执行任务,只输出机器可读的任务计划;JSON 包含 packagetaskdependenciesdependents 等字段。

Turborepo 还提供更简洁的写法:

turbo run build lint test --affected

官方文档说明,--affected 默认等价于基于 main...HEAD 的 affected 过滤,并包含依赖变化包的下游包。它适合 PR 检查;如果是在默认分支收到 push 后做持续部署,应显式提供可靠的 base/head,避免默认分支引用已经指向当前 HEAD。Turborepo run 参考 Turborepo CI 指南

为什么还会有一层自定义脚本#

Turborepo 可以直接执行受影响任务,但真实部署通常还需要一层很薄的适配器:

Git event
  -> resolve base/head
  -> Turborepo dry-run plan
  -> retain deployable applications
  -> validate an allowlist
  -> emit CI output or matrix
  -> deploy exact applications

这层脚本不是重新实现 Git diff 或依赖图,而是负责产品无关的编排策略,例如:

  • 什么样的 Package 才是可部署应用。
  • 没有受影响应用时是否跳过 Deploy Job。
  • 生产环境无法计算 diff 时应停止还是全量部署。
  • 如何把包列表转换为 CI Job output 或 matrix。
  • 如何保证每个部署子进程只得到自己的配置。

一个简化的解析示例:

const plan = JSON.parse(turboOutput);

const applications = [
  ...new Set(
    plan.tasks
      .filter((task) => task.task === "deploy")
      .map((task) => task.package)
      .filter((name) => deployableApplications.has(name))
  ),
];

生产实现还应验证 JSON Schema、拒绝未知包名,并对空列表和异常路径编写测试。

3. GitHub Actions 在其中负责什么#

GitHub 的 push 事件可以提供 push 之前的 SHA;Workflow 可以把它作为 Git 比较基线。GitHub Actions 还可以把一个 Job 产生的 JSON output 转成后续 Job 的动态 matrix。GitHub PushEvent GitHub Actions 动态 matrix

简化后的 Workflow 结构如下:

jobs:
  select:
    runs-on: ubuntu-latest
    outputs:
      applications: ${{ steps.plan.outputs.applications }}
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - id: plan
        env:
          BASE_SHA: ${{ github.event.before }}
        run: node scripts/select-affected-apps.mjs

  deploy:
    needs: select
    if: needs.select.outputs.applications != '[]'
    strategy:
      matrix:
        application: ${{ fromJSON(needs.select.outputs.applications) }}
    steps:
      - run: pnpm --filter "${{ matrix.application }}" run deploy

需要注意:

  • fetch-depth: 0 很重要。Turborepo 官方说明,Git 历史过浅时无法正确比较,可能把所有包视为已变化。
  • 选择阶段通常不需要生产 Secret。先计算白名单,再让真正的 Deploy Job 绑定 Environment 和最小权限。
  • Matrix 适合每个应用需要独立失败、重试和权限的场景;如果多个应用必须作为一个批次串行发布,也可以输出逗号列表交给单个 Job。

4. 最容易忽略的基线问题#

“与哪个 SHA 比较”比具体命令更重要。

4.1 PR 检查#

PR 通常关心“当前分支相对目标分支新增了什么”,适合使用 merge base、--affected,或者显式的 base/head。

4.2 默认分支持续部署#

直接使用 push 事件的 previous SHA,只能覆盖本次 push:

previous push SHA -> current SHA

它有一个经典漏洞:

  1. 提交 A 修改应用甲,但部署失败。
  2. 提交 B 只修改应用乙。
  3. B 若只与 A 比较,就只会部署应用乙。
  4. 应用甲仍未上线,却不会自动重试。

更稳健的生产基线是:

last successful deployment SHA -> current SHA

Nx 的 CI 文档也建议把 base 设置为默认分支最近一次成功 CI 的提交,这样失败运行中的变化不会从后续 affected 集合中丢失。Nx Affected

如果暂时无法可靠保存“最近成功 SHA”,需要明确选择一种失败策略:

  • 保守部署:无法计算时全量部署,成本更高但不漏应用。
  • Fail closed:生产环境停止并要求人工重试,适合部署带有高风险副作用的系统。
  • 显式重放:失败 Job 保存应用列表,下一次运行先合并未完成集合。

不要静默返回空列表;它会把“计算失败”伪装成“没有变化”。

5. 其他方案#

5.1 GitHub Actions paths#

on:
  push:
    paths:
      - "apps/checkout-web/**"
      - "packages/shared-ui/**"

优点是无额外工具、容易理解。缺点是每个应用通常需要一套 Workflow 或重复的路径映射;共享包与下游应用的关系也要人工维护。

GitHub 会用 changed files 判断是否触发整个 Workflow,但这不是 Workspace 依赖图。官方还记录了特殊边界:超大 push、diff 超时和 changed-files 数量上限会改变过滤行为;必需检查被路径过滤跳过时还可能保持 Pending。GitHub Actions 路径过滤

适用场景:应用很少、彼此独立、共享目录关系稳定。

5.2 原生 Git + 自定义映射#

git diff --name-only -z "$BASE_SHA" "$HEAD_SHA"

然后将 apps/foo/** 映射到 foo,将 packages/ui/** 映射到所有 UI 消费者。

优点是跨语言、完全可控、没有 Monorepo 工具依赖。缺点是依赖关系需要自己维护;重命名、锁文件、根配置和共享脚本的影响规则也需要自己测试。处理文件名时应使用 -z,避免空格和特殊字符破坏 shell 解析。

适用场景:异构仓库、包数量少,或者已有权威的服务目录和依赖清单。

5.3 pnpm Workspace Filter#

pnpm 自身也支持 Git ref 和依赖方向:

pnpm --filter "...[origin/main]" run test

[origin/main] 选择变化的 Workspace,前缀 ... 加入其 dependents。它比手写路径映射更懂 Workspace 依赖,又比完整任务图工具轻。pnpm Filtering

适用场景:纯 JavaScript/TypeScript Monorepo,只需要按包执行命令,对跨任务缓存和复杂 Task Graph 没有需求。

5.4 Turborepo#

优势包括 Package Graph、Task Graph、并行执行、缓存、Git filter,以及 --dry=json 机器输出。对已经使用 pnpm/npm/Yarn Workspace 的前端 Monorepo,通常是成本和能力之间较平衡的方案。

主要约束是依赖必须正确声明。应用实际导入共享包、却没有在 package.json 中声明 Workspace 依赖时,任何基于 Package Graph 的 affected 计算都可能漏掉下游应用。

适用场景:JavaScript/TypeScript Monorepo,需要统一测试、构建和部署任务。

5.5 Nx Affected#

Nx 同样组合 Git 历史与 Project Graph,并提供:

nx affected -t build --base=<base> --head=<head>

它的项目建模、插件体系、图可视化、锁文件变化策略和分布式 CI 能力更完整,相应的配置面也更大。Nx Affected

适用场景:项目图复杂、团队需要可视化治理、分布式执行或更强的 Monorepo 约束。

5.6 Bazel#

Bazel 把源文件、规则和 Target 建模为更细粒度的构建图。其 Query Language 可以通过 rdeps(universe, changed_targets) 查询反向依赖闭包,再结合可复现构建和远程缓存执行受影响 Target。Bazel Query Reference

它适合大型、多语言、对可复现性和增量构建要求极高的仓库,但 BUILD 规则、工具链和迁移成本明显高于 Turborepo 或 Nx。

6. 方案比较#

方案Git 变更依赖图任务图/缓存维护成本典型规模
GitHub paths内置低到中,应用越多重复越多少量独立应用
Git + 自定义映射原生自己维护自己维护中到高小型异构仓库
pnpm Filter内置Workspace Graph较弱JS/TS 小中型 Monorepo
Turborepo内置Package GraphJS/TS 中大型 Monorepo
Nx Affected内置Project Graph有,治理能力更强中到高复杂中大型 Monorepo
Bazel需接入变更到 Target 映射Target Graph很强大型、多语言仓库

7. 推荐决策#

  • 两三个互不依赖的应用:先用 GitHub paths,不要过早引入任务图工具。
  • 已经使用 pnpm Workspace,只需执行变化包命令:优先评估 pnpm Filter。
  • 已经使用 Turborepo:优先使用 turbo run ... --affected;需要把计划交给后续 Job 时,使用 --dry=json 加薄适配器。
  • 需要显式 Project Graph、插件治理和分布式 CI:评估 Nx。
  • 多语言巨型仓库、强可复现构建:评估 Bazel。

无论选择哪种工具,生产级实现至少应满足:

  1. 明确 base/head 的语义,并优先记录最近一次成功部署 SHA。
  2. CI Checkout 提供足够的 Git 历史。
  3. 依赖关系进入版本控制,不靠隐藏约定。
  4. 选择结果经过可部署应用白名单校验。
  5. “无变化”“计算失败”“首次运行”是三个不同状态。
  6. 选择阶段不接触部署 Secret。
  7. 为单应用变化、共享包变化、锁文件变化、失败后重试和全量 fallback 编写测试。

参考资料#

本文共 3726 字,创建于 Aug 26, 2026

相关标签: DevOps, Frontend, Tools, Git, ByAI