跳至正文
Tooling — Monorepo 变更感知 CI:Git、Turborepo 与方案比较

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

AI 参与说明(Agent:Codex):本文由 Codex 根据作者提供的主题与公开资料辅助整理。实现示例适用于以 JavaScript/TypeScript 为主、使用 package manager workspace 与 GitHub Actions 的 Monorepo;多语言部分只讨论选型边界,落地前还需验证对应 Plugin 与 Project Graph。受监管发布流程和自建 CI 需要重新评估。资料核验于 2026-08-26,核验范围包括 Turborepo 2.10.x(turbo query 自 2.9 起成为 Stable 接口)与 Nx v23 官方文档。

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

当前推荐:默认使用 Turborepo,满足条件时直接选择 Nx

本文的默认前提是:纯 JavaScript/TypeScript Monorepo 已使用 pnpm/npm/Yarn workspace,存在共享 package,并且确实需要 Task Graph、缓存与 Affected 传播。此时当前推荐方案是 Turborepo + 薄适配器 + GitHub Actions 动态 matrix:以最近一次整批成功部署的 SHA 为 base,查询受影响任务,经过白名单校验后按应用部署。

Nx 可以替换 Turborepo 的 Monorepo 任务编排层,而且按能力广度衡量,Nx 更强。但它不能替换 Git、package manager 或 GitHub Actions,也不是把 turbo 命令机械改名为 nx 的 drop-in replacement。

如果仓库已经稳定使用 Turborepo,仅为了“Nx 功能更多”而迁移通常没有足够收益。若从零开始且已明确需要成熟的多语言 Project Graph、插件推断、代码生成、架构约束或 Nx Agents,则直接选择 Nx。具体命令见第 2 节,完整选型顺序见第 8 节。

当前仓库状况当前推荐
尚未选型的纯 JavaScript/TypeScript workspace,核心需求是 Affected CI、Task Graph 与缓存选择 Turborepo
已稳定使用 Turborepo 或 Nx,且暂时没有明确的能力缺口保持现状,不迁移
从零开始,且已确认需要成熟的多语言建模、Plugins、Generators、架构治理或 Nx Agents直接选择 Nx

因此,本文后续的主实现采用 Turborepo;Nx 是满足明确升级条件时的替代方案,不是无条件的默认答案。

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

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

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

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

假设仓库包含三个 Workspace:

text
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 没有依赖这两个包,不应被部署。

如果只运行:

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

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

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

Turborepo 同时使用:

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

当前更适合 CI 选择阶段的机器接口是:

bash
pnpm exec turbo query affected \
  --tasks deploy \
  --base="$BASE_SHA" \
  --head="$HEAD_SHA"

turbo query 从 Turborepo 2.9 起成为 Stable 接口;旧版本应先评估升级,或继续使用后文的 turbo run ... --dry=jsonTurborepo 2.9

各部分含义如下:

  • --tasks deploy:只返回名为 deploy 的受影响任务。
  • --base--head:显式固定 Git 比较范围,避免 CI 环境中的默认分支引用产生歧义。
  • JSON 中的 affectedTasks.itemslength:分别提供任务列表与数量;每个结果还包含 reason,便于区分文件变化、依赖变化和保守回退。

turbo query affected 默认按 task inputs 做任务级判断:任务自身输入变化,或其上游任务受影响时,该任务才进入结果。Package Graph、Task Graph 与 inputs 必须正确声明,否则再精确的查询命令也无法修复错误的依赖模型。Turborepo query 参考

例如,让应用的 deploy 依赖自身 build,再让 build 依赖上游 Package 的 build,共享包变化才会沿 Task Graph 传播到应用的 deploy。部署具有外部副作用,因此显式关闭缓存:

json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "deploy": {
      "dependsOn": ["build"],
      "cache": false
    }
  }
}

