From 564e90e8671df0bf0d61118b2fc99a7ab5dd19d9 Mon Sep 17 00:00:00 2001 From: Brooooooklyn Date: Mon, 19 Oct 2026 05:33:16 +0000 Subject: [PATCH] fix(deploy): upload the same Worker exports as cf when secrets are pinned (#487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #460 ## Problem ``` 1. void deploy cf workers versions create --prebuilt DO export: {type:"durable-object", storage:"sqlite"} 2. void secret put PUT /scripts/{w}/secrets → Cloudflare copies v1 → 100% 3. void deploy live version has a secret → Void's pinned uploader DO export: {type, storage, state:"created"} ← differs preview_urls:false → stage [v2 @100%, v3 @0%] → 100405 "versions must declare identical exports" ``` The first secret on a Worker moves `void deploy` from cf's uploader to Void's pinned-secret uploader (`cf-version-runner.ts`), and the two wrote `exports` differently: - Void added `state: "created"` to live Durable Object classes; cf leaves it out. - Void kept Build Output order; cf sends Worker entrypoints, then Durable Objects, then Workflows. - Void kept `container` on legacy-kv classes and sent `{}` for no exports; cf drops both. With `preview_urls: false`, Void checks a new version by staging it at 0% beside the live one. Cloudflare only splits traffic between versions with identical `exports`, so every retry failed. ## Fix - `exportsFromOutput` keeps its checks, then builds the entries with `@cloudflare/config`'s `convertToWranglerConfig` (the converter cf uses) and orders and trims them like cf's `resolveExportsUploadPayload` (Workflows stay identity-only, as before). - On 100405 during staging, the error now says the versions declare different exports and suggests one `void deploy --platform cloudflare --atomic`. "Production traffic is unchanged" stays. - The touched 10210 hint check uses `includes` instead of a regex. **Note:** a Worker whose live version was already uploaded by the old pinned path (with `state: "created"`) may hit 100405 once more; the new hint tells the user to run `--atomic` once. ## Tests | Test | Old code | Fix | |---|---|---| | `uploads the same exports as cf for the same Build Output`: runs the real `cf` against a fake API and compares its upload with the pinned upload (key order counts) | fail | pass | | `converts Container and transfer exports as cf does`: expectation built from `convertToWranglerConfig` so cf drift shows up | fail | pass | | Updated locked shapes (live secrets ×2, Sandbox lifecycle ×6) | fail | pass | | `suggests one atomic publication when the versions declare different exports` | fail | pass | `cf-version-runner.test.ts` + `cloudflare-deploy.test.ts`: 545 passed. Typecheck, lint, format clean. Not run against a real Cloudflare account. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/void/src/cli/cf-version-api.ts | 100 ++++---- .../void/src/cli/deploy-cloudflare-helpers.ts | 12 +- .../void/test/unit/cf-version-runner.test.ts | 213 ++++++++++++++++-- .../void/test/unit/cloudflare-deploy.test.ts | 14 ++ 4 files changed, 282 insertions(+), 57 deletions(-) diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index 597128db5..3e3466ffb 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -8,7 +8,11 @@ import { getWorkerBundleDir, readBuildOutput, } from '@cloudflare/build-output-utils'; -import type { ParsedOutputWorkerConfig } from '@cloudflare/config'; +import { + convertToWranglerConfig, + type ParsedInputConfig, + type ParsedOutputWorkerConfig, +} from '@cloudflare/config'; import type { CloudflareCommandResult } from '@void/deploy-cloudflare'; import ignore from 'ignore'; import mime from 'mime'; @@ -244,7 +248,23 @@ function bindingsFromOutput( return bindings; } -function exportsFromOutput(exports: unknown, bindings: Array): Json | undefined { +const durableObjectStates = new Set([ + 'created', + 'deleted', + 'renamed', + 'transferred', + 'expecting-transfer', +]); + +/** + * The `exports` that cf uploads for the same Build Output. cf converts them with + * `@cloudflare/config`'s `convertToWranglerConfig` (cf `build-*.mjs`), and its + * `resolveExportsUploadPayload` (cf `containers-*.mjs`) sends Worker entrypoints, + * then Durable Objects, then Workflows reduced to their identity. Cloudflare + * stages a version at 0% only when its exports are identical to the live + * version's, and that version may have been uploaded by cf. + */ +export function exportsFromOutput(exports: unknown, bindings: Array): Json | undefined { if (exports === undefined) { return undefined; } @@ -252,7 +272,6 @@ function exportsFromOutput(exports: unknown, bindings: Array): Json | unde if (!entries) { throw new Error('Cloudflare Durable Object exports were not readable.'); } - const result: Json = {}; const workflows = new Map(); for (const [name, raw] of Object.entries(entries)) { const value = object(raw); @@ -262,53 +281,30 @@ function exportsFromOutput(exports: unknown, bindings: Array): Json | unde throw new Error(`Cloudflare Workflow ${workflowName} has multiple local exports.`); } workflows.set(workflowName, name); - // cf uploads identity only. Execution settings stay in the immutable - // Build Output and are applied by its separate typed trigger deployment. - result[name] = { type: 'workflow', name: workflowName }; continue; } if (value?.type === 'worker') { - result[name] = { - type: 'worker', - ...(value.cache === undefined ? {} : { cache: value.cache }), - }; continue; } if (!value || value.type !== 'durable-object') { throw new Error(`Cloudflare export ${name} cannot be uploaded with pinned secrets.`); } - if (value.state === 'deleted') { - result[name] = { type: 'durable-object', state: 'deleted' }; - } else if (value.state === 'renamed' || value.state === 'transferred') { - const renamed = value.state === 'renamed'; - result[name] = { - type: 'durable-object', - state: value.state, - [renamed ? 'renamed_to' : 'transferred_to']: requiredString( - value[renamed ? 'renamedTo' : 'transferredTo'], - name, - ), - }; - } else if (value.storage === 'sqlite' || value.storage === 'legacy-kv') { - if ( - value.state !== undefined && - !['created', 'expecting-transfer'].includes(String(value.state)) - ) { - throw new Error(`Cloudflare Durable Object export ${name} has an unsupported state.`); + const state = value.state ?? 'created'; + if (!durableObjectStates.has(String(state))) { + throw new Error(`Cloudflare Durable Object export ${name} has an unsupported state.`); + } + if (state === 'renamed' || state === 'transferred') { + requiredString(value[state === 'renamed' ? 'renamedTo' : 'transferredTo'], name); + } else if (state !== 'deleted') { + if (value.storage !== 'sqlite' && value.storage !== 'legacy-kv') { + throw new Error(`Cloudflare Durable Object export ${name} has no storage type.`); + } + if (value.container !== undefined) { + requiredString(value.container, name); + } + if (state === 'expecting-transfer') { + requiredString(value.transferFrom, name); } - result[name] = { - type: 'durable-object', - storage: value.storage, - state: value.state ?? 'created', - ...(value.container === undefined - ? {} - : { container: requiredString(value.container, name) }), - ...(value.state === 'expecting-transfer' - ? { transfer_from: requiredString(value.transferFrom, name) } - : {}), - }; - } else { - throw new Error(`Cloudflare Durable Object export ${name} has no storage type.`); } } for (const binding of bindings) { @@ -325,7 +321,27 @@ function exportsFromOutput(exports: unknown, bindings: Array): Json | unde ); } } - return result; + // The converter reads only `worker.exports` for this field. + const converted = + object( + convertToWranglerConfig({ + worker: { exports: entries }, + containers: [], + } as unknown as ParsedInputConfig).exports, + ) ?? {}; + const result: Json = {}; + for (const type of ['worker', 'durable-object', 'workflow']) { + for (const [name, value] of Object.entries(converted)) { + const entry = object(value); + if (entry?.type !== type) { + continue; + } + // cf uploads Workflow identity only. Execution settings stay in the immutable + // Build Output and are applied by its separate typed trigger deployment. + result[name] = type === 'workflow' ? { type, name: entry.name } : entry; + } + } + return Object.keys(result).length > 0 ? result : undefined; } async function preflightWorkflowExports(options: { diff --git a/packages/void/src/cli/deploy-cloudflare-helpers.ts b/packages/void/src/cli/deploy-cloudflare-helpers.ts index 27a24be6b..62559f058 100644 --- a/packages/void/src/cli/deploy-cloudflare-helpers.ts +++ b/packages/void/src/cli/deploy-cloudflare-helpers.ts @@ -534,8 +534,16 @@ export function formatCloudflareActivationError( 'Set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain (for example "my-team")', 'and retry. Void caches it after a successful workers.dev deployment; production traffic is unchanged.', ].join('\n'); - case 'readiness-staging-failed': - return `deploy: Cloudflare could not stage the uploaded Worker Version at zero-percent traffic${status}; production traffic is unchanged.${cloudflareCode}${diagnostic}${result.cloudflareErrorCode === 10210 && /Durable Object migration/i.test(result.diagnostic ?? '') ? '\n\nReview the Durable Object migration, then run `void deploy --platform cloudflare --atomic`. For future deploys, set deploy.cloudflare.mode to "atomic" in void.config.ts.' : ''}`; + case 'readiness-staging-failed': { + const hint = + result.cloudflareErrorCode === 10210 && + (result.diagnostic ?? '').toLowerCase().includes('durable object migration') + ? '\n\nReview the Durable Object migration, then run `void deploy --platform cloudflare --atomic`. For future deploys, set deploy.cloudflare.mode to "atomic" in void.config.ts.' + : result.cloudflareErrorCode === 100405 + ? '\n\nThe new version declares different exports from the live version (for example a Durable Object or Workflow class), so Cloudflare cannot split traffic between them. Run `void deploy --platform cloudflare --atomic` once to publish it at 100% directly; it receives traffic before readiness is checked.' + : ''; + return `deploy: Cloudflare could not stage the uploaded Worker Version at zero-percent traffic${status}; production traffic is unchanged.${cloudflareCode}${diagnostic}${hint}`; + } case 'atomic-failed': return `deploy: Cloudflare could not publish the Worker atomically${status}; inspect production status before retrying.${cloudflareCode}${diagnostic}`; case 'atomic-output-invalid': diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index e1fb6bfe2..be7e042aa 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -12,7 +12,11 @@ import { readBuildOutput, writeContainerConfig, } from '@cloudflare/build-output-utils'; -import { OutputContainerSchema } from '@cloudflare/config'; +import { + convertToWranglerConfig, + OutputContainerSchema, + type ParsedInputConfig, +} from '@cloudflare/config'; import { afterEach, expect, it, vi } from 'vite-plus/test'; import { isVoidSecretOnlyDraft } from '../../../deploy-cloudflare/src/secret-draft.ts'; import { @@ -23,7 +27,7 @@ import { import { operationFixture } from '../../../deploy-cloudflare/test/operation-fixture.ts'; import { voidSecretDraftVersion } from '../../../deploy-cloudflare/test/secret-draft-fixture.ts'; import { prepareCfBuildOutput } from '../../src/cli/cf-build-output.ts'; -import { uploadPinnedCloudflareVersion } from '../../src/cli/cf-version-api.ts'; +import { exportsFromOutput, uploadPinnedCloudflareVersion } from '../../src/cli/cf-version-api.ts'; import { createActiveSecretPinner, createCfCommand, @@ -69,6 +73,11 @@ async function listen(server: Server): Promise { return `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`; } +/** Compare JSON with its key order, which a deep equality check ignores. */ +function expectSameJson(actual: unknown, expected: unknown): void { + expect(JSON.stringify(actual, null, 2)).toBe(JSON.stringify(expected, null, 2)); +} + /** The real pinned cf, whose child sees only a synthetic token and the fake API. */ function pinnedCf(apiBaseUrl: string, accountId = 'account-id', env: NodeJS.ProcessEnv = {}) { return createCfCommand({ @@ -895,9 +904,8 @@ async function verifyPinnedUpload(atomic: boolean): Promise { ]), ); expect(body.modules).toEqual([expect.objectContaining({ name: 'index.js' })]); - expect(body.exports).toEqual({ - LocalJob: { type: 'workflow', name: 'local-workflow' }, - ExportOnlyJob: { type: 'workflow', name: 'export-only-workflow' }, + // cf's key order: Worker entrypoints, Durable Objects, then Workflows. + expectSameJson(body.exports, { Rpc: { type: 'worker', cache: { enabled: false } }, Departed: { type: 'durable-object', state: 'transferred', transferred_to: 'target-worker' }, Arriving: { @@ -906,6 +914,8 @@ async function verifyPinnedUpload(atomic: boolean): Promise { storage: 'sqlite', transfer_from: 'source-worker', }, + LocalJob: { type: 'workflow', name: 'local-workflow' }, + ExportOnlyJob: { type: 'workflow', name: 'export-only-workflow' }, }); expect(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')).toBe( originalWorkerConfig, @@ -965,6 +975,187 @@ it('uploads live secrets and multiple asset batches when the latest upload is in it('uploads live secrets and multiple asset batches for atomic publication', () => verifyPinnedUpload(true)); +it('converts Container and transfer exports as cf does', () => { + const exports = { + Flow: { + type: 'workflow', + name: 'flow', + defaultRetention: { successRetention: 1, errorRetention: '2 days' }, + }, + Box: { type: 'durable-object', state: 'created', storage: 'sqlite', container: 'box' }, + LegacyBox: { type: 'durable-object', storage: 'legacy-kv', container: 'legacy-box' }, + Incoming: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'sqlite', + transferFrom: 'source-worker', + container: 'incoming', + }, + LegacyIncoming: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'legacy-kv', + transferFrom: 'source-worker', + container: 'legacy-incoming', + }, + Entry: { type: 'worker' }, + }; + // cf's upload: the converter's entries, Worker entrypoints first, then Durable + // Objects, then Workflows reduced to their identity. + const converted = Object.entries( + convertToWranglerConfig({ worker: { exports }, containers: [] } as unknown as ParsedInputConfig) + .exports as Record, + ); + const fromCf = Object.fromEntries( + ['worker', 'durable-object', 'workflow'].flatMap((type) => + converted + .filter(([, value]) => value.type === type) + .map(([name, value]) => [name, type === 'workflow' ? { type, name: value.name } : value]), + ), + ); + expectSameJson(exportsFromOutput(exports, []), fromCf); + // What cf sends today, kept readable. + expect(fromCf).toEqual({ + Entry: { type: 'worker' }, + Box: { type: 'durable-object', storage: 'sqlite', container: 'box' }, + LegacyBox: { type: 'durable-object', storage: 'legacy-kv' }, + Incoming: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'sqlite', + transfer_from: 'source-worker', + container: 'incoming', + }, + LegacyIncoming: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'legacy-kv', + transfer_from: 'source-worker', + }, + Flow: { type: 'workflow', name: 'flow' }, + }); + expect(exportsFromOutput({}, [])).toBeUndefined(); + expect(() => + exportsFromOutput({ Room: { type: 'durable-object', state: 'paused', storage: 'sqlite' } }, []), + ).toThrow('has an unsupported state'); +}); + +// Cloudflare stages a 0% candidate only when its exports equal the live version's, +// and the live version may come from cf. Compare against the real pinned cf upload. +it( + 'uploads the same exports as cf for the same Build Output', + async () => { + const activeVersionId = '11111111-1111-4111-8111-111111111111'; + const prepared = await output({ secrets: { required: ['AUTH_SECRET'] } }); + const worker = JSON.parse(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')); + worker.exports = { + Job: { + type: 'workflow', + name: 'jobs', + limits: { steps: 3 }, + defaultRetention: { successRetention: '1 day' }, + }, + Room: { type: 'durable-object', storage: 'sqlite' }, + Legacy: { type: 'durable-object', state: 'created', storage: 'legacy-kv' }, + Rpc: { type: 'worker', cache: { enabled: false } }, + Removed: { type: 'durable-object', state: 'deleted' }, + OldRoom: { type: 'durable-object', state: 'renamed', renamedTo: 'Room' }, + Departed: { type: 'durable-object', state: 'transferred', transferredTo: 'target-worker' }, + Arriving: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'sqlite', + transferFrom: 'source-worker', + }, + }; + writeFileSync(getWorkerConfigPath(prepared.directory), JSON.stringify(worker)); + const uploads: Array<{ path: string; exports: unknown }> = []; + const server = createServer(async (request, response) => { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const body = Buffer.concat(chunks); + const path = new URL(request.url!, 'http://localhost').pathname; + let result: unknown = {}; + if (request.method === 'POST' && path.endsWith('/workers/scripts/void-test/versions')) { + const form = await new Request('http://localhost', { + method: 'POST', + headers: { 'content-type': request.headers['content-type']! }, + body, + }).formData(); + uploads.push({ path, exports: JSON.parse(String(form.get('metadata'))).exports }); + result = { id: '33333333-3333-4333-8333-333333333333', number: 2 }; + } else if ( + request.method === 'POST' && + path.endsWith('/workers/workers/void-test/versions') + ) { + uploads.push({ path, exports: JSON.parse(body.toString('utf8')).exports }); + result = { id: '22222222-2222-4222-8222-222222222222', urls: [] }; + } else if (path.endsWith('/services/void-test')) { + result = { + default_environment: { + environment: 'production', + script: { id: 'void-test', last_deployed_from: 'wrangler' }, + }, + }; + } else if (path.endsWith('/workflows/jobs')) { + result = { name: 'jobs', script_name: 'void-test', class_name: 'Job' }; + } else if (path.endsWith(`/versions/${activeVersionId}`)) { + result = { id: activeVersionId, bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }] }; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, errors: [], messages: [], result })); + }); + const apiBaseUrl = await listen(server); + try { + // cf reports a local error after this upload because the fake result is incomplete. + await pinnedCf(apiBaseUrl)(['workers', 'versions', 'create', '--prebuilt'], { + cwd: prepared.directory, + capture: true, + timeoutMs: REAL_CF_TIMEOUT_MS, + }); + expect( + await createActiveSecretPinner({ + output: prepared, + handoffPending: false, + requiredNames: ['AUTH_SECRET'], + })!(activeVersionId, [{ name: 'AUTH_SECRET', type: 'secret_text' }]), + ).toBe(true); + await uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl, + workerName: 'void-test', + token: 'synthetic-token', + atomic: false, + verifyUploader: () => true, + }); + expect(uploads.map(({ path }) => path.split('/').at(-3))).toEqual(['scripts', 'workers']); + const [cf, pinned] = uploads.map(({ exports }) => JSON.stringify(exports, null, 2)); + expect(pinned).toBe(cf); + expect(JSON.parse(pinned!)).toEqual({ + Rpc: { type: 'worker', cache: { enabled: false } }, + Room: { type: 'durable-object', storage: 'sqlite' }, + Legacy: { type: 'durable-object', storage: 'legacy-kv' }, + Removed: { type: 'durable-object', state: 'deleted' }, + OldRoom: { type: 'durable-object', state: 'renamed', renamed_to: 'Room' }, + Departed: { type: 'durable-object', state: 'transferred', transferred_to: 'target-worker' }, + Arriving: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'sqlite', + transfer_from: 'source-worker', + }, + Job: { type: 'workflow', name: 'jobs' }, + }); + } finally { + prepared.dispose(); + } + }, + REAL_CF_TIMEOUT_MS, +); + it('uploads Worker limits, placement and cache with pinned secrets', async () => { const prepared = await output({ secrets: { required: ['AUTH_SECRET'] }, @@ -1754,16 +1945,12 @@ it.each([ expect(post.body.containers).toEqual([ { name: 'sandbox', class_name: 'Sandbox', images: expectedImages }, ]); - expect(post.body.exports).toEqual({ + // Live classes carry no state, as in cf's upload of the same Build Output. + expectSameJson(post.body.exports, { OldRoom: { type: 'durable-object', state: 'renamed', renamed_to: 'Room' }, - Room: { type: 'durable-object', storage: 'sqlite', state: 'created' }, Removed: { type: 'durable-object', state: 'deleted' }, - Sandbox: { - type: 'durable-object', - storage: 'sqlite', - state: 'created', - container: 'sandbox', - }, + Sandbox: { type: 'durable-object', storage: 'sqlite', container: 'sandbox' }, + Room: { type: 'durable-object', storage: 'sqlite' }, }); expect(requests.findIndex((row) => row.url.endsWith('/image-preparations'))).toBeLessThan( requests.indexOf(post), diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 5869c55a4..542385d64 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -244,6 +244,20 @@ describe('Cloudflare activation diagnostics', () => { expect(message).toContain('deploy.cloudflare.mode'); }); + it('suggests one atomic publication when the versions declare different exports', () => { + const message = formatCloudflareActivationError({ + ok: false, + reason: 'readiness-staging-failed', + status: 1, + cloudflareErrorCode: 100405, + diagnostic: + '[100405] All versions in a multi-version deployment must declare identical `exports`.', + }); + expect(message).toContain('production traffic is unchanged'); + expect(message).toContain('declares different exports from the live version'); + expect(message).toContain('Run `void deploy --platform cloudflare --atomic` once'); + }); + it('reports readiness restoration outcomes without claiming uncertain traffic is unchanged', () => { expect( formatCloudflareActivationError({