Skip to content

ci: wire the Vercel turbo remote cache into Build Core, signed and inert until configured - #21191

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-21186-turbo-remote-cache
Oct 2, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-21186-turbo-remote-cache

Conversation

@hotlong

@hotlong hotlong commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Part of #21186

Clause-②: no. This is CI wiring and changes no contract's acceptance or rejection behaviour.

Stage 1 of the card: measure, audit, then inert wiring. The card stays OPEN for stage 2, where the maintainer sets the two missing values. The stage-2 checklist and the inertness run URLs are in the card's report comment.

What changes

  • turbo.json: remoteCache.signature: true. Turbo then tags every remote-cache upload with an HMAC keyed by TURBO_REMOTE_CACHE_SIGNATURE_KEY and verifies the tag on every download. It is not a task hash input (measured below), and it does nothing while remote caching is off.
  • .github/workflows/ci.yml: the Build Core step Build packages (excluding docs) gets four env lines, and nothing else in the file changes.
    • TURBO_CACHE is local:rw,remote:rw on merge_group and on push / workflow_dispatch against main, and local:rw,remote:r for every other event. Read-only is the default branch of the expression. Both branches are non-empty literals.
    • TURBO_TOKEN is passed only when the signing-key secret is non-empty. TURBO_TEAM is a repository variable, and the key is a secret.
  • Untouched: every other job and step, pull_request_target, the shard and nightly workflows, the --force legs, and every required check name.

Hash compatibility with cloud's framework build

Cloud's test.yml step Install framework deps + build builds this repo at cloud's pin.