每个可部署应用还需要在自己的 package.json 中声明 deploy script。outputs 应替换为实际构建工具的输出目录;这里的 dist/** 只是通用示例。

如果选择阶段还需要完整的 Task Graph、任务依赖、输入输出与缓存 hash,则继续使用执行命令的 dry run:

bash
TURBO_SCM_BASE="$BASE_SHA" \
TURBO_SCM_HEAD="$HEAD_SHA" \
pnpm exec turbo run deploy --affected --dry=json

两者的分工是:turbo query affected 回答“哪些任务受影响以及为什么”,turbo run ... --dry=json 回答“如果运行,这份完整任务计划是什么”。

Turborepo 还提供更简洁的写法:

bash
pnpm exec turbo run build lint test --affected

官方文档说明,--affected 默认等价于基于 main...HEAD 的 affected 过滤,并包含依赖变化包的下游包。它默认按 package 判断;Turborepo 2.10.x 只有启用 futureFlags.affectedUsingTaskInputs 后,才改用 task inputs。它只适合基线确实是 main 的 PR;目标分支不是 main 时,应显式设置 TURBO_SCM_BASETURBO_SCM_HEAD,或者使用支持 --base--head 参数、默认任务级判断的 turbo query affected。默认分支 push 的持续部署也应显式提供可靠范围。Turborepo run 参考 Turborepo CI 指南

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

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

text
Git event
  -> resolve base/head
  -> Turborepo affected query
  -> retain deployable applications
  -> validate an allowlist
  -> emit CI output or matrix
  -> deploy exact applications

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

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

一个简化的解析示例:

js
const result = JSON.parse(turboOutput);

const applications = [
  ...new Set(
    result.data.affectedTasks.items
      .map((task) => task.package.name)
      .filter((name) => deployableApplications.has(name))
  ),
];

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

当 Git ref 不存在或 source control 查询失败时,Turborepo 会保守地把全部视为 affected。任务级查询只负责生成部署集合;选择脚本若还要区分“真实全量变化”与“保守回退”,应另外执行 Package 级诊断:

bash
pnpm exec turbo query affected \
  --packages \
  --base="$BASE_SHA" \
  --head="$HEAD_SHA"

然后检查 data.affectedPackages.items[].reason.__typename 中是否存在 GitRefNotFoundScmError 等 repository-wide reasons,再执行预先声明的“全量部署”或“fail closed”策略,而不是把诊断失败当作普通变更或空列表。

3. GitHub Actions 在其中负责什么

GitHub 的 push 事件可以提供 push 之前的 SHA,它适合只比较本次 push;生产持续部署更稳健的基线仍是最近一次成功部署的 SHA。GitHub Actions 还可以把一个 Job 产生的 JSON output 转成后续 Job 的动态 matrix。GitHub PushEvent GitHub Actions 动态 matrix

下面给出 selectdeploy 两个核心 Job 的结构。示例假设根 package.jsonpackageManager 字段已经锁定 pnpm 10 的精确版本,并使用 Node.js 24;如果仓库版本不同,应同步替换 Runner 配置。最终推进成功 SHA 的 Job 取决于状态存储后端,因此不在这个片段中伪造通用实现。

yaml
jobs:
  select:
    runs-on: ubuntu-latest
    outputs:
      applications: ${{ steps.plan.outputs.applications }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - id: plan
        env:
          # 示例占位:生产环境应从可靠的部署状态存储读取并在部署成功后更新
          BASE_SHA: ${{ vars.LAST_SUCCESSFUL_DEPLOY_SHA }}
          HEAD_SHA: ${{ github.sha }}
        run: node scripts/select-affected-apps.mjs

  deploy:
    needs: select
    if: needs.select.outputs.applications != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        application: ${{ fromJSON(needs.select.outputs.applications) }}
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec turbo run deploy --filter="${{ matrix.application }}"

需要注意:

  • pnpm/action-setuppackageManager 读取 pnpm 版本;应提交 lockfile,并在两个 Job 中使用 --frozen-lockfile,避免选择阶段与部署阶段得到不同依赖图。
  • 还需要一个依赖 selectdeployrecord-success Job:select 必须成功,deploy 则必须成功,或因应用列表为空而按预期跳过;满足条件后再把本次 HEAD_SHA 原子写入可靠的状态存储。否则 LAST_SUCCESSFUL_DEPLOY_SHA 会一直停留在旧值。
  • Deploy Job 继续通过 turbo run deploy 执行,才能兑现前文的 deploy -> build -> ^build Task Graph 并复用构建缓存。若改用 pnpm --filter ... run deploy,则 deploy script 必须自行完成构建,且不会经过 Turborepo 的任务编排与缓存。
  • 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:

text
previous push SHA -> current SHA

它有一个经典漏洞:

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

更稳健的生产基线是:

text
last successful deployment SHA -> current SHA

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

本文默认采用一个“整批成功 SHA”:只有选择 Job 和全部 deploy matrix job 都成功后,最终 Job 才推进这个标记。这样不会漏掉失败应用,但下一次运行可能重复部署上次已经成功的应用,因此部署操作应保持幂等。若每个应用必须独立推进版本,则应保存 per-application SHA 或未完成应用集合;这会换取更高的状态管理复杂度。

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

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

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

5. 方案选项与适用场景

5.1 GitHub Actions paths

yaml
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 + 自定义映射

bash
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 和依赖方向:

bash
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、并行执行、本地缓存、Remote Cache、Git filter,以及两类机器接口:turbo query affected 输出受影响任务与原因,turbo run ... --dry=json 输出完整任务计划。Turborepo 的 Remote Cache 既有托管服务,也公开了 API 供自建实现。Turborepo Remote Caching

Turborepo 也提供 turbo generate,并正在实验 turbo boundaries。因此“更聚焦”不等于“完全没有代码生成或边界检查”;与 Nx 的差异主要在能力覆盖、集成深度和成熟度,而不是简单的有无。Turborepo generate Turborepo boundaries

Turborepo 2.10.x 还提供 Experimental 的原生 Cargo/Rust 与 uv/Python workspace 支持,因此不能简单称它为“只支持 JavaScript/TypeScript”。不过,当仓库要求成熟、覆盖面更广的 polyglot integration,尤其是 first-party Maven、Gradle 与 .NET Plugins 时,Nx 仍有更明确的优势。Turborepo Rust 指南 Turborepo Python 指南 Nx Multi-Language Support

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

适用场景:JavaScript/TypeScript Monorepo,需要统一运行、缓存和筛选测试、构建及部署任务,但暂时不需要完整的代码生成、架构约束与跨 Runner 自动分发体系。这也是本文默认前提下的当前推荐。

5.5 Nx

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

bash
pnpm exec nx affected -t build --base="$BASE_SHA" --head="$HEAD_SHA"

Nx 提供 Project Graph、Task Graph、本地缓存、Affected、Plugins、Generators 和交互式图等能力。Remote Cache 可以通过 Nx Cloud 等后端共享;Nx Agents 跨机器任务分发则要求 workspace 连接 Nx Cloud。Nx Agents 会根据任务依赖和历史耗时在多台机器间分配任务,不能把“本地使用 Nx”与“已经拥有分布式 CI”混为一谈。Nx Affected Nx Remote Caching Nx Agents

适用场景:Project Graph 复杂,需要插件推断、代码生成、Migrations、module boundary rules、Release Management、多语言建模,或者确实需要 Nx Cloud 分布式执行的 Monorepo。

5.6 Bazel

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

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

6. Nx 能否替换 Turborepo

可以替换,但不是 drop-in replacement。两者在本文架构中处于同一层:都能负责任务编排、Affected 计算和缓存。切换到 Nx 后,Git 仍负责变更事实,pnpm/npm/Yarn 仍负责 workspace 与依赖安装,GitHub Actions 仍负责 Job、Secret、Environment 与部署。

Nx v23 的官方迁移文档说明,npx nx@latest init 可以识别现有 turbo.json、生成 nx.json,并让两个工具在迁移期间并存;但按 Package 覆盖的任务、persistent、环境变量、Inputs 与其他配置语义仍需要逐项核对。只有 Nx CI 验证通过后,才应删除 Turborepo 配置和依赖。Nx:Migrating from Turborepo to Nx

能力轴TurborepoNx判断
核心任务编排Package Graph + Task GraphProject Graph + Task Graph两者都能满足常规 JS/TS CI
Affected 自动化turbo query affected 可按 task inputs 查询并输出原因;dry run 可输出完整计划nx affected 结合 Git 与 Project Graph,并可用 nx graph --affected 可视化精度取决于依赖和 inputs 建模,不应只按品牌判断
项目建模主要依赖 package manager 建立的 workspace 关系可通过 Plugins、项目配置和工具配置扩展 Project GraphNx 的建模面更广
本地与远程缓存本地缓存;托管或自建 Remote Cache本地缓存;可连接 Nx Cloud 等 Remote Cache 后端两者都具备,部署副作用都不应缓存
跨 Runner 任务分发通常由 CI 自行分片和维护Nx Cloud 的 Nx Agents 可按任务图和历史耗时分发Nx 更强,但依赖 Nx Cloud
工程治理有代码生成与实验性 Boundaries,整体仍聚焦任务运行与缓存Plugins、Generators、Migrations、module boundary rules、Release Management 等集成更深Nx 的能力面更广
引入与心智负担对现有 JS/TS workspace 更聚焦可渐进引入,但可选能力和升级面更多只需任务运行与缓存时,Turborepo 更直接

所以,“哪个更强”必须先定义强的含义:

  • 按产品能力广度、项目治理和分布式 CI 上限衡量,Nx 更强
  • 按纯 JavaScript/TypeScript 仓库完成 Affected 构建与部署的最小必要复杂度衡量,Turborepo 通常更合适
  • 对已经稳定运行的 Turborepo 仓库,只有在 Plugins、架构约束、多语言 Project Graph 或 Nx Agents 等能力能解决实际问题时,迁移到 Nx 才有明确价值。

替换工具也不会自动解决错误的 base/head、浅克隆、漏声明依赖、失败后未重放或部署步骤被错误缓存等生产问题。两者都需要处理这些风险,但依赖发现方式与 inputs、lockfile 的失效规则并不完全相同,迁移时必须重新验证。

7. 方案比较

方案Git 变更依赖图任务图/缓存维护成本主要选择条件
GitHub paths内置低到中,应用越多重复越多少量独立应用
Git + 自定义映射原生自己维护自己维护中到高小型异构仓库
pnpm Filter内置Workspace Graph较弱只需按变化 Package 执行命令
Turborepo内置Package GraphJS/TS,聚焦运行、缓存与 Affected
Nx内置Project Graph有,治理能力更强中到高需要插件治理、多语言图或分布式 CI
Bazel需接入变更到 Target 映射Target Graph很强大型、多语言仓库

8. 推荐决策

本文给出的选择顺序不是工具清单,而是一组明确的默认值与升级条件:

  1. 符合本文默认前提:选择 Turborepo,以 pnpm exec turbo query affected --tasks deploy --base="$BASE_SHA" --head="$HEAD_SHA" 加薄适配器作为选择阶段,再交给 GitHub Actions 动态 matrix 部署。
  2. 已经稳定使用 Turborepo:保留现状;不要只因为 Nx 功能更多而迁移。
  3. 从零开始且已明确需要多语言 Project Graph、Generators、module boundary rules、Release Management 或跨 Runner 分发,并已确认对应 Nx Plugin 能正确建模:直接选择 Nx,不必先用 Turborepo 过渡。
  4. 只需按变化 workspace 执行命令、不需要 Task Graph 与缓存:选择 pnpm Filter。
  5. 只有两三个互不依赖的应用:选择 GitHub paths,接受手工路径映射。
  6. 多语言巨型仓库且要求更细粒度 Target、可复现构建与远程执行:再评估 Bazel。

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

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

参考资料

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

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

博客助手

正在打开博客助手…