Skip to content

[utils] Fix vercel world not being selected when running build on external CI - #3144

Merged
VaguelySerious merged 3 commits into
mainfrom
peter/prebuilt-world-detection
Jul 28, 2026
Merged

[utils] Fix vercel world not being selected when running build on external CI#3144
VaguelySerious merged 3 commits into
mainfrom
peter/prebuilt-world-detection

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 27, 2026

Copy link
Copy Markdown
Member

Reported by users upgrading 5.0.0-beta.225.0.0-beta.36: the SDK used its local filesystem world inside a Vercel deployment when using vercel build + vercel deploy --prebuilt in CI.

Cause

Static world injection moved world selection from runtime to build time. The detection rule itself never changed — VERCEL_DEPLOYMENT_ID ? vercel : local — only when it runs.

VERCEL_DEPLOYMENT_ID does not exist while vercel build runs: no deployment has been created yet. A vercel pull --environment production env contains VERCEL=1, VERCEL_ENV, VERCEL_TARGET_ENV, VERCEL_URL, VERCEL_OIDC_TOKEN and the VERCEL_GIT_* set, but no deployment ID. So a prebuilt build compiles @workflow/world-local into the deployment, @workflow/next additionally points WORKFLOW_LOCAL_DATA_DIR at .next/workflow-data, and the deployment writes workflow state to a read-only filesystem.

Nothing surfaced it. No guard warned when the resolved world was local on Vercel; @workflow/world-local is always resolvable, so there was no missing-module error; and ensureDir() in world-local swallowed every mkdir error, so the read-only filesystem showed up later as ENOENT on a state file rather than as EROFS on the data directory. Before static injection the same rule was evaluated per cold start, where the deployment ID does exist — hence the regression at beta.28 rather than a change in behavior anyone opted into.

Change

Selection. isVercelDeploymentEnv() in @workflow/utils is now the single answer to "is this a Vercel deployment, or a build that becomes one", used by both resolveWorkflowTargetWorld and getWorldImport:

  • VERCEL_DEPLOYMENT_ID present → Vercel (runtime, and Vercel's own build container).
  • otherwise VERCEL=1 → Vercel, unless VERCEL_ENV=development (vercel dev) or NODE_ENV=development (any framework dev server).

VERCEL=1 alone is not sufficient because vercel env pull writes it into .env.local, which frameworks load before the config runs — without the development exclusions a plain pnpm dev would flip to the Vercel world.

workflows.local.port in @workflow/next is keyed to the resolved local world instead of the deployment ID, since that option only feeds world-local's callback base URL. Note the behavior change: it previously set PORT whenever no deployment ID was present, regardless of world.

Both wrong-world directions now fail with an actionable message, so a misdetection names its cause instead of surfacing as an unrelated error:

  • Vercel world outside a deploymentstart() awaits world.getDeploymentId() before writing any state, and world-vercel throws there when VERCEL_DEPLOYMENT_ID is missing. That throw existed; its message named only the env var. One message in packages/world-vercel/src/deployment-id.ts now explains that the world was selected at build time and that WORKFLOW_TARGET_WORLD=local + a rebuild is the fix, shared by getDeploymentId(), queue(), and resolveLatestDeploymentId().
  • Local world inside a deploymentensureDir() throws UnwritableDataDirError (a WorkflowWorldError) when the data directory cannot be created with EROFS/EACCES/EPERM and does not already exist, instead of letting the caller's write fail as ENOENT. This catches every misdetection variant, including one env sniffing cannot: a project with system environment variables disabled has no VERCEL* at runtime either. world-local also warns when it is created inside a Vercel deployment with a data directory outside the temp dir, which covers deployments built by an older SDK; the temp dir is exempt because it is writable on Vercel and therefore a deliberate choice.

WORKFLOW_TARGET_WORLD continues to override in both directions, and the docs now describe build-time selection and both failure modes.

Verification

Real vercel build --prod against the linked nextjs-turbopack project (fresh compile, not a cache hit), pulled production env, no deployment ID:

before after
grep -rl WORKFLOW_LOCAL_DATA_DIR .next/server 4 files 0 files
grep -rl 'world-vercel|WORKFLOW_VERCEL' .next/server 0 files 37 files

Running that build output under a Vercel runtime env reproduces the customer's failure before the change (POST /api/workflows/start200, .workflow-data/{events,hooks,runs} written to disk) and after it selects the Vercel world instead.

Serving that same build outside a deployment (the pulled-prod-env next start case raised in review) cannot reach production state: the run fails at getDeploymentId() before any write and before credentials are used — 500 VERCEL_DEPLOYMENT_ID environment variable is not set, no run created — which is the throw whose message this PR rewrote.

Dev is unaffected: with .env.local containing VERCEL=1 / VERCEL_ENV=preview, pnpm dev still resolves the local world, starts a run successfully, and emits no warning.

Trade-off

next build && next start on a developer machine whose .env.local carries pulled production env now resolves the Vercel world rather than the local one, and fails at the first start() with the message above. The alternative is leaving prebuilt pipelines broken by default; WORKFLOW_TARGET_WORLD=local plus a rebuild is the opt-out.

🤖 Generated with Claude Code

World selection moved from runtime to build time, but the detection rule
still keys only on VERCEL_DEPLOYMENT_ID. That variable does not exist while
`vercel build` runs — no deployment has been created yet — so prebuilt
pipelines (`vercel build` + `vercel deploy --prebuilt`) compile the local
filesystem world into the deployment, and every run fails writing to a
read-only filesystem.

Fall back to VERCEL=1, which is present whenever system environment
variables are exposed, and exclude development so that `VERCEL=1` arriving
in .env.local via `vercel env pull` does not flip a dev server onto the
Vercel world.

Also warn from the local world when it initializes inside a Vercel
deployment with a data directory outside /tmp, so a deployment built
against the wrong world says so instead of failing on filesystem writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6b32304

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 21 packages
Name Type
@workflow/world-local Patch
@workflow/utils Patch
@workflow/next Patch
@workflow/world-vercel Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/vitest Patch
@workflow/web Patch
@workflow/world-postgres Patch
@workflow/world-testing Patch
@workflow/errors Patch
@workflow/web-shared Patch
workflow Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nitro Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 28, 2026 5:39pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 28, 2026 5:39pm
example-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-express-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 28, 2026 5:39pm
workflow-swc-playground Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workflow-tarballs Ready Ready Preview, Comment Jul 28, 2026 5:39pm
workflow-web Ready Ready Preview, Comment Jul 28, 2026 5:39pm

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 6b32304 · Tue, 28 Jul 2026 17:58:46 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 208 (-74%) 💚 819 🔴 (-27%) 💚 1308 🔴 (+7.0%) 1467 🔴 (±0%) 30
TTFS stream 212 (-30%) 💚 874 🔴 (-25%) 💚 1308 🔴 (+6.2%) 1408 🔴 (+1.8%) 30
TTFS hook + stream 345 (-74%) 💚 1305 🔴 (-14%) 1785 🔴 (+6.2%) 2100 🔴 (+8.1%) 30
STSO 1020 steps (1-20) 177 (-12%) 260 🔴 (-27%) 💚 436 🔴 (+14%) 688 🔴 (+79%) 🔻 19
STSO 1020 steps (101-120) 210 (-13%) 270 🔴 (-33%) 💚 429 🔴 (-11%) 432 🔴 (-17%) 💚 19
STSO 1020 steps (1001-1020) 497 (-6.8%) 584 🔴 (-7.0%) 676 🔴 (+0.6%) 869 🔴 (+28%) 🔻 19
WO 1020 steps 410591 (-23%) 💚 410591 (-23%) 💚 410591 (-23%) 💚 410591 (-23%) 💚 1
SL stream latency 101 (-17%) 💚 499 🔴 (+146%) 🔻 599 🔴 (+138%) 🔻 805 🔴 (+46%) 🔻 30
SO stream overhead (text) 163 (+9.4%) 443 🔴 (+9.7%) 576 🔴 (+18%) 🔻 1245 🔴 (+14%) 30
SO stream overhead (structured) 125 (-9.4%) 371 🔴 (+24%) 🔻 583 🔴 (+65%) 🔻 1279 🔴 (+56%) 🔻 30
📜 Previous results (2)

9d62ee2

Tue, 28 Jul 2026 00:04:59 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1272 (+72%) 🔻 1355 🔴 (+20%) 🔻 1387 🔴 (+15%) 1479 🔴 (+8.2%) 30
TTFS stream 317 (+1.0%) 1334 🔴 (+21%) 🔻 1341 🔴 (+17%) 🔻 1440 🔴 (+23%) 🔻 30
TTFS hook + stream 1235 (-2.6%) 1620 🔴 (+16%) 🔻 1785 🔴 (+22%) 🔻 5485 🔴 (+235%) 🔻 30
STSO 1020 steps (1-20) 176 (+3.5%) 272 🔴 (-9.9%) 337 🔴 (-4.3%) 354 🔴 (-6.6%) 19
STSO 1020 steps (101-120) 186 (-5.6%) 267 🔴 (-21%) 💚 300 🔴 (-27%) 💚 370 🔴 (-26%) 💚 19
STSO 1020 steps (1001-1020) 466 (-3.5%) 553 🔴 (-3.7%) 634 🔴 (-1.9%) 810 🔴 (+23%) 🔻 19
WO 1020 steps 404145 (-7.9%) 404145 (-7.9%) 404145 (-7.9%) 404145 (-7.9%) 1
SL stream latency 100 (+7.5%) 154 🔴 (-8.3%) 174 🔴 (-26%) 💚 3325 🔴 (+1214%) 🔻 30
SO stream overhead (text) 115 (-12%) 161 (-44%) 💚 196 (-45%) 💚 267 (-37%) 💚 30
SO stream overhead (structured) 106 (-21%) 💚 156 (-40%) 💚 168 (-40%) 💚 513 (+24%) 🔻 30

fa7d14e

Mon, 27 Jul 2026 23:01:15 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 319 (-57%) 💚 1352 🔴 (+20%) 🔻 1443 🔴 (+19%) 🔻 1847 🔴 (+35%) 🔻 30
TTFS stream 222 (-29%) 💚 1343 🔴 (+22%) 🔻 1367 🔴 (+19%) 🔻 1431 🔴 (+22%) 🔻 30
TTFS hook + stream 359 (-72%) 💚 1535 🔴 (+9.5%) 1572 🔴 (+7.6%) 1655 🔴 (+1.2%) 30
STSO 1020 steps (1-20) 176 (+3.5%) 312 🔴 (+3.3%) 400 🔴 (+14%) 716 🔴 (+89%) 🔻 19
STSO 1020 steps (101-120) 205 (+4.1%) 281 🔴 (-17%) 💚 311 🔴 (-24%) 💚 481 🔴 (-3.8%) 19
STSO 1020 steps (1001-1020) 485 (±0%) 575 🔴 (±0%) 637 🔴 (-1.4%) 694 🔴 (+5.6%) 19
WO 1020 steps 409686 (-6.7%) 409686 (-6.7%) 409686 (-6.7%) 409686 (-6.7%) 1
SL stream latency 100 (+7.5%) 181 🔴 (+7.7%) 215 🔴 (-8.1%) 382 🔴 (+51%) 🔻 30
SO stream overhead (text) 110 (-16%) 💚 231 (-20%) 💚 284 (-20%) 💚 351 (-17%) 💚 30
SO stream overhead (structured) 118 (-13%) 207 (-20%) 💚 256 (-7.9%) 361 (-13%) 30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

📦 Local Production (1 failed)

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYMWYGQY0GXFDWY5H4JXEHHH

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
❌ 📦 Local Production 1620 1 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7518 1 1132 8651
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

❌ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
❌ nextjs-webpack-stable 153 1 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@VaguelySerious VaguelySerious changed the title [utils] Select the Vercel world for builds without a deployment ID [utils] Fix vercel world not being selected when running build on external CI Jul 27, 2026

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with the Slack thread context — fixing the build-time inference signal (rather than reverting #2752) makes sense, and the verification here is great: real vercel build against a linked project, bundle greps, and a before/after repro of the customer failure.

One substantive concern, inline on world-target.ts: the "ambiguity resolves toward Vercel because that failure is loud" premise doesn't hold when pulled production env includes VERCEL_OIDC_TOKEN — that direction can silently point a local next start at production. The rest are minor.

Two things that shouldn't get lost when the #3142/#3143 stack closes:

  • #3143's runtime hardening (actionable errors when a custom world package can't load, bundler-safety tests) is valuable independent of the build-vs-runtime decision.
  • The agents/feedback failure (Workflow target world was not statically injected from a server-action bundle) is an alias-coverage bug in the same mechanism — this PR fixes the inference signal but not that, so it needs its own fix.

Comment thread packages/utils/src/world-target.ts Outdated
Comment on lines +37 to +40
* framework dev server). Ambiguity resolves toward Vercel because that failure
* mode is loud — the Vercel world reports missing credentials — whereas
* wrongly choosing the local world silently writes workflow state to a
* read-only filesystem.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "fails loudly" premise doesn't hold in the most realistic instance of the ambiguity: vercel env pull --environment production writes VERCEL_OIDC_TOKEN into .env.local, and world-vercel authenticates via getVercelOidcToken() (packages/world-vercel/src/utils.ts:307-313), which reads it. So next build && next start on a dev machine with pulled prod env selects the Vercel world, authenticates successfully, and starts/executes workflows against production state (with step code running locally) for the OIDC token's validity window — no credential error at all. Pre-#2752 runtime selection resolved this same setup to the local world, so this is a new exposure, not a pre-existing one.

Two cheap mitigations (either or both):

  1. A symmetric runtime warning in world-vercel — the mirror of this PR's world-local warning: warn once when the Vercel world is created in a process with no VERCEL_DEPLOYMENT_ID. Then both wrong-world directions are observable.
  2. A build-time log line when the Vercel world is selected via the VERCEL=1 fallback rather than a deployment ID, naming WORKFLOW_TARGET_WORLD=local as the opt-out — makes the new inference visible in CI logs instead of only in bundle greps.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Checked this empirically, and the exposure doesn't exist — but for a reason that made the message the real defect, so this pushed the PR in the direction you asked for anyway.

start() awaits world.getDeploymentId() before it writes anything (packages/core/src/runtime/start.ts:278, ahead of run creation and unconditional even when opts.deploymentId is given), and world-vercel's getDeploymentId() reads process.env.VERCEL_DEPLOYMENT_ID and throws when it is unset — before any credential is used. So next build && next start with pulled prod env cannot reach production state regardless of how valid VERCEL_OIDC_TOKEN is. I verified it on the workbench app: build confirmed as world-vercel (0 world-local markers / 37 world-vercel markers in the server bundles), then POST /api/start with a deliberately valid-shaped env returned 500 {"details":"VERCEL_DEPLOYMENT_ID environment variable is not set"} and no run was created.

What was wrong is that message: it names an env var and nothing else — no mention that the world was chosen at build time, or that WORKFLOW_TARGET_WORLD=local + rebuild is the fix. That's the loud-failure claim in this comment block failing in practice. Fixed in 9d62ee2: packages/world-vercel/src/deployment-id.ts holds one message used by getDeploymentId(), queue(), and resolveLatestDeploymentId(), and the doc comment here now points at that throw instead of at credentials. Docs got the mirror-image paragraph too.

On the two mitigations:

  1. Declined as written. A warning at world-creation time fires during builds, not just at runtime: createWorld() runs at build time via getWorldHandlers(), where a prebuilt build legitimately has no deployment ID — so every vercel build would print it. The throw in getDeploymentId() covers the same wrong-world direction at the only moment it is unambiguously wrong.
  2. Reasonable, not in this PR. ensureWorkflowTargetWorldEnv() is called several times per build (and with synthetic env objects from tests), so a log line needs a once-guard with a test seam. The inference is now documented in configuration/worlds.mdx and both wrong-world directions fail with an actionable message; happy to add the log line as a follow-up if you still want it visible in CI logs.

Comment thread packages/utils/src/world-target.ts Outdated
Comment on lines +69 to +70
normalizeWorkflowTargetWorldImport(resolveWorkflowTargetWorld(env)) ??
'@workflow/world-local'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the ?? '@workflow/world-local' arm is unreachable — resolveWorkflowTargetWorld always returns a non-empty string, and normalizeWorkflowTargetWorldImport only returns undefined for falsy input. It exists solely to satisfy the string | undefined return type; an explicit non-optional path would be clearer than a dead fallback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Fixed in 9d62ee2 — the fallback is gone, replaced by a cast with a comment naming why the type is wider than the values:

// `resolveWorkflowTargetWorld` always returns a non-empty specifier, which is
// the only input `normalizeWorkflowTargetWorldImport` maps to `undefined`.
return normalizeWorkflowTargetWorldImport(
  resolveWorkflowTargetWorld(env)
) as string;

Comment thread packages/next/src/builder-eager.ts Outdated
if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
// Keep the generated manifest out of the developer's repository. A
// Vercel build has no repository to dirty.
if (!isVercelDeploymentEnv()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This site is different from the BOA one: the public manifest is written into the app's own public/.well-known/workflow/v1, and a prebuilt build (vercel build locally or in CI — the exact case this PR fixes) does run in a checked-out repository. With WORKFLOW_PUBLIC_MANIFEST=1, the generated manifest can now dirty the repo where it previously got a .gitignore. The comment's premise ("a Vercel build has no repository to dirty") is wrong precisely for prebuilt builds.

Suggest keeping this write unconditional — it's harmless in Vercel's build container and protective everywhere else.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) You're right, and it's worse than dirtying the repo: with WORKFLOW_PUBLIC_MANIFEST=1 a prebuilt build would have stopped writing the .gitignore into a real checkout. Reverted to the VERCEL_DEPLOYMENT_ID guard in 9d62ee2 (unchanged from main), along with the wrong comment.

if (process.env.VERCEL_DEPLOYMENT_ID === undefined) {
// Keep the generated manifest out of the developer's repository. A
// Vercel build has no repository to dirty.
if (!isVercelDeploymentEnv()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change here is fine, but for a different reason than the comment gives: prebuilt builds do run in a repository — it's just that .vercel/output is generated output (and the CLI gitignores .vercel on link), so this .gitignore never protected anything. Skipping it means prebuilt static output stops shipping a stray .gitignore as a deployable asset, matching Vercel-container builds. Suggest the comment say that instead of "no repository to dirty".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Reverted this site to main as well in 9d62ee2, rather than keeping the change with a corrected comment. It isn't needed for the bug this PR fixes, and your reading is the accurate one: the .gitignore never protected anything there because .vercel is already ignored on link. Dropping both guard changes also removes the isVercelDeploymentEnv re-exports from @workflow/builders, which existed only for these two call sites, so the PR is now scoped to world selection.

Comment thread packages/world-local/src/index.ts Outdated
return;
}
const resolvedDataDir = path.resolve(dataDir);
if (resolvedDataDir.startsWith(`${tmpdir()}${path.sep}`)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge: startsWith(`` ${tmpdir()}${path.sep} ``) misses dataDir === tmpdir() exactly — on Vercel tmpdir() is /tmp, so dataDir: '/tmp' itself would warn spuriously. resolvedDataDir === tmpdir() || resolvedDataDir.startsWith(...) covers it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Fixed in 9d62ee2, and the warning moved to packages/world-local/src/build-target-mismatch.ts next to the shared advice string:

const resolvedDataDir = path.resolve(dataDir);
const tmp = tmpdir();
if (resolvedDataDir === tmp || resolvedDataDir.startsWith(`${tmp}${path.sep}`)) {
  return;
}

Covered by two cases in vercel-deployment-warning.test.ts: tmpdir() itself and a path under it stay quiet, and ${tmpdir()}-elsewhere still warns (so the prefix check can't be loosened back into a substring match).

Comment thread packages/world-local/src/index.ts Outdated
* deliberate choice rather than a misconfigured build.
*/
function warnIfRunningInVercelDeployment(dataDir: string): void {
if (warnedAboutVercelDeployment || !isVercelDeploymentEnv()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap worth noting: this safety net keys on isVercelDeploymentEnv() at runtime, but in the scenario the new docs paragraph calls out (system environment variables disabled), the runtime env also lacks VERCEL/VERCEL_DEPLOYMENT_ID — so the warning stays silent exactly where it's needed.

A robust complement (fine as a follow-up): emit this same actionable message when the local world's first write fails with EROFS. That catches every misdetection variant regardless of env visibility, which env sniffing can't.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Good catch, and it's more necessary than the comment assumes — done in this PR rather than as a follow-up.

Two findings while implementing it:

  • world-local's existing DataDirAccessError never fires in a deployment. It comes from initDataDir(), which only runs from start(), and world.start?.() is called only by @workflow/vitest — never by the Next.js integration. So a deployed app never hits that path.
  • ensureDir() in fs.ts swallowed every mkdir error, so EROFS surfaced later as ENOENT on the file, which is what the customer's failure looked like.

So the throw is in ensureDir() — the choke point every write goes through, env-independent as you suggested:

const code = (error as NodeJS.ErrnoException).code;
if (isUnwritableDirCode(code) && !(await isExistingDirectory(resolvedPath))) {
  throw new UnwritableDataDirError(resolvedPath, code as string);
}
// Ignore if already exists

UnwritableDataDirError extends WorkflowWorldError (so it maps to HTTP like other world errors) and carries the same advice string as the warning. Codes are EROFS/EACCES/EPERM; the existence re-check preserves the historical tolerance for races and for an unsearchable parent that stat can still resolve. Two tests in fs.test.ts cover both directions.

Comment on lines 367 to 369
const maybePort = workflows?.local?.port;
if (maybePort) {
process.env.PORT = maybePort.toString();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change worth a line in the changeset: previously workflows.local.port set PORT whenever VERCEL_DEPLOYMENT_ID was absent, regardless of world. Keying it to the resolved local world is correct (PORT only feeds world-local's callback base URL — packages/world-local/src/config.ts:103), but since Next's dev server also honors PORT, anyone (mis)using this option to move their dev server port while targeting a non-local world silently loses that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Added to the changeset in 9d62ee2:

Select the Vercel World for builds that target Vercel without a deployment ID, such as vercel build followed by vercel deploy --prebuilt, and apply workflows.local.port whenever the Local World is the target.

Also dropped @workflow/builders from that changeset — after the two .gitignore reverts its only remaining change is a test.

NODE_ENV: 'production',
};

describe('isVercelDeploymentEnv', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: nothing pins VERCEL: '1' with NODE_ENV unset (common outside framework processes — plain node scripts, CLI contexts), which should still resolve to Vercel. Cheap to add to this table.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Added in 9d62ee2, derived from the existing fixture rather than a new literal so it can't drift:

test('detects a Vercel build that exposes no NODE_ENV', () => {
  const { NODE_ENV: _unset, ...envWithoutNodeEnv } = PREBUILT_BUILD_ENV;
  expect(isVercelDeploymentEnv(envWithoutNodeEnv)).toBe(true);
});

… sides

- Restore the `public/.well-known/workflow/v1/.gitignore` guard to
  `VERCEL_DEPLOYMENT_ID`, so prebuilt builds keep writing it.
- world-vercel: name the cause and the fix when VERCEL_DEPLOYMENT_ID is
  missing, from the getDeploymentId() call that start() makes before any
  state write.
- world-local: throw UnwritableDataDirError from ensureDir when the data
  directory cannot be created, instead of surfacing a later ENOENT.
- Move the Vercel-deployment warning next to the shared advice string and
  exempt the temp dir by path equality, not prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VaguelySerious
VaguelySerious requested review from a team as code owners July 28, 2026 01:34

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review comments addressed in 9d62ee2 — and better than asked:

  • The production-exposure concern was refuted with evidence: start() awaits world.getDeploymentId() before any state write, and world-vercel throws when VERCEL_DEPLOYMENT_ID is unset, ahead of any credential use. I verified both in the source. Turning the finding into the shared missingDeploymentIdMessage() (naming the build-time cause and the WORKFLOW_TARGET_WORLD=local + rebuild fix) plus the mirror docs paragraph is the right response — the message was the real defect.
  • The EROFSUnwritableDataDirError choke-point error in ensureDir() closes the safety-net gap env sniffing can't, and the two pre-existing findings along the way (DataDirAccessError never firing in deployments; mkdir errors surfacing as ENOENT on the file — exactly what the customer saw) make it clearly worth doing in this PR.
  • Both .gitignore sites reverted to main, tmpdir edge fixed with prefix-sibling coverage, dead fallback replaced, changeset updated, NODE_ENV-unset test added.

Declining the world-vercel creation-time warning is fair given createWorld() runs during builds via getWorldHandlers(); the getDeploymentId() throw covers that direction at the only unambiguous moment. The build-time log line for the VERCEL=1 fallback path is fine as a follow-up.

Required checks are green. :shipit:

@VaguelySerious VaguelySerious added the event-log-race-repro Run the event log race reproduction job label Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Event Log Race Repro

882 of 1400 latest repro runs hit event-log regressions. 1 run hit harness-side infra outcomes (hook-resume timing races / transport errors); these are reported but do not fail the job.

Run History

Metric 2026-07-28 17:36 UTC #1
logs
2026-07-28 18:18 UTC #1
logs / deploy
Result missing result file 882/1400 regressions (+1 infra)
Total 0 1400
completed 0 517
CORRUPTED_EVENT_LOG 0 844
USER_ERROR 0 0
RUNTIME_ERROR 0 0
stuck 0 38
other 0 0
infra 0 1
Config 1400 runs / step-storm 600, hook-storm 600, hook-sleep 200 / c40 / 6x8
Timing watchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

Scenario Total completed CORRUPTED_EVENT_LOG USER_ERROR RUNTIME_ERROR stuck other infra
step-storm 600 32 568 0 0 0 0 0
hook-storm 600 286 276 0 0 38 0 0
hook-sleep 200 199 0 0 0 0 0 1

Latest Non-Completed Runs

Scenario Attempt Outcome Status Error code Run
step-storm 20 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHP40GWX4C51A8VB4M4B
step-storm 24 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHT30GM5SDH761FM6MPY
step-storm 39 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ4S0GZGDFHKEE2NK67E
step-storm 31 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ250GYZ4EDEVWW47Z61
step-storm 18 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHJY0GRS1858RJJV08G7
step-storm 4 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWH7D0GWNCRD43N2F5H99
step-storm 13 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHGZ0GWTCBAX4DFPBDK2
step-storm 2 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWH7K0GZ9DR7TT20YFDRT
step-storm 37 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ3W0GPT805B0SS8Z2YT
step-storm 34 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ240GJ1DSQHG62AHC1N
step-storm 42 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWX1750GXDHQ6XMWYFGSZB
step-storm 7 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHAC0GSN69VK5M4H1KT5
step-storm 10 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHC10GWDC8SEFYW0QKFD
step-storm 15 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHJA0GGW5YJ8H9PDRJ20
step-storm 36 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ280GN60X8ENWA155J7
step-storm 5 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWH860GXYZ1Y82TV6CMWW
step-storm 33 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ5J0GNY80CKPJSKBD28
step-storm 27 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHW50GGRBVCTJVTR4DMV
step-storm 40 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWJ4V0GK6GN10ZASZRNSH
step-storm 28 CORRUPTED_EVENT_LOG failed CORRUPTED_EVENT_LOG wrun_41KYMWWHWM0GXMB75FKZ19CYE9

Showing 20 of 883 non-completed runs.

@VaguelySerious
VaguelySerious merged commit 49276f2 into main Jul 28, 2026
103 of 106 checks passed
@VaguelySerious
VaguelySerious deleted the peter/prebuilt-world-detection branch July 28, 2026 18:26
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 49276f2 (AI decision).

This fixes a regression introduced by static build-time world injection, which is main-only: origin/stable's packages/utils/src/world-target.ts still resolves the world at runtime (VERCEL_DEPLOYMENT_ID ? vercel : local), has no getWorldImport, and packages/builders/src/world-target.ts/ensureWorkflowTargetWorldEnv don't exist there at all — so the bug being fixed cannot occur on stable. Backporting would instead import a new VERCEL=1-based detection heuristic, a new exported isVercelDeploymentEnv API, a new UnwritableDataDirError, and a behavior change to workflows.local.port, none of which fix a defect on the maintenance line.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

49276f2d0b11d7552ac4504936cbca51df4ce98d

VaguelySerious added a commit that referenced this pull request Jul 29, 2026
#3144 landed after this branch was cut and adapted world selection to
#2752's build-time model: `isVercelDeploymentEnv` treats `VERCEL=1`
without a deployment ID as Vercel, because a `vercel build` on external
CI had to decide which world to bake into the bundles.

With selection back at runtime, `VERCEL_DEPLOYMENT_ID` is authoritative
and always present inside a deployment, so the broader signal only
changes the cases that are not deployments: a production server started
locally or in CI with an env file from `vercel env pull` would resolve to
the Vercel world and then fail for want of a deployment ID, where before
#2752 it used the local world. The same applies to the local world's
read-only-filesystem warning, which would fire in a writable one.

The user-facing text from #3144 explains all of this in terms of build
output and prescribes a rebuild, which no longer applies now that
`WORKFLOW_TARGET_WORLD` takes effect on the next start.

The rest of #3144 is independent of the injection mechanism and stays:
`UnwritableDataDirError`, the warning itself, and the Vercel world's
missing-deployment-ID message.

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

Labels

event-log-race-repro Run the event log race reproduction job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants