Repository navigation
ci(docs): Build Docs runs the production build command, read from vercel.json - #11265
Merged
Merged
Conversation
…cel.json `Build Docs` ran `pnpm --filter @objectstack/docs build` — a second, hand-maintained spelling of the production build. Production runs whatever `apps/docs/vercel.json` declares, and nothing in this repo read that file, so the two were free to drift silently and did: the gate was green on nine PRs while the production build of the same app was failing. Read the command from `vercel.json` instead of copying it, run it from `apps/docs` the way Vercel does, and assert `.next/BUILD_ID` afterwards because a filter matching no package exits 0 having built nothing. `TURBO_FORCE` is the one deliberate difference: the docs build task declares no input under `content/`, so a content-only commit is a turbo cache hit and would let the gate pass on a replayed artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPBxLsqQGCVL5z5UXvgipH
os-zhuang
marked this pull request as ready for review
August 23, 2026 07:57
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.
Fixes #10642
The divergence, measured
Not the path filter. The
docsfilter already listscontent/**and is fail-open, so the greens on epic #10206 were real runs — that candidate was already struck in the card's unblock note and I did not re-derive it.The divergence is that
Build Docsnever ran the production build at all. It ranpnpm --filter @objectstack/docs build, a second, hand-maintained spelling of it. Production runs whateverapps/docs/vercel.jsondeclares, and nothing in this repo read that file — agrep -rl vercelacross every workflow, script and config returnsapps/docs/vercel.jsonitself,apps/docs/next.config.mjs(a comment naming it),apps/docs/redirects.mjs, and four unrelated files about the Vercel AI SDK. No reader. So the gate's command and the shipped command were free to drift apart with nothing to notice, which is the general form of the specific failure the card recorded.A gate that re-implements the thing it guards can only ever test the re-implementation.
What this changes
.github/workflows/ci.yml, theBuild Docsjob:apps/docs/vercel.jsoninstead of copied. The step runs withworking-directory: apps/docs, because Vercel runsbuildCommandfrom the project's Root Directory — which is why the stored command opens withcd ../... Changing how production builds now changes this gate in the same commit, by construction. That is the part that survives the next divergence rather than pinning today's..next/BUILD_IDis asserted afterwards. Exit code 0 is not evidence the app was built:pnpm --filter <name>with a filter matching no package printsNo projects matched the filtersand exits 0 (measured below). Vercel's symptom for that is a deploy-timeNo Output Directory found, i.e. after the gate has already reported green.TURBO_FORCE: 'true'— the one deliberate difference from production, and it is load-bearing.@objectstack/docs#buildinherits turbo's genericbuildtask, whose inputs are the package's own 35 files, none undercontent/. Appending a line tocontent/docs/index.mdxleft the task hash at4e0c51cb3db203a8— a cache HIT. A content-only PR is the normal shape of a docs change and precisely the shape this gate must be able to fail, so the gate must not be satisfiable by a replayed artifact. The same hole on the production side is a different defect and is filed separately as@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; it is not addressed here.The acceptance test: manufactured breakages, and what the gate did
The card's suggested lever — re-introducing
output: 'standalone'— is gone (PR #10652 removed it andnext.config.mjsnow explains why it must not return), so it was not used. Instead the breakage is applied to the file that defines the production build. Both fixtures are ephemeral: applied locally, proven on disk, reverted by anEXIT/INT/TERMtrap. Nothing in this diff is a fixture. The legs execute the actualrun:bodies extracted fromci.ymlwith the YAML parser, not a paraphrase of them.Breakage A — the production build command names a package that no longer exists (a rename or typo in
vercel.json; production build fails):pnpm --filter @objectstack/docs build).next/BUILD_ID=1b78aPQIaZ8tdciC3Avb7and reported success while production is brokenx No package found with name '@objectstack/docs-renamed' in workspaceThat pair is the whole card in two rows: same tree, same broken production config, gate green before and red after.
Breakage B — the production build command exits 0 and builds nothing (
cd ../.. && pnpm --filter @objectstack/docs-renamed build), run against a simulated fresh checkout where onlyapps/docs/.next/cacheis restored, asactions/cachedoes:No projects matched the filters— the build step alone cannot catch thisThe production build command exited 0 but produced no apps/docs/.next/BUILD_ID - nothing was built.So the assertion is load-bearing rather than decoration; it is the only thing that fails on B.
Healthy tree (the same extracted steps, clean committed tree): build step exit 0 in 190s,
Tasks: 2 successful,Cached: 0 cached, 2 total— confirmingTURBO_FORCEreally defeated the cache — and assert step exit 0,BUILD_ID=0_cmVtBk3cHW3hhSrb-Yl.The cost trade, stated rather than shipped quietly
The job now also builds
@objectstack/spec(turbo's^build) and forces execution instead of accepting a cache hit. Measured in this container: 190s vs 87s, about +1.7 min perBuild Docsrun, against the job's 30-minute timeout. No secrets and no new runner are needed — the production command is a plain workspace build. I think that is the right trade for a gate whose entire job is to be the thing that goes red, but it is the PM's call and it is reversible: droppingTURBO_FORCErecovers most of it and gives back exactly the cache-replay hole described above.Also in this diff, named because it is a scope exception
apps/docs/next.config.mjs: one clause of an existing comment claimed "Vercel builds this app with Turbopack viavercel.json'spnpm turbo run build". That conflates turborepo with Turbopack, and it is measured false in the direction that matters —next buildon Next 16 uses Turbopack in CI too: both this repo's CI command and the production command print▲ Next.js 16.3.1 (Turbopack). The bundler is not what differs between them. Corrected in place (comment only, no behaviour), because a false explanation of this exact divergence sitting in the config would contradict this PR. The comment's conclusion — do not reintroduceoutput: 'standalone'— is untouched.What this does NOT cover
The card's strongest surviving candidate was a Vercel-injected Next build adapter. The mechanism is real in the installed Next 16.3.1 —
config-shared.js:190resolvesadapterPathfromNEXT_ADAPTER_PATH, andbuild/index.js:2783carries the in-source note that standalone output "not be allowed if an adapter with onBuildComplete is configured". But it is unreachable for any CI gate: the adapter is Vercel's, is not public, and no runner can install it. It is also moot on this tree, sinceoutput: 'standalone'— the only thing that interacts with it — is gone. Closing that class needs a check on the deployment itself, not a build job; it is not claimed here.Verification
All gates below ran on
d4382b3with a clean tree, each exit code captured before any pipe.check:node-version,check:pnpm-filter-targets,check:published-files,check:required-contexts,check:shard-attestation,check:test-source-alias,check:type-source-resolution,check:workflow-status-functions,check:nul-bytes— all exit 0.check-aggregator-roster.mjs,check-ci-filter-parity.mjs,check-required-contexts.mjs,check-shard-attestation.mjs,check-step-collectors.mjs— all exit 0. The two that judge this file say so themselves:OK: all 88 declared cross-package glob(s) (76 unique) are covered by 'core' or 'crosspkg' …and✓ check-step-collectors: 355 'run:' steps across 26 workflow(s).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-built path list); it returned the 13 families above and no others.pnpm lintwas attempted and diedexit 134— V8 heap OOM under container contention, not a lint failure. Narrowed to the one changed JS file with three proofs: the population iseslint.config.mjs's ownignores/filesand it includes this file (eslint reported on it rather than skipping it);--format jsonreports exactly 1 file, 0 errors, 0 warnings; and the config configures noparserOptions.project/projectService, so with type-aware linting off a comment-only edit in one file cannot move any untouched file's verdict. The other changed file is YAML and outside eslint's population entirely.@objectstack/docsisprivate: true; the rest is workflow config. Labelledskip-changeset.Generated by Claude Code
Generated by Claude Code