Repository navigation
ci: wire the Vercel turbo remote cache into Build Core, signed and inert until configured - #21191
Merged
Merged
Conversation
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>
This was referenced Oct 1, 2026
os-zhuang
approved these changes
Oct 2, 2026
os-zhuang
marked this pull request as ready for review
October 2, 2026 01:57
os-zhuang
enabled auto-merge
October 2, 2026 01:57
This was referenced Oct 2, 2026
This was referenced Oct 2, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 byTURBO_REMOTE_CACHE_SIGNATURE_KEYand 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 stepBuild packages (excluding docs)gets four env lines, and nothing else in the file changes.TURBO_CACHEislocal:rw,remote:rwonmerge_groupand onpush/workflow_dispatchagainstmain, andlocal:rw,remote:rfor every other event. Read-only is the default branch of the expression. Both branches are non-empty literals.TURBO_TOKENis passed only when the signing-key secret is non-empty.TURBO_TEAMis a repository variable, and the key is a secret.pull_request_target, the shard and nightly workflows, the--forcelegs, and every required check name.Hash compatibility with cloud's framework build
Cloud's
test.ymlstepInstall framework deps + buildbuilds this repo at cloud's pin.4b4ee88fbc(cloud's current pin), turbo 2.10.10merge_grouprun 36674533149, Build Core, against cloudmerge_grouprun 36797594481TURBO_CACHElandedpushrun 36846326339--dry=jsonat2c1cef3345, turbo 2.11.5objectstack,.turbo/config.jsonwith signature on, cloud's step env with empty credentials, spec pass then full pass)globalCacheInputsbyte-identicalOS_SKIP_DTS=1No change is needed on either side. The one declared input that would split them is the
globalEnvvalueOS_SKIP_DTS, which neither workflow sets.Signing is hash-neutral
--dry=jsonoverbuild test test:repo typecheck, 231 tasks with a command:remoteCacheblock removed: 231/231 identical.globalEnventry: 0/231 identical.45d71bab).Audit: why only build tasks are wired, and what must land before stage 2
test/test:repo: kept off the remote. Their cross-package inputs are hand-declared, the detector that keeps them honest reads source spellings only, and the Test Core legs carry the hash history of [finding] 图说@objectstack/cli#test该被 spec 的改动波及,#17914 那次运行却说它是缓存重放、从未执行 —— 这才是让红落进main的那一环,而它至今没有卡 #18671 and [finding] 分片腿的任务哈希仍不携带上游闭包 —— #19271 的--force修好的是「执行」,根因是「哈希」,而恒真的绕过是一条不会再报警的电线 #19278.@objectstack/docs#build: kept off. Build Docs forces execution and gets no credentials. The input gap of@objectstack/docs#builddeclares no input undercontent/, so a content-only commit is a turbo cache HIT — the production build command can replay a stale.next#11264 is declared inturbo.jsontoday.build: under-declared, measured. For each probe I appended one comment line to a tracked file outside the task's declared inputs and re-derived the plan.scripts/tsup-drop-sources-content.mjstsup.config.tsand 21 package tsup configs.mapfilescripts/sync-scaffold-emission-policy.mjscreate-objectstackbuilddist/templatespackages/cli/src/commands/init.tscreate-objectstack#buildunchanged (cli moved)dist/templatesscripts/check-dev-prereqs.mjsdist/scripts/check-regen-pending.mjsgen:schemascripts/check-dts-emitted.mjstsup.config.tsmoved 72/72, andpackages/create-objectstack/tsup.config.tsmovedcreate-objectstack#build.create-objectstack: I changedSCAFFOLD_TYPESCRIPT_RANGEininit.tsand ran the build's first command. The template that tsup copies intodist/templateswent from^5.3.0to^5.9.0, whilecreate-objectstack#buildstayed840dbe274b784226.merge-base(HEAD, origin/main). That changes the verdict only, not the outputs.merge_grouptrees become writers, including trees that a lint gate then dequeues. Cloud also replays them at older pins.turbo.jsonchange 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.
remote:rw(today)Remote caching disabled (remote cache requested — set TURBO_TOKEN and TURBO_TEAM, …)TURBO_TEAMset, token emptyTURBO_TEAMemptyRemote caching disabled (TURBO_TOKEN set without TURBO_TEAM)TURBO_CACHE=''WARNING no caches are enabled,cache bypass, force executingremote:rRemote cache is read-only, skipping uploadremote:rwRemote caching enabledx-artifact-tagpresentremote:r, after that writecache hit, replaying logs, summarysource: REMOTETURBO_REMOTE_CACHE_SIGNATURE_KEY is too short (0 bytes)remote:rwWARNING artifact signature error, no uploadAcceptance notes
workflow_dispatchwrites only againstmain. 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.TURBO_*to the tasks it spawns, so a same-repo PR's build scripts hold the token onpull_request, exactly as in cloud. Fork PRs receive no secrets.apps/docs/vercel.jsonruns turbo outside CI. If that Vercel project uses a remote cache, signing without a key there givesartifact signature errorand every remote read misses. Builds still succeed.futureFlags.longerSignatureKeyto make a key shorter than 32 bytes fatal. It is outside this PR's surface.Generated by Claude Code