reading where result
Real CI logs at 4b4ee88fbc (cloud's current pin), turbo 2.10.10 objectstack merge_group run 36674533149, Build Core, against cloud merge_group run 36797594481 72/72 identical task hashes
The same, after cloud's signing config and TURBO_CACHE landed against cloud push run 36846326339 72/72 identical
Local --dry=json at 2c1cef3345, turbo 2.11.5 Build Core's command and env against cloud's (separate checkout under a directory named objectstack, .turbo/config.json with signature on, cloud's step env with empty credentials, spec pass then full pass) 72/72 identical, and globalCacheInputs byte-identical
Control for the row above cloud side with OS_SKIP_DTS=1 0/72

No change is needed on either side. The one declared input that would split them is the globalEnv value OS_SKIP_DTS, which neither workflow sets.

Signing is hash-neutral

--dry=json over build test test:repo typecheck, 231 tasks with a command:

  • HEAD against HEAD with the remoteCache block removed: 231/231 identical.
  • Positive control, one extra globalEnv entry: 0/231 identical.
  • The mutation was restored from HEAD and verified by blob hash (45d71bab).

Audit: why only build tasks are wired, and what must land before stage 2

file read by build hashes moved what it affects
scripts/tsup-drop-sources-content.mjs root tsup.config.ts and 21 package tsup configs 0/72 every .map file
scripts/sync-scaffold-emission-policy.mjs create-objectstack build 0/72 dist/templates
packages/cli/src/commands/init.ts the same script create-objectstack#build unchanged (cli moved) dist/templates
scripts/check-dev-prereqs.mjs spec, core, organizations, plugin-auth 0/72 the stamp in dist/
scripts/check-regen-pending.mjs spec gen:schema 0/72 the schema stamp
scripts/check-dts-emitted.mjs every build 0/72 verdict only
  • Controls: the root tsup.config.ts moved 72/72, and packages/create-objectstack/tsup.config.ts moved create-objectstack#build.
  • Output effect, create-objectstack: I changed SCAFFOLD_TYPESCRIPT_RANGE in init.ts and ran the build's first command. The template that tsup copies into dist/templates went from ^5.3.0 to ^5.9.0, while create-objectstack#build stayed 840dbe274b784226.
  • The spec build's authorable-surface check also anchors on merge-base(HEAD, origin/main). That changes the verdict only, not the outputs.
  • Today, these gaps already exist under the actions/cache replay.
  • The remote cache widens them in two ways. merge_group trees become writers, including trees that a lint gate then dequeues. Cloud also replays them at older pins.
  • So the input declarations must land before the secrets are set. That is a turbo.json change outside this PR's surface. It is reported on the card as a stage-2 precondition. Until then this wiring stays inert.

turbo 2.11.5, re-measured against a mock remote cache

The card's facts were measured on 2.9/2.10. This repo is on 2.11.5, so I measured them again.

env summary line requests
all three credentials empty, remote:rw (today) Remote caching disabled (remote cache requested — set TURBO_TOKEN and TURBO_TEAM, …) none
TURBO_TEAM set, token empty the same none
token set, TURBO_TEAM empty Remote caching disabled (TURBO_TOKEN set without TURBO_TEAM) none
TURBO_CACHE='' WARNING no caches are enabled, cache bypass, force executing none
all set, remote:r Remote cache is read-only, skipping upload GET only, no PUT
all set, remote:rw Remote caching enabled PUT with x-artifact-tag present
all set, remote:r, after that write cache hit, replaying logs, summary source: REMOTE GET
wrong key miss GET only
key empty, token set TURBO_REMOTE_CACHE_SIGNATURE_KEY is too short (0 bytes) GET, still signed. The wiring never passes the token in this state.
key unset, token set, remote:rw WARNING artifact signature error, no upload GET

Acceptance notes

  • workflow_dispatch writes only against main. That is narrower than the card's list. A dispatch from a feature branch runs that branch's unreviewed tree, which is the case the read-only default exists for.
  • schedule (the hourly full run) reads only. The card lists three writer events, and this follows it.
  • Same-repo PRs hold the token. Turbo hands TURBO_* to the tasks it spawns, so a same-repo PR's build scripts hold the token on pull_request, exactly as in cloud. Fork PRs receive no secrets.
  • Not measured: the Vercel docs build. apps/docs/vercel.json runs turbo outside CI. If that Vercel project uses a remote cache, signing without a key there gives artifact signature error and every remote read misses. Builds still succeed.
  • Option, not taken: turbo 2.11 offers futureFlags.longerSignatureKey to make a key shorter than 32 bytes fatal. It is outside this PR's surface.

Generated by Claude Code

hotlong and others added 2 commits October 1, 2026 23:05
Turbo now tags every remote-cache upload with an HMAC of the artifact
keyed by TURBO_REMOTE_CACHE_SIGNATURE_KEY and verifies the tag on every
download, so an entry that was not produced by a holder of the key is a
miss rather than a replay. The setting is inert while remote caching is
off, which it is until CI is given a token and team; it is not a task
hash input.

Claude-Session: https://claude.ai/code/session_57c17198-e47f-48f8-9242-3fc94c651c85
Co-authored-by: Claude <noreply@anthropic.com>
…ntil configured

Build Core's `pnpm build` step gets the remote-cache policy and
credentials: TURBO_CACHE is local:rw,remote:rw on merge_group and on
push / workflow_dispatch against main, and local:rw,remote:r for every
other event. TURBO_TOKEN is passed only when the signing key is set,
TURBO_TEAM is read from a repository variable, and the signing key from
a secret. Neither the variable nor the key exists yet, so remote caching
stays off and the build runs exactly as before.

Only build tasks are wired; test, test:repo and the forced docs build
stay off the remote cache.

Claude-Session: https://claude.ai/code/session_57c17198-e47f-48f8-9242-3fc94c651c85
Co-authored-by: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang marked this pull request as ready for review October 2, 2026 01:57
@os-zhuang
os-zhuang enabled auto-merge October 2, 2026 01:57
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 4b09689 Oct 2, 2026
39 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-21186-turbo-remote-cache branch October 2, 2026 02:29
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
objectstack-ai#21193) (objectstack-ai#21199)

Fixes objectstack-ai#21193

Clause-②: no. Turbo hash declarations change no contract's acceptance or
rejection behaviour.

This is finding F1 of the stage-1 report on objectstack-ai#21186, the precondition for
that card's stage 2. objectstack-ai#21186 stays open; its draft PR objectstack-ai#21191 (the
`remoteCache` block) is untouched here.

## What changes

`turbo.json` only, `build` task family only. Each build now declares the
root files its build command loads, as task-level `$TURBO_ROOT$/…`
inputs beside `$TURBO_DEFAULT$`:

| task | added inputs | why it reads them |
|:--|:--|:--|
| `build` (generic) | `scripts/tsup-drop-sources-content.mjs`,
`scripts/check-dts-emitted.mjs`, `scripts/invoked-as.mjs` | the root
`tsup.config.ts` (loaded with `--config` by 47 builds) and 19 package
tsup configs import the first, 66 builds in all (it shapes every
`.map`); 67 build commands end with `node …/check-dts-emitted.mjs`,
which imports the third |
| `@objectstack/spec#build` | the generic three, plus
`check-dts-references.mjs`, `ts-parse.mjs`, `check-regen-pending.mjs`,
`regen-artifacts.mjs`, `git-env.mjs`, `import-prerequisite.mjs`,
`cli-build-prerequisite.mjs`, `check-dev-prereqs.mjs`,
`build-input-hash.mjs`, `workspace-enumerator.mjs`,
`js-comment-mask.mjs` | `gen:schema` (`build-schemas.ts`) imports
`check-regen-pending.mjs` to write the `json-schema/` stamp; the build
runs `check-dts-references.mjs` and `check-dev-prereqs.mjs --stamp`; the
rest is their import closure |
| `@objectstack/core#build`, `@objectstack/organizations#build`,
`@objectstack/plugin-auth#build` | the generic three, plus
`check-dev-prereqs.mjs`, `build-input-hash.mjs`,
`workspace-enumerator.mjs`, `js-comment-mask.mjs` | each build ends with
`check-dev-prereqs.mjs --stamp`; `build-input-hash.mjs` computes the
stamp bytes written into `dist/` |
| `create-objectstack#build` | `check-dts-emitted.mjs`,
`invoked-as.mjs`, `sync-scaffold-emission-policy.mjs`,
`sync-template-versions.mjs`, `packages/cli/src/commands/init.ts` | its
first step reads the `SCAFFOLD_*` policy out of `init.ts` and stamps it
into the template that tsup copies to `dist/templates`; it does not run
the root tsup config, so `tsup-drop-sources-content.mjs` is not declared
there |

All paths above are under `scripts/` unless spelled in full. The rule
for "is it an input": a file Node loads while the build command runs.
That is the static import closure of every script the command executes
and every tsup config it loads, measured by walking the relative
imports, not by reading names. Each of these files can change a build's
output bytes (`tsup-drop-sources-content.mjs`, `build-input-hash.mjs`,
`check-regen-pending.mjs`, `sync-scaffold-emission-policy.mjs`,
`init.ts`) or its pass/fail verdict (all of them, at minimum by failing
to load).

Not `globalDependencies`: `globalCacheInputs` is byte-identical before
and after. A root `pkg#task` key replaces the generic definition instead
of merging with it (measured on turbo 2.11.5: an override carrying only
`inputs` resolved to `dependsOn: []` and `outputs: []`), so each
override restates the generic `dependsOn` and `outputs` verbatim.
Measured: across all 320 tasks of `build test test:repo typecheck` (231
with a command), every resolved task definition is identical before and
after except `inputs`.

## Probe (the objectstack-ai#21186 stage-1 method, re-run)

Method: append one comment line to the file, run `turbo run build test
test:repo typecheck --dry=json`, compare every task hash with the
unedited plan, restore with `git checkout HEAD -- FILE`, and prove the
restore by blob hash. Before = base `ebdb6f2aca`; after = `120faa2030`.
Turbo 2.11.5. Build Core has 72 build tasks; `+docs` means
`@objectstack/docs#build` moved too. Expected = the direct readers'
build tasks plus every build downstream of them in the task graph.
"Exact" means the moved set equals the expected set task for task, not
only in count.

| file | direct readers | build moved, before | build moved, after |
expected | test / test:repo / typecheck moved, before → after |
|:--|--:|--:|--:|:--|:--|
| `scripts/tsup-drop-sources-content.mjs` | 66 | 0/72 | 72/72 +docs |
exact | 1/1/0 → 70/6/75 |
| `scripts/sync-scaffold-emission-policy.mjs` | 1 | 0/72 | 6/72 | exact
| 2/1/0 → 7/1/7 |
| `packages/cli/src/commands/init.ts` | 2 | 5/72 (no
`create-objectstack#build`) | 6/72 | exact | 8/5/7 → 8/5/7 |
| `scripts/check-dev-prereqs.mjs` | 4 | 0/72 | 71/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/check-regen-pending.mjs` | 1 | 0/72 | 71/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/check-dts-emitted.mjs` | 67 | 0/72 | 72/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/check-dts-references.mjs` | 1 | 0/72 | 71/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/invoked-as.mjs` | 67 | 0/72 | 72/72 +docs | exact | 2/1/0 →
70/6/75 |
| `scripts/build-input-hash.mjs` | 4 | 0/72 | 71/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/workspace-enumerator.mjs` | 4 | 0/72 | 71/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/js-comment-mask.mjs` | 4 | 0/72 | 71/72 +docs | exact |
13/2/0 → 71/6/75 |
| `scripts/regen-artifacts.mjs` | 1 | 0/72 | 71/72 +docs | exact | 1/1/0
→ 70/6/75 |
| `scripts/git-env.mjs` | 1 | 0/72 | 71/72 +docs | exact | 2/1/0 →
70/6/75 |
| `scripts/import-prerequisite.mjs` | 1 | 0/72 | 71/72 +docs | exact |
1/1/0 → 70/6/75 |
| `scripts/cli-build-prerequisite.mjs` | 1 | 0/72 | 71/72 +docs | exact
| 2/1/0 → 70/6/75 |
| `scripts/ts-parse.mjs` | 1 | 0/72 | 71/72 +docs | exact | 1/1/0 →
70/6/75 |
| `scripts/sync-template-versions.mjs` | 1 | 0/72 | 6/72 | exact | 2/1/0
→ 8/1/7 |
| control: root `tsup.config.ts` | global | 72/72 +docs | 72/72 +docs |
unchanged | 73/6/79 → 73/6/79 |
| control: `packages/create-objectstack/tsup.config.ts` | 1 | 6/72 |
6/72 | exact | 7/5/8 → 7/5/8 |
| negative control: `scripts/check-turbo-task-graph.mjs` | 0 | 0/72 |
0/72 | nothing new | 1/1/0 → 1/1/0 |

The 71/72 rows: `@objectstack/sdui-parser#build` is the only build that
is not downstream of `@objectstack/spec#build`, and it does not load
those files.

**End to end, on the real cache.** `turbo run build
--filter=create-objectstack --only`, then `SCAFFOLD_TYPESCRIPT_RANGE` in
`init.ts` edited from `^5.3.0` to `^5.9.0` and run again. Before
(`ebdb6f2aca`): `cache hit, suppressing logs d14ac0ba80366056`, and
`dist/templates/blank/package.json` still says `^5.3.0`. After
(`120faa2030`): `cache miss, executing 78b5115918e44c53`, and the dist
template says `^5.9.0`. Restored, the next run is a hit on the original
hash `b336dcf35aaf1381` with `^5.3.0` again.

## Before/after plan diff (this commit itself)

`turbo run build --dry=json` (same hashes as the four-task run, 80/80
identical) from `ebdb6f2aca` to `120faa2030`:

- Input keys: the 67 generic build tasks with a command gain exactly the
three generic files; spec gains its 14; core, organizations and
plugin-auth gain their 7; create-objectstack gains its 5. Zero input
keys are removed or re-hashed on any task, so `$TURBO_DEFAULT$` keeps
the default file set. Zero test, test:repo or typecheck tasks gain or
lose an input key. `@objectstack/docs#build` inputs are unchanged.
- Hashes: 73/73 build tasks with a command move once (the definition
changed; docs through its `^build` edge). This is the one-time "every
build hash moves once" the stage-1 option A priced.
- Test and typecheck: 70/73 test, 5/6 test:repo and 75/79 typecheck
hashes move once. That is turbo folding each dependency task's hash into
the dependent's hash along `dependsOn: ["^build"]`, not a declaration on
those tasks. The 8 that do not move are exactly the tasks with zero task
dependencies (`spec`, `sdui-parser` and `vitest-filter-preflight` test;
`spec#test:repo`; `refd-timer-testkit`, `sdui-parser`, `spec` and
`vitest-filter-preflight` typecheck).

The same mechanism explains the probe's test column: after this change,
an edit to a spec-closure script moves 70/6/75, against 73/6/79 for the
root `tsup.config.ts` global control. The difference is the
zero-dependency tasks, which a `globalDependencies` entry would also
have moved.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands` at
`120faa2030`. All 9 ran and exited 0: `check-closing-keyword-parity`
(plus its `--self-test`), `check-comment-mask-corpus`,
`check:driver-memory-census`, `check:gitlink-declared`,
`check:nul-bytes`, `check:refd-timer-probe`, `check:turbo-task-graph`
(43 package-scoped and 9 generic tasks judged),
`check:watch-hint-literal`. `dispatch-gates --ran`: 9 derived, 9 run, 0
NOT-MEASURED, 0 unrun. No guard asserts build inputs. I also ran the
other gates that parse `turbo.json` or read the declared files, all exit
0: `check:cross-package-test-inputs`, `check-ci-filter-parity`
(`--self-test` and the run), `check:examples-live-imports`,
`check-dev-prereqs.mjs --self-test` (it reads `globalDependencies`,
which is unchanged) and `check:scaffold-emission-policy`.

Labelled `skip-changeset`: `turbo.json` is repo-root configuration and
ships in no package.

## Acceptance notes

- **The declared set is wider than the card's table, on the card's own
rule.** Beyond the five named rows, it adds `check-dts-references.mjs`
(the spec build runs it; it was in the stage-1 option A list but not in
the card's table) and the import closure of every script: `invoked-as`,
`build-input-hash`, `workspace-enumerator`, `js-comment-mask`,
`regen-artifacts`, `git-env`, `import-prerequisite`,
`cli-build-prerequisite`, `ts-parse` and `sync-template-versions`.
Before this change each one moved 0/72 builds, the same as the named
rows. `build-input-hash.mjs` decides the stamp bytes written into
`dist/` and `json-schema/`.
- **The generic key over-reaches on five tasks at the declaration level
only.** `@objectstack/cli#build` (tsc) and the four `example-*#build`
tasks (`objectstack build`) load none of the three generic files. Each
is downstream of `@objectstack/spec#build`, which does load them, so
their moved set is the same either way (the probe rows are exact).
Splitting them out would take five more overrides for no hash
difference.
- **`@objectstack/docs#build` is left alone.** Its build runs the spec
`gen:schema` and `gen:docs`, whose out-of-package imports were measured
to be a subset of the spec closure above. It reaches them through its
`^build` edge to `@objectstack/spec#build`, the same edge that already
carries `packages/spec/scripts/**` to it. Every spec-closure row shows
`+docs`.
- **Not declarable as a turbo input:** the spec build's
authorable-surface check anchors on `merge-base(HEAD, origin/main)`,
which is git state, and it is verdict-only (from the stage-1 audit).
- **Nothing keeps these lists honest.** The test side has
`check:cross-package-test-inputs`, but no gate compares a build's
declared inputs with its import closure. A new relative import in any of
these scripts reopens this gap silently. Carrier: none.
- **Scheduling, not hashing, at PR time.** `ci.yml`'s `core` filter
matches none of `scripts/**`, the root `tsup.config.ts` or `turbo.json`
(picomatch 4.0.5 against the checked-in list). So a PR confined to these
files, this one included, skips Build Core and its build-time gates (for
example sourcemap-no-sources-content) until the merge queue, where the
filter defaults to true. This PR leaves `ci.yml` alone by constraint.
The Type Check lane still executes the new build definitions through
`typecheck`'s `^build` edge.

---
_Generated by [Claude
Code](https://claude.ai/code/session_57c17198-e47f-48f8-9242-3fc94c651c85)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants