Turborepo:把 Monorepo 构建变成可解释的任务图与可信缓存
同一个提交在开发机上只用几百毫秒就显示 cache hit,进入 CI 后却发布了旧的 API 地址。团队最初怀疑远端缓存损坏,删除缓存后构建恢复正常;过了几轮,相同故障再次出现。真正的问题不是“缓存偶尔不可靠”,而是构建脚本读取了 PUBLIC_API_URL,任务哈希却没有包含它。缓存系统忠实地复用了一个输入声明不完整的结果。
这类事故揭示了 Turborepo 的真实角色:它不是更快的 npm run,而是一个围绕 package graph 和 task graph 工作的调度器。包管理器告诉它“哪些包互相依赖”,turbo.json 告诉它“哪些任务必须先完成、什么会改变结果、成功后产生什么”,缓存再根据这些声明保存日志和文件。声明准确,增量构建才能既快又可信;声明错误,速度只会让错误扩散得更快。
先把 workspace 做对,再引入任务调度
Turborepo 建立在 npm、pnpm、Yarn 或 Bun 的 workspace 约定之上,不替代包管理器。一个可重复的仓库至少需要根 package.json、唯一的包管理器 lockfile、根 turbo.json,以及每个 workspace 自己的 package.json。仓库结构说明还指出,lockfile 不只负责安装复现,Turborepo 也用它理解内部包之间的依赖关系;缺少 lockfile 会让包图和缓存行为变得不可预测。
当前示例锁定 Turborepo 2.10.5。turbo CLI 与仓库代码采用 MIT 许可证,但 Vercel Remote Cache、账号、SSO、组织权限、保留策略和商业支持属于托管服务边界;使用开源 CLI 不等于自动获得某种远端缓存权限模型。自建 Remote Cache 也只是复用公开 API,服务端的认证、授权、隔离和运维责任仍由实现方承担。
先固定工具链身份。下面以 npm workspace 为例,pnpm 项目应改用 pnpm-workspace.yaml 和 pnpm-lock.yaml,不能同时提交多套活跃 lockfile:
{
"name": "commerce-platform",
"private": true,
"packageManager": "npm@10.9.7",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"dev": "turbo run dev"
},
"devDependencies": {
"turbo": "2.10.5"
}
}private: true 防止根包被误发布;packageManager 让 Corepack、CI 和 Turborepo 对预期工具及版本形成一致判断;本地依赖中的 turbo 由 lockfile 固定,团队脚本不会随着某位开发者的全局版本漂移。版本升级应通过依赖更新 PR 完成,而不是把 latest 长期留在主干。
若从空目录开始,可以用官方脚手架建立结构:
npx create-turbo@latest迁入已有多包仓库时,更稳妥的顺序是先让包管理器独立工作,再安装 Turborepo:
npm install
npm query .workspace
npm install --save-dev --save-exact turbo@2.10.5
npx turbo lsnpm query .workspace 应列出预期包,npx turbo ls 应给出相同数量和路径。若 npm 能发现包而 turbo ls 显示 0 packages,先检查根 workspaces、子包 package.json、单一 lockfile 与 packageManager,不要用 dangerouslyDisablePackageManagerCheck 长期绕过。该开关会让 Turborepo以 best-effort 猜测包管理器,也把不稳定 lockfile 带来的包图和缓存风险交还给团队。
内部依赖必须真实写入消费方的 dependencies 或 devDependencies,不能靠相对路径跨包读取源码:
{
"name": "@acme/web",
"version": "1.0.0",
"dependencies": {
"@acme/ui": "1.0.0"
},
"scripts": {
"build": "node build.mjs"
}
}如果 @acme/web 实际 import 了 @acme/ui,却没有声明依赖,任务图就不知道 UI 变化应让 Web 失效。TypeScript path alias、hoisted node_modules 或编辑器自动补全可能暂时掩盖缺口,干净安装和裁剪部署最终会暴露它。把“每个包能在声明依赖后独立构建”作为迁移不变量,比先追求缓存命中率更重要。
turbo.json 描述的是因果关系
一份可用的根配置可以从四个问题展开:任务等谁、读取什么、产出什么、哪些环境变量会改变结果。
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "stream",
"envMode": "strict",
"globalDependencies": ["tsconfig.base.json", ".nvmrc"],
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!README.md"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"env": ["PUBLIC_API_URL"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["$TURBO_DEFAULT$", "test/**"],
"outputs": ["coverage/**"],
"env": ["TZ"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}dependsOn 决定调度顺序,也决定下游哈希
"^build" 中的 ^ 表示先构建当前包的内部依赖,再构建当前包。@acme/web 依赖 @acme/ui 时,任务边为 @acme/ui#build -> @acme/web#build。没有 ^ 的 "build" 表示同一个包里的 test 等自己的 build 完成。还可以用 utils#build 表达指定包任务,但这种硬编码关系过多时,通常意味着包依赖没有被正确建模。
任务配置参考区分依赖包任务、同包任务和任意包任务。不要用 shell 脚本里的 cd package-a; npm run build; cd ../package-b... 复制这张图:顺序藏在脚本后,过滤、并发和缓存都无法可靠利用它。
inputs 是缓存正确性的输入契约
未配置 inputs 时,任务默认考虑包内受版本控制的文件,并结合 .gitignore 排除内容。显式配置 inputs 会退出这套默认行为,因此通常应从 $TURBO_DEFAULT$ 增量调整:
{
"tasks": {
"check-types": {
"inputs": ["$TURBO_DEFAULT$", "!README.md"]
}
}
}只写 "inputs": ["src/**"] 往往过窄:构建脚本、局部 tsconfig.json、代码生成模板或测试 fixture 变化可能不再失效缓存。包外根配置可用 $TURBO_ROOT$/path 纳入任务输入;影响所有任务的根文件放进 globalDependencies。package.json、turbo.json 和 lockfile 属于始终参与判断的关键文件,不能靠排除 glob 绕开。
输入声明的验收方式不是看一次命中,而是建立变化矩阵:源码、包内配置、根共享配置、lockfile、生成模板和相关环境变量分别改变时,哪些任务必须 miss;README、注释或不参与构建的文档改变时,哪些任务允许 hit。矩阵中任何“应该失效却命中”的格子,都是潜在错误制品入口。
outputs 决定缓存能恢复哪些文件
outputs 是成功任务产生的文件 glob,路径相对包目录。日志会自动缓存,文件制品只有命中 outputs 才能在缓存命中时恢复:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}遗漏 dist/** 的典型现象是第二次显示 cache hit、日志也被重放,但清理工作区后没有可部署文件。写得过宽同样危险,例如缓存整个包目录会把临时文件、测试报告甚至敏感配置上传到远端。先列出构建命令真实写入路径,再只缓存可重建、可跨机器复用、没有秘密的制品。
生成器若把时间戳、绝对路径、随机 ID 或机器名写入 dist,相同输入在不同机器上可能产生不同内容。Turborepo 不会自动把非确定性构建变成确定性构建;团队应先消除这些变量,或者将确实需要变化的值纳入哈希并接受较低命中率。
env 与 passThroughEnv 不能互换
env 既允许变量进入任务,又把变量值纳入任务哈希;passThroughEnv 只允许变量进入严格环境模式下的任务,不参与哈希。前者适合会改变输出的配置,后者只适合不会影响结果的运行凭据或控制信号。
{
"envMode": "strict",
"tasks": {
"build": {
"env": ["PUBLIC_API_URL", "FEATURE_*"],
"passThroughEnv": ["NPM_TOKEN"]
}
}
}NPM_TOKEN 若只用于下载依赖、不进入制品,通常无需进入哈希;但构建脚本如果把 registry、用户名或 token 的派生信息写入输出,就必须先修复脚本,而不是把秘密加入 env。哈希不是保险箱,远端缓存也不应承载秘密。
环境模式说明把严格模式作为发现漏声明变量的工具。envMode: "loose" 会让所有宿主环境变量进入任务进程,却不会自动让它们都影响哈希;它适合短期迁移诊断,不应成为长期解决方案。框架推断可以识别部分常见公共变量前缀,但团队自定义变量仍要明确声明。
用一个双包实验证明任务图和缓存
下面的实验只需要 Node.js、npm 与 PowerShell。目录中 @lab/web 依赖 @lab/lib,两个包都把结果写进 dist:
turborepo-cache-lab/
├─ package.json
├─ package-lock.json
├─ turbo.json
├─ .gitignore
├─ apps/web/
│ ├─ package.json
│ └─ build.mjs
└─ packages/lib/
├─ package.json
├─ build.mjs
└─ src/value.txt.gitignore 至少排除构建输出与缓存,否则新生成的 dist 可能反过来进入下一轮默认输入:
node_modules/
.turbo/
dist/根 package.json 使用前面的 npm workspace 配置。turbo.json 保留关键关系:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["dist/**"],
"env": ["PUBLIC_MODE"]
}
}
}两个子包用各自的 scripts 提供同名 build 任务。Web 必须声明对 lib 的内部依赖,Turborepo 才能从 lockfile 建立依赖边:
packages/lib/package.json:
{
"name": "@lab/lib",
"version": "1.0.0",
"scripts": {
"build": "node build.mjs"
}
}apps/web/package.json:
{
"name": "@lab/web",
"version": "1.0.0",
"dependencies": {
"@lab/lib": "1.0.0"
},
"scripts": {
"build": "node build.mjs"
}
}初始化 packages/lib/src/value.txt:
v1packages/lib/build.mjs 把源码值与环境模式写进制品:
import { mkdir, readFile, writeFile } from "node:fs/promises";
const value = (await readFile("src/value.txt", "utf8")).trim();
await mkdir("dist", { recursive: true });
await writeFile("dist/value.txt", `${value}:${process.env.PUBLIC_MODE}\n`);
console.log(`LIB_BUILD ${value}:${process.env.PUBLIC_MODE}`);apps/web/build.mjs 读取依赖包输出:
import { mkdir, readFile, writeFile } from "node:fs/promises";
const lib = (
await readFile("../../packages/lib/dist/value.txt", "utf8")
).trim();
await mkdir("dist", { recursive: true });
await writeFile("dist/app.txt", `web->${lib}\n`);
console.log(`WEB_BUILD web->${lib}`);安装后先做 dry run,不执行构建:
npm install
npx turbo run build --filter=@lab/web... --dry=jsonJSON 中应出现 @lab/lib#build 和 @lab/web#build,前者在后者的 dependencies 中。@lab/web... 末尾的三个点表示选择 Web 及其依赖;dry run 是上线前审查过滤集合、任务边、哈希输入和缓存状态的第一证据,不应只靠终端里“看起来只跑了两个包”判断。
正向链路依次执行相同构建和源码变更:
$env:PUBLIC_MODE = "alpha"
npx turbo run build --filter=@lab/web... --output-logs=full
npx turbo run build --filter=@lab/web... --output-logs=full
Set-Content packages/lib/src/value.txt "v2"
npx turbo run build --filter=@lab/web... --output-logs=full
Get-Content apps/web/dist/app.txt在 Node.js 22.22.2、npm 10.9.7 与 Turborepo 2.10.5 的实际运行中,首轮是 0 cached, 2 total;第二轮是 2 cached, 2 total,并显示两条 cache hit, replaying logs;把 lib 从 v1 改成 v2 后,两项任务都变为 cache miss,最终文件为:
web->v2:alpha这组输出同时证明三件事:包依赖被任务图识别,相同输入能复用,底层包变化会沿依赖边让应用失效。若第二轮仍 miss,先检查输出是否被 .gitignore 排除、脚本是否持续改写输入文件、环境变量是否变化,以及任务是否产生非确定性文件;不要立刻归因于“Turbo 缓存不稳定”。
反向实验稳定暴露错误环境哈希
把 env 故意改成 passThroughEnv:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["dist/**"],
"passThroughEnv": ["PUBLIC_MODE"]
}
}
}清理本地缓存和输出,只为建立可控反例;日常排障不要把“删缓存”当成修复:
Remove-Item .turbo -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem apps,packages -Directory -Recurse |
Where-Object Name -In @("dist", ".turbo") |
Remove-Item -Recurse -Force
$env:PUBLIC_MODE = "alpha"
npx turbo run build --filter=@lab/web...
$env:PUBLIC_MODE = "beta"
npx turbo run build --filter=@lab/web...
Get-Content apps/web/dist/app.txt实际运行的第二轮显示 2 cached, 2 total,但文件仍然是:
web->v2:alpha缓存没有损坏。PUBLIC_MODE 被允许进入进程,却没有进入任务哈希,所以 alpha 与 beta 对缓存来说是同一组输入。将配置恢复为 "env": ["PUBLIC_MODE"] 后再次以 beta 构建,两项任务都 miss,文件变为 web->v2:beta。这个反例比“环境变量要记得配置”的口号更重要:任何读取变量并改变代码、静态资源、source map、路由或部署清单的任务,都必须让该变量参与哈希。
--filter 选择入口,任务图决定闭包
过滤器不是字符串模糊搜索,而是对包图和版本控制差异的集合运算。运行任务说明中的常见语义可以用方向记忆:
# 只选择一个包
npx turbo run build --filter=@acme/web
# 选择 web 及其依赖,适合构建或启动目标应用
npx turbo run build --filter=@acme/web...
# 选择 ui 及其依赖方,适合验证公共库改动影响
npx turbo run test --filter=...@acme/ui
# 选择目录中的包
npx turbo run lint --filter="./packages/*"
# 选择版本控制范围内发生变化的包,以及依赖这些包的下游
npx turbo run build --filter="...[origin/main...HEAD]"前置三个点 ...ui 朝向依赖方,后置三个点 web... 朝向被依赖包。多个 --filter 默认取并集,不是交集;把多个条件连续写上去并不会自动缩小集合。涉及组合、排除和 Git 范围时,先运行:
npx turbo run test --filter="...[origin/main...HEAD]" --dry=json审查 packages、每个 taskId 的 dependencies 和 dependents。方括号只得到 Git 范围内直接变化的包;前置三个点再把这些包的依赖方纳入集合,避免公共库改动漏掉消费应用。CI 的浅克隆若没有比较基线,基于 Git 的过滤可能无法得到预期集合。稳健策略是显式获取基线引用,并在基线缺失、强制推送或首次运行时回退全量关键门禁,而不是把“零任务成功”当作绿色构建。
--affected 可以根据 Git 变化选择受影响包;任务级输入参与 affected 计算目前由 affectedUsingTaskInputs future flag 控制。预发布行为适合在影子流水线比对新旧任务集合后再启用,不能直接用于减少主干门禁。任何 affected 策略都应定期与全量构建对账:记录增量集合、全量集合和漏选差异,漏测率必须保持为零。
缓存命中是一条可审计的恢复协议
Turborepo 为一次任务计算全局哈希与任务哈希。全局部分包含根 package.json、lockfile、globalDependencies、globalEnv 等;任务部分包含包内输入、依赖、任务配置、环境变量与命令上下文。缓存说明将它们称为任务运行的 fingerprint:任一哈希变化都会 miss。
本地缓存默认位于 .turbo/cache。命中时,Turborepo 恢复声明过的输出并重放日志,所以终端中再次出现 BUILD_OK 不等于脚本重新执行。诊断时要看 cache hit、任务哈希和制品元数据,而不是从日志内容猜执行次数。
下面四类操作要分开:
--force 忽略已有 artifact、重新执行任务并写入新结果;当前 CLI 将它等价为 --cache=local:w,remote:w,适合主动刷新缓存,不适合要求“不要覆盖现有缓存”的审计。删除本地 .turbo/cache 只清除当前机器副本;已连接远端缓存时,下一轮仍可能下载同一制品。
--cache=local:r,remote:r 只读本地和远端缓存,不再写入;它是弃用的 --no-cache 对应迁移写法,但仍可能命中旧结果。--cache=local:,remote: 同时关闭两个缓存源的读写,适合证明任务能从当前输入独立执行。修改 inputs、env 或构建脚本才是在修复因果模型,修复后要重新跑变化矩阵。
缓存故障应先分型。显示 hit 但制品缺失,查 outputs;变量变化仍 hit,查 env;无关改动导致全仓 miss,查 globalDependencies、globalEnv 和 lockfile;相同提交在不同机器总是 miss,比较包管理器、Node、环境变量、绝对路径与非确定性生成内容;命中后应用仍旧,核对部署步骤消费的是否就是被恢复的目录。
远端缓存共享的是信任域,不只是磁盘空间
远端缓存把本机生成的日志和制品上传到共享服务,让另一位开发者或 CI 用相同哈希恢复结果。接入前必须先证明本地缓存正确,否则远端只会扩大错误结果的传播半径。Remote Cache 说明明确提醒日志也属于 artifact,构建命令打印的 token、请求头、用户数据和内部路径都可能随缓存长期流转。
Vercel Remote Cache
开发者使用交互登录和仓库关联:
npx turbo login
npx turbo link启用 SSO 的团队使用组织要求的 SSO 登录入口。CI 不执行交互登录,而是从 secret store 注入:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}TURBO_TOKEN 是访问远端缓存的 bearer credential,TURBO_TEAM 标识缓存命名空间。token 不写进仓库、镜像层、命令行回显或 PR 日志。Vercel 文档要求使用 Scoped Access Token,但 Turborepo 的标准客户端配置本身没有声明一个可移植的 cache read-only/read-write scope;不能仅靠换两个 token 名称就假定完成读写分权。fork PR 和不可信分支应默认不注入生产 TURBO_TOKEN;若业务必须让它们读取缓存,应先确认所选托管或自建服务确实提供只读授权、不可覆盖对象和独立审计,否则改用隔离 team/命名空间或完全关闭远端缓存。可信主干与发布流水线也应使用不同服务身份,并以拒绝写入实验验证服务端权限,而不是只看客户端环境变量是否存在。
验证远端命中要隔离本地副本:先完成一次可确认的上传,再删除当前工作区的 .turbo/cache 和任务输出,以相同提交、相同环境重新运行。预期任务不在本机执行,而是下载制品并重放日志。若只是连续运行两次而不清本地缓存,只能证明本地命中,不能证明远端链路。
自建 Remote Cache
Turborepo 的远端缓存使用公开 HTTP API;Remote Cache API允许任何满足规范的服务实现,通过 turbo login --manual 提供 API URL、team 和 token。自建不是“部署一个对象存储桶”就结束,至少要承担这些运行职责:
API 兼容与 CLI 升级测试,不能只验证上传成功。tenant、team 与仓库隔离,禁止仅凭可猜测哈希读取其他项目制品。bearer token 颁发、最小权限、轮换、撤销和审计。
制品大小限制、保留期、淘汰、存储加密、备份和费用预算。下载完整性、并发写冲突、超时、限流和服务不可用时的降级行为。日志与制品的数据分级、区域约束和删除请求。
远端缓存不可用时,构建应能退回本地执行;如果团队把缓存服务变成发布硬依赖,就要为它定义可用性目标和故障演练。remoteCache.timeout 与 uploadTimeout 控制等待预算,设为 0 表示没有超时,容易让 CI 在网络故障时无限挂起,不应作为“提高稳定性”的默认值。
制品签名解决完整性,不解决泄密
开启签名后,Turborepo 使用团队提供的密钥对上传制品做 HMAC-SHA256,下载时拒绝缺失或无效签名的制品:
{
"futureFlags": {
"longerSignatureKey": true
},
"remoteCache": {
"signature": true
}
}签名密钥通过 TURBO_REMOTE_CACHE_SIGNATURE_KEY 注入;futureFlags.longerSignatureKey 会要求至少 32 字节。该开关当前仍属于 future flag,应在目标 CLI 版本上先做短密钥拒绝与有效密钥命中实验,并计划在后续主版本强制要求之前完成迁移。密钥应与 bearer token 分开保管和轮换:token 控制谁能访问 API,共享 HMAC 密钥只证明制品来自持有同一密钥的信任域,不能识别具体上传者。签名可以发现篡改或错误命名空间返回的 artifact,不能加密内容,也不能阻止构建脚本主动把秘密写进输出或日志。
轮换签名密钥会让旧制品无法通过新密钥验证。Turborepo 当前配置只接收一个签名密钥,没有内建双签名或双密钥验证窗口,因此不要把“双写过渡”写进轮换方案。可执行的路径是切换到新的隔离命名空间并逐步预热,或在原命名空间更换密钥后接受旧 artifact 被当作 miss;是否清理旧对象取决于远端服务的删除能力。把轮换直接放在发布高峰,可能同时触发全仓重建和远端上传峰值,切换前应以代表任务估算冷构建、带宽和存储峰值,并保留关闭远端读取的降级开关。
CI 把“少跑任务”和“少做重复工作”分开设计
缓存与过滤解决不同问题。缓存允许完整任务图快速命中,过滤直接不选择某些任务。前者的错误通常是复用旧结果,后者的错误是任务根本没进入图;后者更容易制造漏测,因此主干初期应优先依赖缓存提速,再用 dry run 和全量对账逐步收紧 affected 范围。
一条通用流水线可以这样组织:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Inspect task graph
run: npx turbo run lint test build --affected --dry=json > turbo-dry.json
env:
TURBO_SCM_BASE: origin/main
TURBO_SCM_HEAD: HEAD
- name: Run affected tasks
run: npx turbo run lint test build --affected --output-logs=errors-only
env:
TURBO_SCM_BASE: origin/main
TURBO_SCM_HEAD: HEAD
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}--affected 默认等价于选择 ...[main...HEAD],前置三个点会包含变化包的依赖方;示例用 TURBO_SCM_BASE 与 TURBO_SCM_HEAD 把比较对象显式化。稳定行为是包级选择:只要包内任一文件变化,该包被请求的任务都会进入集合,任务自己的 inputs 只控制缓存哈希,不会进一步裁剪 --affected。futureFlags.affectedUsingTaskInputs 可以改为按任务输入过滤,但它当前是预发布能力;若试用,必须用 README 变更、源码变更、根 package.json、turbo.json、lockfile 和 globalDependencies 变化做正反样本,并与全量结果持续对账,不能直接成为唯一生产门禁。浅克隆缺少比较历史时,Turborepo 会保守地把所有包视为变化,但流水线仍应在执行前验证 base/head 与 merge-base,避免把异常全量构建误判成正常增量。真实流水线还要处理合并队列引用、fork 权限和 Windows/Linux 差异。turbo-dry.json 可以作为短期诊断制品,但上传前要扫描路径和环境元数据;不要默认永久保留。若比较基线不存在,脚本应明确输出原因并执行全量关键任务,不能以空集合退出成功。
包管理器下载缓存与 Turborepo 任务缓存也不能混为一谈。setup-node 或 CI 自带缓存减少依赖下载,npm ci 仍根据 lockfile建立 node_modules;Turborepo 缓存恢复的是任务日志和 outputs。缓存整个 node_modules 常把平台、ABI、安装脚本和权限差异固化,除非包管理器与 CI 平台有明确支持,不要用它代替 lockfile 安装。
CI 还应定期运行不读旧缓存、也不覆盖既有缓存的审计作业:选择代表性包或全仓执行 --cache=local:,remote:,比较结果摘要、制品哈希和测试数量。审计频率由变更量和风险决定,目标不是让每次流水线都变慢,而是及时发现“缓存持续命中,底层构建早已不可重复”的状态。
watch 与 persistent 任务处理的是开发反馈环
开发服务器、测试 watch 和类型检查 watch 不会自然退出,必须标记 persistent: true,通常同时关闭缓存:
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"test:watch": {
"cache": false,
"persistent": true,
"interactive": true,
"interruptible": true
}
}
}普通任务不能依赖 persistent 任务,因为后者永远不完成;Turborepo 会拒绝这种图。interactive 允许任务接收终端输入,interruptible 允许 turbo watch 在受影响变化后重启 persistent 任务。watch 参考描述了“变化发生后重新执行受影响任务”的工作方式,适合代码生成、测试和构建反馈,不等同于应用内部的热更新机制。
npx turbo watch test --filter=@acme/web...
npx turbo run dev --filter=@acme/web...watch 失控常表现为文件一变化就全仓重跑、生成文件再次触发自身、旧进程占住端口或内存持续增长。排查顺序是先用 dry run 看任务集合,再检查生成目录是否在输入里、inputs 是否过宽、任务是否真的可中断、子进程是否随父进程退出。开发机可接受的 watch 配置不自动适合 CI,CI 应运行有明确退出码和超时的非交互任务。
turbo prune 为部署生成最小工作区
Monorepo 的根 lockfile 可能包含数百个包,部署一个服务却只需要其中一小部分。turbo prune <package> 会生成目标包、它依赖的内部包、裁剪后的 lockfile 和根 package.json;--docker 额外拆成 json 与 full 两层,便于先复制元数据安装依赖,再复制源码构建:prune 命令参考。
npx turbo prune @acme/web --docker --out-dir .turbo-pruned预期目录包含:
.turbo-pruned/
├─ json/
├─ full/
└─ package-lock.jsonjson 适合作为依赖安装层输入,full 包含构建需要的完整源码,裁剪 lockfile 只保留目标闭包。prune 不是安全脱敏器:只要某个文件位于被复制的 workspace,或被全局依赖机制纳入,它仍可能进入构建上下文。镜像构建前继续使用 .dockerignore、秘密挂载和制品扫描。
部署验证至少包含三步:在 pruned 目录执行干净安装;运行目标构建;扫描输出确认没有缺失内部包、根配置和运行时资源。prune 保证的是目标包闭包、裁剪 lockfile 和根 package.json 等规定输出,不要据此假设 globalDependencies 指向的根文件会自动复制;Turborepo 2.10.5 提供的 futureFlags.pruneIncludesGlobalFiles 仍是预发布能力,启用前必须检查它会不会把 .env、证书或其他不应进入构建上下文的文件一并带入。生产基线仍应在 Dockerfile 中显式复制经过审查、确实需要的根配置,并用 --use-gitignore=true 保持默认忽略规则;秘密通过运行平台或 BuildKit secret 注入,不能依赖 prune 过滤。
回滚路径也要保留。迁移初期让旧 Docker build 与 pruned build 并行产出,比较镜像内容、启动探针、依赖清单和关键测试;出现漏文件时先回退旧构建上下文,再修正包依赖或全局输入。不要为了让 prune“收齐文件”把整个仓库加入 globalDependencies,那会同时摧毁增量缓存粒度。
常见失败沿任务模型找第一条证据
每次都 cache miss
先运行两次完全相同的命令并保存任务哈希,再比较 --dry=json 中的 globalCacheInputs 与任务 inputs。高频原因包括 lockfile 被安装过程改写、构建输出未被 .gitignore 排除、脚本写回源码目录、环境变量每轮变化、根配置被过宽地放进 globalDependencies。若只是远端 miss、本地 hit,再查 team、token、API URL、上传超时和远端写权限。
显示 hit 但文件不存在
删除输出后以相同输入重跑;若日志重放而目录不恢复,检查 outputs glob 是否相对包目录、是否遗漏隐藏目录、是否用否定规则排除了实际制品。不要把部署脚本改成“文件不存在就重新 build”掩盖问题,这会让缓存指标与真实执行脱节。
公共包修改后应用没有重建
执行 npx turbo run build --filter=@acme/web... --dry=json,查看 Web 任务是否依赖公共包任务。缺边通常来自消费方没有在 package.json 声明内部依赖、包名重复、lockfile 未更新,或 dependsOn 漏掉 ^build。先修包图,再讨论 filter。
本机通过、CI 报找不到包管理器或包数量不同
比较 Node、npm/pnpm/Yarn 版本、packageManager、lockfile 与 workspace 声明;确认 CI 使用 lockfile 的冻结安装命令。不要在 CI 中动态选择“发现哪个 lockfile 就用哪个工具”,这会把仓库损坏变成随机分支。
changed filter 选择了零任务
先检查基线引用是否真实存在于 runner,输出解析后的 base/head SHA,并用 dry run 查看 packages。首次构建、浅克隆和历史重写都可能让比较失效。零任务不是天然成功条件:关键流水线需要显式判断“确实无影响”与“无法计算影响”的差别。
远端缓存让不相关分支拿到制品
相同哈希本来就允许共享;若不应共享,说明输入、命名空间或信任域缺少区分。检查环境变量是否进入哈希、发布与 PR 是否共用 team、外部代码是否拥有写 token、签名是否启用。不要通过在脚本中加入随机数强制 miss,那会摧毁整个缓存模型。
缓存日志暴露敏感数据
立即撤销已暴露凭据,停止对应缓存命名空间写入,按远端服务能力删除 artifact,并修复任务输出。后续只记录变量名和错误类型,不打印值;对构建日志、source map、静态配置和测试快照执行 secret scan。缩短缓存保留期不能替代凭据撤销。
架构选型看任务语义,而不是只看仓库大小
Turborepo 适合已经采用 JavaScript/TypeScript workspace、任务主要由 package scripts 表达、希望低成本获得任务图与本地/远端缓存的团队。它能渐进加入现有仓库:先统一 workspace 和 lockfile,再为少量稳定任务建模,最后接入远端缓存与 affected CI。
以下信号出现时,要评估更强的构建系统或额外平台能力:
多语言 target、编译工具链和系统库需要严格声明,单靠 package scripts 难以形成可重复沙箱。需要进程级或文件访问级 hermeticity,不能信任任务只读取声明输入。远端执行、跨平台 toolchain、超大规模 action graph 成为核心诉求,而不只是远端缓存。
组织需要强制依赖边界、代码所有权和发布编排,现有仓库规则与审查工具无法补足。
反过来,几十个包并不自动需要 Monorepo 调度器。若包之间没有共享变更、构建时间很短、团队无法维护准确输入输出,新增缓存层的治理成本可能高于收益。架构决策应比较冷构建、热构建、变更传播、CI 排队、缓存存储和误命中风险,而不是用“包数量超过某个固定值”做万能阈值。
权限、数据、容量和成本要进入日常治理
远端缓存的读权限意味着可获取构建制品和日志,写权限意味着可向同一信任域投放可复用结果。最小权限模型至少区分开发者、可信主干 CI、发布 CI 与不可信 PR;服务 token 有 owner、用途、过期与轮换记录,离职和仓库归档时可撤销。共享个人 token 会让审计无法回答“谁上传了这个 artifact”。
数据边界要覆盖的不只是源码。source map、编译后配置、测试 fixture、日志、错误堆栈和生成文档都可能包含客户标识、内部域名、路径或凭据。每个 outputs 变更都应经过与 artifact 上传同等级别的审查;对远端缓存设置保留期、区域与删除能力,并确认供应商或自建服务的备份副本如何处理。
容量模型至少记录:任务执行次数、local/remote hit rate、下载与上传字节、恢复耗时、冷构建耗时、缓存存储增长和逐出率。命中率不是唯一目标;一个 5 秒任务下载 2 GB 制品可能比本地重建更慢。按任务比较“执行成本”和“传输加解压成本”,对巨大低成本输出关闭缓存,或拆分更稳定的制品层。
成本还包括错误模型的维护。每次构建脚本读取新文件、环境变量或外部工具时,都要同步更新输入契约;每次新增输出都要确认可重建与可共享;每次 package manager 或 Turborepo 升级都要跑正反实验。平台团队可以提供基线配置和观测,业务包 owner 仍要对自己的任务输入、输出和失败语义负责。
从普通多包仓库渐进迁移并保留退路
迁移不应一口气把所有脚本改成 turbo run。先选一条无副作用的 lint 或确定性 build 链路,按以下顺序推进:
统一 workspace 声明、包名、内部依赖、单一 lockfile 和 package manager 版本。用 turbo ls 与 --dry=json 核对包图和任务图,不开启远端缓存。为试点任务声明 dependsOn、inputs、outputs 和 env,完成正向命中与反向失效实验。
在 CI 中并行运行旧命令和 Turbo 命令,比较退出码、测试数量、制品摘要和耗时。本地缓存稳定后,以隔离命名空间接入远端缓存,验证跨机器恢复、签名、凭据和删除。缓存稳定后再引入 Git filter 或 affected,并持续与全量结果对账。
最后评估 watch、prune 和更细的 package configuration,避免同时改变太多故障变量。
回滚时保留根 scripts 的旧入口,允许 CI 临时切回原始命令;删除或禁用远端缓存关联不能成为唯一退路,因为任务图配置本身仍可能错误。一次可用回滚应能在不依赖缓存服务的情况下完成全量安装、测试、构建和部署,并保留失败任务的输入摘要供修复。
团队完成一轮升级或规则变更后,证据应串成同一条链:包管理器与 Turborepo 看到相同 workspace;dry run 中任务边符合真实依赖;相同输入稳定命中;源码、根配置和构建变量变化按预期失效;删除本地缓存后能从远端恢复;错误签名被拒绝;不可信代码拿不到写凭据;pruned 工作区能干净安装并构建;禁用缓存后结果与缓存结果一致。到这一步,“构建加速”才不只是一个漂亮的命中率,而是一套可以解释、验证、回滚和长期治理的工程能力。
