说明:本文由 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-ui,admin-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 同时使用:
- Git 历史:找出基线之后发生变化的文件。
- Package Graph:根据 Workspace 的
package.json依赖关系连接各个包。 - Task Graph:根据
turbo.json中的任务和dependsOn推导任务关系。
一个适合机器读取的通用命令是:
turbo run deploy --filter="...[<base-sha>]" --dry=json各部分含义如下:
turbo run deploy:规划名为deploy的任务。[<base-sha>]:选择从指定 Git 基线到当前工作树发生变化的包。- 前缀
...:把这些变化包的反向依赖者一起纳入。例如共享包变化时,依赖它的应用也会进入计划。 --dry=json:不执行任务,只输出机器可读的任务计划;JSON 包含package、task、dependencies、dependents等字段。
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它有一个经典漏洞:
- 提交 A 修改应用甲,但部署失败。
- 提交 B 只修改应用乙。
- B 若只与 A 比较,就只会部署应用乙。
- 应用甲仍未上线,却不会自动重试。
更稳健的生产基线是:
last successful deployment SHA -> current SHANx 的 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 Graph | 有 | 中 | JS/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。
无论选择哪种工具,生产级实现至少应满足:
- 明确 base/head 的语义,并优先记录最近一次成功部署 SHA。
- CI Checkout 提供足够的 Git 历史。
- 依赖关系进入版本控制,不靠隐藏约定。
- 选择结果经过可部署应用白名单校验。
- “无变化”“计算失败”“首次运行”是三个不同状态。
- 选择阶段不接触部署 Secret。
- 为单应用变化、共享包变化、锁文件变化、失败后重试和全量 fallback 编写测试。