Turborepo 缓存边界(实测判据表)
一句话:本页回答「改哪个文件,会让哪个 task 的缓存失效」。 所有结论都是实测出来的,不是从配置推的;每条都给了可重跑的命令。 机械守卫:
pnpm lint:turbo-cache(push 前、CI、发版电池三处都跑)。
本仓几乎所有常用命令都走 turbo 缓存(pnpm build / test / lint / types:check), 所以「边界错了」的代价不是慢,而是静默:FULL TURBO + exit 0, 但门禁回放了一份与当前代码无关的旧结论。
一、怎么测(唯一真源)
pnpm exec turbo run build build:stage transit types:check lint test --dry=json--dry=json 的返回里有两样东西就是全部答案:
| 字段 | 含义 |
|---|---|
tasks[].inputs | 这个 task 的哈希逐文件输入(包内相对路径 → 内容哈希) |
tasks[].hash | 这个 task 最终的键 |
globalCacheInputs.files | 全局哈希里那些文件 |
globalCacheInputs.hashOfExternalDependencies | 外部依赖(锁文件解析结果)那一块 |
globalCacheInputs.hashOfInternalDependencies | 内部依赖那一块(见下,最反直觉的一处) |
要验证「改 X 会不会失效」,改完再跑一次比 hash 即可 —— 本文所有行都是这么得出的。 注意:turbo 把自己的 cwd 当成仓库根,在子目录里跑只会看到那一个包、 照样「全部成立」⇒ 必须在仓库根跑(门禁里对此有硬断言)。
二、一个 task 的哈希由四块拼成
- 全局哈希 ——
globalDependencies的文件内容 +globalEnv的值 + 内部依赖哈希(见第四节)+ engines - 本包输入 ——
inputs解析出的文件集合(内容哈希;$TURBO_DEFAULT$= 包内所有 git 跟踪 + 未被忽略的未跟踪文件) - 依赖任务的哈希 ——
dependsOn里^xxx/xxx指到的上游 task 的哈希,递归 - 声明的环境变量 ——
env里那些(值进哈希)
第 3 条是传播的来源:^build 让「上游的 build 哈希」进到本 task, 而 transit(本仓新增的传递节点)让「上游的 inputs」沿依赖图逐级并进来 —— 见第四节。
三、边界表(2026-09-23 实测)
「→ N 个 task」是本仓当前 90 个 task 里的失效个数。
3.1 全仓失效(改一次,90 个 task 全 miss)
| 文件 | 机制 |
|---|---|
pnpm-workspace.yaml | globalDependencies(catalog 在这里,合理) |
eslint.config.ts · commitlint.config.ts · knip.config.ts | globalDependencies |
根 package.json · tsconfig.json · .gitattributes | globalDependencies |
packages/tooling/tsconfig/*.json | globalDependencies(这个 glob 顺手把该包自己的 turbo.json 也纳入了) |
packages/tooling/vitest-config/*.ts | globalDependencies |
packages/tooling/{eslint-config,release,scripts} 下的 .ts 源文件 | 内部依赖哈希 —— 见下 |
pnpm-lock.yaml 的语义变化(依赖版本真变了) | 全局外部依赖哈希 |
内部依赖哈希是什么:根
package.json依赖到的那几个 workspace 包, 它们的源码内容也算全局输入。实测:改packages/tooling/eslint-config/base.ts一行注释 → 90 个 task 全失效(hashOfInternalDependencies变了)。 但它们的package.json/tsconfig.json/turbo.json/README.md不在这一块里 (只影响自己那几段)。这不是直觉能推出来的,所以它被写进了门禁与本文。
3.2 本包 + 下游
| 改什么 | 失效范围 |
|---|---|
任一包的 src/**、package.json、tsconfig.json、turbo.json | 本包 5 个 task;+ 每个下游包的 build / build:stage / test(走 ^build);+ 下游的 types:check(走 transit) |
| 包内新增一个未被 gitignore 的未跟踪文件 | 同上(未跟踪也算输入,实测:往 contract/src 丢一个 .txt → 20 个 task 失效) |
apps/admin/ 下任何东西(含 build/**、vite.config.ts、uno.config.ts) | 只影响 admin 自己(5 个 task),不外溢 |
apps/server/libs/** | 只影响 @walnut/server(6 个 task),不外溢 |
apps/*/env-encrypted/.env.*(密文,进仓库那份) | 只影响该 app |
实测数字:改 packages/platform-any/types/src/index.d.ts → 21 个 task; 改 packages/platform-web/ui/src/index.ts → 11 个;改 apps/server/libs/db/src/index.ts → 6 个(只有 server)。
3.3 只有 docs 的 build
| 改什么 | 失效范围 |
|---|---|
apps/docs/src/**/*.md(站点内容) | 只有 @walnut/docs#build(1 个 task) |
这一条是刻意的:VitePress 的构建同时就是死链门禁,所以根 build 任务那句 !**/*.md 被 apps/docs/turbo.json 的包级覆写抵消掉了。 包级 inputs 是整体替换通用任务那条(实测:把根 build.inputs 里的 !**/*.md 删掉, docs#build 的输入一个都没变),而 outputs 会保留。
3.4 完全不失效
| 改什么 | 说明 |
|---|---|
根 README.md · AGENTS.md · CLAUDE.md · CONTEXT.md · TODO.md | 不在任何包目录内,也没进 globalDependencies |
apps/*/AGENTS.md、各包 README.md | 被 !**/*.md 排除(改 README 不该重建) |
.github/workflows/** · lefthook.yml · .syncpackrc.json · .npmrc · .gitignore · docker-bake.hcl · deploy/** | 与任何 task 的产物无关 |
一切被 gitignore 的东西:dist/、.turbo/、各包 node_modules/ | $TURBO_DEFAULT$ 看不见 |
⚠️ 但 gitignore 会被显式 glob 盖过:实测给
build.inputs加一句env-local/**之后, admin 的构建输入从 748 → 752 条,多出来的正是那个被忽略目录里的 4 个文件。 这正是env-local那个缝的补法(见第四节)。
四、四条已经修掉的缝(都是实测出来的)
4.1 build:stage 报成功却一个文件都没产出 ✅ 已修
apps/admin 的 staging 产物目录由 VITE_BUILD_OUT_DIR 决定,实际值是 dist-staging (生产是 dist)。而根 turbo.json 的 build:stage.outputs 只写了 dist/**。
实测后果(这是本文最有价值的一条):
删掉 apps/admin/dist-staging → pnpm build:stage
Tasks: 1 successful, 1 total
Cached: 1 cached, 1 total
Time: 327ms >>> FULL TURBO
命中后 dist-staging 文件数 = 0 ← 一个都没有即:缓存命中只回放日志,不落产物,而退出码是 0。 任何依赖 pnpm build:stage 产物的流程(或人)拿到的是一句「成功」。
改法:build:stage.outputs 补上 dist-staging/**。 守卫:门禁按 env-local/.env.stage 里 VITE_BUILD_OUT_DIR 的真实取值去核对 outputs —— 判据来自配置本身,不是手抄的目录名。
4.2 types:check 看不到依赖包的源码 ✅ 已修
本仓 6 个共享包全部源码直消费(exports 指 ./src/**),下游的类型是从源码读的。 但 types:check 原本是 dependsOn: [],而依赖包的源码不在它的 inputs 里。
实测:改 @walnut/types 的 src/index.d.ts 一行注释 → 14 个 task 失效, 而 @walnut/admin#types:check 纹丝不动 ⇒ 类型门禁回放假绿。
改法:新增一个没有脚本的传递节点任务 transit(dependsOn: ["^transit"], inputs: ["$TURBO_DEFAULT$"]),让 types:check 挂 dependsOn: ["transit"]。 于是「上游的 inputs」沿依赖图逐级并进本 task 的哈希。改完同一个实验 → 21 个 task 失效, admin#types:check 正确失效。
为什么只挂 types:check:build / test 走 ^build 已经覆盖(那几个 echo 的 build 任务 inputs 就是包内文件);lint 刻意不挂 —— ESLint 不跨包跟随 import, 挂了只会让 lint 的命中率大跌。
4.3 改解密后的 .env 不会重建 ✅ 已修
Vite 的 envDir 是 apps/admin/env-local,而它被 gitignore ⇒ 不进 $TURBO_DEFAULT$ ⇒ 不进哈希。改了它(或重跑 pnpm setup-env 换了值)构建不失效,打出来的包内联的是旧 VITE_*。
改法:build / build:stage 的 inputs 显式加 env-local/** (实测显式 glob 能盖过 gitignore)。
4.4 @walnut/server 的 build 与 build:stage 写同一个目录 ✅ 已修
apps/server/infra/nest/{prod,stage}.json 的 compilerOptions.outDir 原本都是默认的 dist, 而根 turbo.json 给这两个任务声明的 outputs 也都是 dist/**。 这正是 4.1 那个 bug 的另一半:两条流程声明了同一个产物面, 缓存命中会把盘上的产物换成另一条流程的那一版(后跑的那次说话)。
改法(用户 2026-09-23 拍板:「build:stage 肯定要单独输出到一个独立的 dist 下」): 把最外层那个目录名换掉。踩了两个坑,都记下来:
outDir的真源是 tsconfig,不是nest-cli的配置。先在nest/stage.json的顶层与项目级 都写了compilerOptions.outDir: "dist-stage",实测 Nest 的 SWC builder 完全忽略它 —— 编译产物照旧落dist/(apps/server/tsconfig.json里写着outDir: "./dist"), 只有assets[].outDir生效;而deleteOutDir: true照样先把 prod 的dist删了再写。 正确做法是给 staging 一份自己的 tsconfig(apps/api/tsconfig.app.stage.json,只覆盖outDir与tsBuildInfoFile),再让nest/stage.json的tsConfigPath指向它。nest/*.json由 Nest CLI 按严格 JSON 解析(不是 JSONC)。第一版我在里面写了注释, 构建直接报Expected property name or '}' in JSON。说明因此写在apps/server/infra/README.md而不是配置里。
里面的 apps/api/src 结构不动(那由 Nest monorepo 的 root / sourceRoot 决定)。 路径有四处同源声明,改一处必须改其余:apps/api/tsconfig.app.stage.json 的 outDir(真源)、 nest/stage.json 的 tsConfigPath(顶层与 projects.api 两处)与三条 assets[].outDir、 apps/server/package.json 的 start:stage、根 turbo.json 的 build:stage.outputs。
实测:turbo run build:stage --filter=@walnut/server 冷跑 → dist-stage 1465 个文件、 含 dist-stage/apps/api/src/main.js,而 prod 的 dist 一字未动(754 个文件,sha 不变); 删掉 dist-stage 再跑 → FULL TURBO 命中并完整回放 1465 个文件。
两个 app 的 staging 目录名不同是刻意的:admin 侧是
dist-staging, 它来自VITE_BUILD_OUT_DIR—— 那个值在加密的 env 文件里,改它要重建 dotenvx 密钥、 会让 CI 的ENV_KEYSsecret 失效。所以只改 server 这一侧,并把两个目录名都写进outputs。
4.5 NODE_ENV 该不该进哈希 ✅ 已裁决(不进)
原本放在 globalEnv(进哈希)。实测代价:NODE_ENV=production 与 NODE_ENV=development 跑同一份代码 → 90 个 task 全部失效,白扔一整轮缓存。
裁决:改到 globalPassThroughEnv(可见、不进哈希)。两条理由:
- 可见性仍然是必须的 —— Turbo 的严格模式会剥离未声明的变量,而
NODE_ENV不在 Turbo 的内置放行名单里(不声明就被剥成 undefined)。 - 它不该进哈希 —— 没有任何 task 的产物取决于外部传进来的这个值:
@walnut/server的 build 由脚本自己cross-env NODE_ENV=…设定;@walnut/admin走vite build,Vite 自己强制 production、vitest 自己强制 test;@walnut/docs走vitepress build,同理。
判据可重跑:NODE_ENV=a pnpm exec turbo run build --dry=json 与 NODE_ENV=b … 比 hash —— 改前 90/90 失效,改后 0/90。
4.6 根级文件的 lint 完全不缓存 ✅ 已修
根 lint:root 脚本(eslint *.ts *.json *.yaml)以前只以根脚本的身份被 prepush 与发版电池直接调用 ⇒ 每次推送都真跑,而它检查的只是根目录那 9 个文件。
改法:在根 turbo.json 里定义 //#lint:root 根任务(//# 前缀 = 没有对应 workspace 包的任务), inputs 逐字等于脚本里那三个 glob;prepush 与发版电池改走 turbo run lint:root。
为什么 inputs 要逐字相等(本项唯一需要记住的规矩,也是两个坑的分界线):
- 写成
$TURBO_ROOT$/**再逐条排除 ⇒ 那些「随运行变化」的目录(.turbo、各包dist、node_modules)漏排一个,缓存就永不命中; - 排过头(例如顺手排掉
packages/**)⇒ 根 eslint 配置 import 的东西不进缓存键, 改完规则却回放旧结论 —— 门禁假绿(参考仓实测踩过这个方向)。
实测:冷跑 3.8s → 热跑 0.11s(FULL TURBO)。边界也精确: 改 node-modules-inspector.config.ts 或 .syncpackrc.json(根文件,且不在 globalDependencies 里) → 根任务失效、其他 0 个 task 失效;改 apps/admin/src/main.ts → 5 个包任务失效、根任务不动。
顺带翻了一处过期的断言:发版电池的测试原本钉着「
lint:root只是根脚本、不是 turbo 任务, 所以绝不能放进 turbo 的 argv」—— 理由是turbo run lint lint:root会Could not find task当场退 1。那是当时的实测事实;//#lint:root定义出来之后前提就没了, 现在turbo run lint lint:root实测 16 个任务、正常跑通。断言已按新事实改写并留了记录。
4.7 未声明的环境变量会被静默剥离 ✅ 已加护栏
Turbo 的严格 env 模式会把「没在 env / globalEnv / passThroughEnv / globalPassThroughEnv 里声明过」的变量从任务进程里剥掉 —— 没有任何报错,只是那个变量在构建期变成 undefined。 本仓此前对这件事零护栏。现在接了 eslint-plugin-turbo 的 no-undeclared-env-vars(error 级), 判据的唯一真源就是根 turbo.json(插件按被 lint 文件的目录向上找)。
上线当场抓出 6 个真缺口(参考仓同类规则抓出 5 个):
| 变量 | 谁在读 | 被剥掉的后果 |
|---|---|---|
npm_execpath | @walnut/scripts 的 lib/pnpm-launcher.ts | 定位不到 pnpm 的真 exe,退化成「找不到 pnpm」 |
PATH | 同上(查找路径的后备) | 同上 |
PREPUSH_CONCURRENCY | prepush.ts 的并发旋钮 | 旋钮失效、静默回落默认值 |
GITHUB_API_URL | @walnut/release 的 github.ts | GitHub Enterprise 覆盖点失效 |
GH_TOKEN | @walnut/release 的 credentials.ts | token 回退来源失效 |
WALNUT_TEST_MARKER | 发版工具链的测试夹具 | (见下:夹具,走 allowList 而不是声明) |
四类豁免,各自有不同理由(这个分类本身就是这条规则的落地经验):
| 类别 | 成员 | 为什么不进 turbo.json |
|---|---|---|
| 框架编译期内建 | DEV PROD SSR BASE_URL | 打包器在编译期替换成字面量。规则同时匹配 import.meta.env.X,所以 apps/admin/src/utils/constant/vue.ts 那三行会被误报 |
| 运行时注入 | npm_package_version、npm_config_* | 由 npm/pnpm 执行脚本时注入,不是外部传入 |
| 测试夹具 | WALNUT_TEST_MARKER | 用例自己设再断言,不是任何代码的依赖 —— 只在测试文件里豁免 |
| 运行期配置面 | apps/server/libs/config/src/modules/*.config.ts 的 ~70 个(APP_* / AUTH_* / DATABASE_* …) | 见下 |
最后那一类值得单独说:它们是 @nestjs/config 的 registerAs 工厂,读的是磁盘上的 env-local/.env.*(pnpm setup-env 解密而来),由 ConfigModule 在进程内灌进 process.env。 把它们写进 globalPassThroughEnv 是语义错误 —— 那份清单的含义是「外部传给 turbo 任务的变量」。 判据(2026-09-23 实测):全仓 process.env.* 共 75 个不同变量名,其中 74 处集中在这个目录; 去掉它之后,剩下的命中刚好就是上面那 6 个真缺口 —— 也就是说这份豁免只盖住了运行期配置面, 没有盖住真缺口。豁免写在 nest.ts 里并附了这段理由。
验证:注入一个未声明的 process.env.WALNUT_BOUNDARY_PROBE → 规则报红(并字节级还原验证过)。
接线时踩的三个坑(都会静默失败,记下来省得下次重踩):
- 共用的一段必须三个预设都挂。
base/vue/nest各自直接调antfu(...),nest不经过base—— 第一版只改了base.ts,跑 server 的 lint 得到 0 条命中(看着像「没有缺口」, 其实是规则根本没生效)。所以抽成了eslint-config/turbo-env-vars.ts。 - 那个相对导入不能带
.ts扩展名。nest.ts以类型方式引入base.ts,于是base.ts会进apps/server的类型程序,而那份 tsconfig(ADR 0012 自包含)没开allowImportingTsExtensions⇒ 带扩展名让@walnut/server的types:check报 TS5097。同一个坑nest.ts的文件头早就写过。 - 规则元组不能用
as const:readonly [...]不满足 flat config 的RuleEntry(要求可变元组), 会报 TS2345。
五、交叉对比:调研文档 / 参考仓 Z / 本仓
三份材料回答的是同一个问题,但深度差两级。
| 议题 | 仓内调研文档(industry-research/05) | 参考仓 Z(另一个更成熟的内部 monorepo) | 本仓现在 |
|---|---|---|---|
inputs 精确化 | ✅ 提出(「默认 hash 包内所有文件,改 README 也会失效」) | ✅ 做到,且按消费面分层:全包共享的根输入放 transit,只有某个 app 消费的(.env* / types/** / vendor/**)收进该 app 的包级 turbo.json | ✅ 做了排除与包级覆写,但没有分层:全局输入一律 globalDependencies,一处改动全仓失效 |
| 依赖包源码变更 | ❌ 未涉及 | ✅ transit 传递节点(本仓直接采纳了这个设计) | ✅ 本轮补上 |
| 产物目录 | ⚠️ 只写「不声明 outputs 等于放弃缓存」,没说清真正的症状:声明了但漏一个目录时是「命中缓存 ⇒ 静默不产出 + exit 0」 | ✅ 实测过同一类 bug,并把 dist / dist-stage 按画像分离 | ✅ 本轮补上 dist-staging,并把它做成门禁(Z 仓靠断言 + 负向验证器) |
| 根级文件的缓存 | ❌ 未涉及 | ✅ 有 //#lint:root 这种根任务,inputs 用 $TURBO_ROOT$/** + 逐条排除运行期目录,且记了「漏排除 ⇒ 缓存永不命中」「排除过头 ⇒ 门禁回放假绿」两个方向的坑 | ✅ 已补 //#lint:root 根任务(inputs 逐字等于脚本的 glob),冷跑 3.8s → 热跑 0.11s,见 4.6 |
✅ 已接 no-undeclared-env-vars(error 级,三个预设共用一段),上线当场抓出 6 个真缺口;四类豁免(框架内建 / 运行时注入 / 测试夹具 / 运行期配置面)各有理由,见 4.7 | |||
| 缓存淘汰 | ❌ 未涉及 | ✅ cacheMaxAge: "14d" + cacheMaxSize: "5GB" | ✅ 已对齐(同日把 turbo 升到 2.11.2 —— 这两个键要 2.10+) |
| 并发 | ❌ 未涉及 | ✅ concurrency: 4,并与 vitest 的 maxWorkers: '50%' 一起收敛 | ⚠️ concurrency: "4" 已加(Turbo 默认是 10,本仓 12 核 ⇒ 收了一半多);但没跟着设 vitest 的 maxWorkers —— 本机实测两种设置下全仓 test 都是 9.5–11s(无差异),而在小核 CI 上 50% 反而可能欠配。结论与实测记在 Turbo |
| 边界怎么被守住 | ❌ 未涉及 | ✅ 把缓存断言写进 check-scripts / check-package-standard,并给关键断言配负向验证器(注入错误证明它真会红) | ✅ 本轮补上 walnut-check-turbo-cache + 7 个注入用例 |
| 远端缓存 | ⚠️ 推荐接入(后被推翻) | ✅ 明确只用本地缓存,理由是「单机验证够用、CI 是构建 tag 不是增量」 | ✅ 同为不接入,理由一致 |
哪边更好,逐条说:
- 调研文档是入门级的:它把「有
inputs/outputs/env这三样东西」讲对了, 但没有一条实测,也没触及「写错了会怎样」。它的 §8 差距表还停在 2026-09 之前 (写着inputs未配置、env未区分),本仓早就做了 —— 那份表需要重写。 - 参考仓 Z 明显更深:它的
turbo.jsonc里每条规则都带实测结论和日期, 甚至记录了「哪个断言曾经只是注释里的承诺、其实不存在」。本仓这一轮的三条修正 (transit/ 产物目录 / env 进键)里有两条是直接采纳它的设计,一条(产物目录) 是它的实测结论让我们提前知道该去查。 - 本仓比 Z 仓弱的地方(都已记进架构待办,不是本轮范围): 没有
NODE_ENV该不该进哈希的裁决、没有未声明 env 的护栏、没有缓存淘汰与并发上限、lint:root不缓存。本仓比 Z 仓强的地方:输入集合的实测边界表是独立做的 (用--dry=json直接量,而不是靠断言间接守),而且工具链包少一半、图更浅。
「参考仓 Z」是另一个私有仓库,不在此展开其路径与业务内容; 上面的对比只保留与本仓决策相关的部分。
六、这道门禁守什么(每条都有 rule id,报错时按它定位)
pnpm lint:turbo-cache 不改任何文件,只拿 --dry=json 的解析结果 + 配置文件里的真实取值核对一组不变量 (表里不写条数 —— 它随门禁增删而变;真源是 check-turbo-cache.ts 里的 rule id):
| # | 不变量(rule id) | 不守会怎样 |
|---|---|---|
| 1 | 每个 workspace 包都有 turbo.json,且 extends: ["//"] + 非空 tags(package-turbo-json) | 那个包静默不受 turbo boundaries 约束 |
| 2 | globalDependencies 每条都命中真实文件(global-dependency-exists) | 写错一个字符 = 少一个全局失效源(本轮实测出 packages/tooling/tsconfig/*.json 顺带纳入了 turbo.json,说明这类 glob 值得核) |
| 3 | 有 env-local/ 的 app,其 build / build:stage 的 inputs 必须含 env-local/**(env-local-in-inputs) | 改 .env 不重建(4.3) |
| 4 | 从 env-local/.env.* 读出的 VITE_BUILD_OUT_DIR 必须出现在对应任务的 outputs 里(outputs-cover-artifacts) | 缓存命中静默不产出(4.1) |
| 5 | transit 存在、带 ^transit,且 types:check 挂它(transit-exists / transit-chains) | 依赖包源码变更 ⇒ 类型门禁回放假绿(4.2) |
| 6 | @walnut/docs#build 的输入里有 .md(docs-build-sees-markdown) | 改文档不重建 ⇒ 死链校验被跳过 |
| 7 | 同一个 app 的 prod 与 stage 不许落进同一个产物目录(判据顺着 tsConfigPath → extends 读 tsconfig 的 outDir),且该目录必须在 build:stage.outputs 里(stage-outdir-separate) | 两条流程抢同一目录 ⇒ 缓存重放互相覆盖;deleteOutDir 还会先删掉对方的产物(4.4) |
| 8 | //#lint:root 的 inputs 与根脚本里那几个 glob 双向一致(lint-root-inputs) | 写成大排除式 ⇒ 漏排一个运行期目录就永不命中;排过头 ⇒ 根 eslint 配置的改动不进缓存键、门禁回放假绿 |
| 9 | tags 的形态:字符串、小写 kebab-case、无重复(tag-shape) | Platform_Web 这种拼法能通过 JSON 校验,却匹配不上 boundaries 里那条 platform-web 的规则 ⇒ 与"没写"同效 |
| 10 | 平台 tag 与目录组一致:packages/platform-any/* 必须带 platform-any 且不带另两个(platform-tag-matches-dir) | 那条 boundaries 规则会套到错的一侧(比如 platform-any 的包声明了 platform-web,于是它躲开了本该受的约束) |
| 11 | 反向断言:根 boundaries.tags 的每个 key 都得有包真的声明它(boundary-rule-has-subject) | 那条规则永远不会触发 —— 恒关的规则等于没有规则(与 lint:doc-budget 的「预算用不到一半也算失败」同一族) |
| 12 | CI 那份跨 run 的缓存里只许有元数据:ci.yml 里落在「恢复缓存」步骤之后(且同一个 job 内)的步骤,只要跑的是 build* 这类有产物的 turbo 任务,就必须显式给 --cache-dir(ci-cache-holds-only-metadata) | 产物会随整份 .turbo/cache 一起上传:实测 Docs build(4.6 MB)把归档从几十 KB 顶到 4.7 MB、第二次 9.8 MB(见 §七) |
| 13 | --cache-dir 不许写在 -- 之后(ci-cache-flag-reaches-turbo) | 那个参数会被 turbo 当成透传给任务本身的参数 ⇒ 缓存照旧写默认目录:看着像改好了、其实一行没生效(2026-09-24 实测踩到,见 §七 7.2) |
第 9–11 条是 2026-09-23 交叉对比时补的:原先把「非空 tags」当成够用了, 但它只挡住「整个忘写」,挡不住「写了个不会匹配任何规则的 tag」—— 后者同样是静默豁免。 第 12–13 条是 2026-09-24 CI 实测踩到「docs 构建混进了缓存」之后补的(细节见 §七)。
⚠️ 这道门禁曾经在 CI 上从没成立过(2026-09-23 读出来的真 bug):
loadTurboDry()原本是execFileSync('cmd', ['/c', 'pnpm exec turbo … 2>nul'])—— 那是只在 Windows 上成立的写法,而 CI 的 runner 是ubuntu-latest:那里没有cmd, 直接 ENOENT ⇒ 这一步在 CI 上只会报错退出 2。(当时那批提交还没推过,所以没红过 —— 一推就会红。)现已改走getPnpmBin()(本仓唯一被认可起 pnpm 的方式,Windows 上execFileSync('pnpm', …)同样会 ENOENT,而shell: true会重新解析 argv)。check-turbo-cache.test.ts里有一条剥注释后扫源码的守卫,防止再退回去。
它真的会红吗——注入用例逐个验过(改完即还原,turbo.json sha 不变):
| 注入的错误 | 报出的规则 |
|---|---|
| 输出目录漏声明(复现 4.1) | outputs-cover-artifacts |
env-local 掉出 inputs | env-local-in-inputs(admin 与 server 各两条) |
types:check 掉了 transit | transit-chains |
transit 不再向上串联 | transit-chains |
globalDependencies 写了不存在的路径 | global-dependency-exists |
删掉某个包的 turbo.json | package-turbo-json |
docs 的 build 不再把 .md 当输入 | docs-build-sees-markdown |
server 的 stage 又写回 dist | stage-outdir-separate |
nest/stage.json 的 tsConfigPath 指回 prod 的 tsconfig | stage-outdir-separate |
build:stage.outputs 漏掉 dist-stage/** | outputs-cover-artifacts |
//#lint:root 的 inputs 少一个 glob | lint-root-inputs |
改成 $TURBO_ROOT$/** 大排除式 | lint-root-inputs |
| 根脚本多检查一类文件、inputs 没跟上 | lint-root-inputs |
tag 写成 Platform_Web / tags 有重复 | tag-shape |
packages/platform-any/* 漏了 platform-any/声明了 platform-web | platform-tag-matches-dir |
boundaries.tags 里有一条没有包声明 | boundary-rule-has-subject |
第 9–11 条的三行用例走的是临时夹具仓库(mkdtemp 造一个只有 1 个包的迷你仓, 把 dry 的 directory 指过去)—— 因为 tags 是门禁从盘上的 turbo.json 读的, 没法像 outputs 那样靠改 dry 注入。
另外还有一道跑错目录的守卫:在子目录里跑时 turbo 只会看到 1 个包、 所有断言照样成立 —— 门禁对此硬报错(turbo 只在 <cwd> 下发现了 1 个包), 不让它退化成假绿。
七、CI 里的缓存:只缓存质量门(2026-09-23 落地 · 2026-09-24 在 CI 上实测并收口)
在此之前 CI 完全没有 actions/cache(actions/setup-node 的 cache: pnpm 只缓存 pnpm store, 不缓存 turbo 的 task 缓存)⇒ 每次 push 都是冷跑。现在 quality job 里恢复了 .turbo/cache。
⚠️ 范围的口径是「这条 job 里 turbo 跑过什么」,不是「哪几类任务」 —— 恢复 / 保存的路径就是整个 .turbo/cache。所以「只缓存质量门」不是靠 key 或过滤器做到的,而是靠这条 job 里本来只有质量门 那几类 turbo 任务;一旦有别的 turbo 任务在这条 job 里跑,它的产物就会跟着上传 (第一版就踩到了,见 7.2)。
本地整份 .turbo/cache | CI quality job 保存的那份 | |
|---|---|---|
| 内容 | lint / types / test / build(admin·server·docs 全量) | 只有 lint / types:check / test 的元数据 |
| 体积(实测) | 385 MB / 6150 文件(2026-09-23 口径) | 几十 KB(质量门 outputs: [],条目只是"这个 hash 跑过且通过了") |
| 冷跑 → 热跑(实测) | — | 144s → 0.13s(43/43 FULL TURBO,本地口径) |
为什么 CI 那份小:本仓最大的两类产物 —— admin / server 的 dist —— 不在这条 job 里 (它们在 build job)。385 MB 里的绝大部分在这儿根本不存在。
7.1 CI 实测(2026-09-24)
| run | 缓存 | Restore | Lint (affected) | Type check (affected) | Tests (affected) | Docs build |
|---|---|---|---|---|---|---|
| #15(首跑) | miss,末尾保存 | 0s | 69s | 41s | 10s | 23s |
| #16 | 命中 | 1s | 17s | 20s | 0s | 20s |
| #17 | 命中 | 2s | 0s | 0s | 0s | 1s |
- 每个新提交的第一次跑一定 miss:key 里带本次
sha,恢复步骤没有可命中的对象 ⇒ 那三个数 (69 / 41 / 10s)就是"没缓存"的基线。 - run #17 三个步骤全 0s = 任务全部回放(对照冷跑 69 / 41 / 10s);
Docs build从 20s 掉到 1s 同样是回放(那次没动 docs,hash 与上一次相同)。「命中了」有两份独立证据:① 步骤耗时坍缩; ②actions/cachesAPI 上那份归档的last_accessed_at被刷新到 run #16 的恢复时刻 —— 这个 API 公开可读,判据可重跑。 - ⚠️ run #16 的 17s / 20s 不是缓存失效:那次推送改了 admin 的一份生成声明 (
components.d.ts—— 当时它还被 git 跟踪,而.gitignore里那两条规则因为「带斜杠 = 锚定 仓库根」其实从未生效;2026-09-24 已把它移到types/generated/并从跟踪面移除)⇒ 它在 admin 的 hash 输入里 ⇒ 改了就该真跑。改了就真跑、没改就 0s,这正是"按内容 hash 判断"而不是"按这台机器上有没有"的现场证据。顺带一条边界变化:那两份文件移出跟踪面之后就不在
$TURBO_DEFAULT$里了 ⇒ 它们不再是 hash 输入(正确:它们由src/**决定,而src本来就是输入)。
7.2 一次「差 47 倍」的排查:docs 构建混进了缓存(已收口)
run #15 保存下来的归档实测 4,912,216 字节(4.7 MB),而按「质量门三类任务的 outputs 都是 [] ⇒ 条目只有元数据」推出来的是 0.1 MB。差 47 倍,原因不在 key、也不在 actions/cache:
Docs build (dead-link check)那一步是pnpm build:docs,而它就是turbo build --filter=@walnut/docs—— 是 turbo 任务,位置又在恢复之后、保存(job 末尾)之前 ⇒ VitePress 的构建产物一起进了缓存。本地同一个任务的条目 4.84 MB(tar.zst4.63 MB + manifest 215 KB),与 CI 那 4.7 MB 吻合;质量门那部分只有几十 KB。- 而且它会继续长:归档每次整份重传,每动一次 docs 的推送就多一个 4.6 MB 的构建条目 —— 实测 run #16 保存的那份已经是 9.8 MB(4.9 → 9.8,正好差一个 docs 构建条目)。
处置(两条必须一起做):
- 那一步改用自己的缓存目录:
pnpm build:docs --cache-dir=.turbo/cache-docs。于是它 每次都真跑、不回放 —— 对一条「死链校验就是构建本身」的门禁,这正是想要的态度; 而那 4.6 MB 从此不进这份跨 run 的归档。代价:docs 输入没变时省不下那 20s。 - 缓存 key 从
…-turbo-quality-换成…-turbo-quality-v2-,restore-keys一起换: v1 的归档里已经混着那批产物条目,而restore-keys是前缀匹配 —— 只换 key 不换前缀, 照样会把 v1 那份拉回来。
⚠️ 改这条时踩的坑(实测):pnpm run build:docs -- --cache-dir=x 与 pnpm build:docs --cache-dir=x只差一个 --,行为完全不同 —— 前者被 turbo 当成透传给任务本身的参数(VitePress 收到 --cache-dir=x,缓存照旧写进默认目录),后者才到 turbo。判别法很简单:参数真到了 turbo ⇒ 新目录 是空的 ⇒ 这次必定 miss 并重建;被吞掉 ⇒ 直接命中、新目录不会出现。
收口实测(run #18,v2 的第一次跑):Restore 0s(v2 还没有归档可命中)、Docs build 20s 真跑(没有回放)、job 末尾保存出的 v2 归档 32,194 字节(32 KB) —— 对照 v1 最后那份 9.8 MB 差 306 倍,docs 构建产物确实不再进来了(判据:actions/caches API,公开可读)。
已上门禁:上面 §六 的第 12/13 条 —— 那条断言直接解析 ci.yml,所以这个坑的任何一种复发 (新加一步跑 build、或把 --cache-dir 写到 -- 后面)都会在推送前当场红。
7.3 当时为什么不算正确性问题(留档)
万一把它留在缓存里也不会"误用":apps/docs/turbo.json 特意覆盖了 build.inputs 把 .md 纳回哈希 —— 根 build 的 inputs 是 !**/*.md,沿用会让「改文档不触发重建」⇒ 本地 pnpm build:docs 直接 重放旧 dist、静默跳过 VitePress 的死链校验(那段注释写的就是这句话)。hash 覆盖了 docs 的 全部输入 ⇒ 只有输入完全相同时才可能回放。把它挡在外面是为了体积口径,不是因为回放会出错 —— 这与下面「为什么这条的风险比看上去低」是同一条推理。
两个容易做错的点
- key 必须 rolling。
actions/cache只在 key 不存在时才保存 ⇒ 用固定的 「锁文件哈希」当 key,会让缓存冻在第一次写入那一版, 之后新产生的 task 条目永远进不去、命中率越用越低。现在写成key: <os>-turbo-quality-v2-<本次 sha>+restore-keys: <os>-turbo-quality-v2-: 每次恢复"最近的一份",保存一份新的(几十 KB,无所谓)。 - 别把 admin / server 的 build 也搬进这条 job(见上表)。它俩才是 385 MB 的来源, 而只值约 80s。
为什么这条的风险比看上去低
「缓存 key 设计错 ⇒ 命中陈旧缓存 ⇒ 门禁静默回放旧结论」是对的担心,但它不是新的风险类别:
- turbo 的缓存条目按 task hash 校验 —— 恢复到不匹配的条目只会 miss,不会误用;
- 本仓每次 push 都在用同一套机制(prepush 里
turbo run lint:root与turbo run types:check本来就命中本地缓存),CI 只是把同一份缓存持久化到下一次 run; - 真正决定"会不会误用"的是 hash 覆盖面(env 有没有声明、inputs 全不全), 而那正是
pnpm lint:turbo-cache那一组不变量的职责(见 §六)。
实测确认了前提:CI 注入的 CI / TURBO_SCM_BASE / TURBO_SCM_HEAD / GITHUB_ACTIONS都不改变 task hash(比对同一个 task 的 hash,完全一致)⇒ 粗 key 不会"永远 miss"。
与远端缓存的关系:本仓早已决定不接入远端缓存 —— 那需要外部服务与 token。 这一条是它的本地替代:只在 CI 内部持久化,不引任何外部依赖。
相关文档
- Turbo —— 任务编排与边界(本页是它的「缓存」那一半的展开)
- package.json & Scripts —— 根脚本与包脚本的分工
- 📋 架构待办事项 —— 4.4 与「本仓比 Z 仓弱的地方」都登记在这里