From e2ddbafd248fd4d14cf6c717925c29b804e8ce49 Mon Sep 17 00:00:00 2001 From: froQ Date: Wed, 4 Nov 2026 14:06:59 +0000 Subject: [PATCH 1/4] fix(deploy): activate Workers that have no readiness URL Skip the readiness probe on a later deploy when workers.dev is off and no route can reach the Worker, then activate the uploaded version. Do not suggest an account subdomain in that case. Co-authored-by: sayhola --- docs/integrations/cloudflare.md | 4 +- docs/reference/cli/deploy.md | 2 +- packages/deploy-cloudflare/src/version.ts | 209 ++++++++++-------- .../test/unit/version.test.ts | 62 +++++- .../void/src/cli/deploy-cloudflare-helpers.ts | 38 +++- packages/void/src/cli/init.ts | 2 +- .../void/test/unit/cloudflare-deploy.test.ts | 72 ++++++ 7 files changed, 290 insertions(+), 99 deletions(-) diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index a7c1e2d10..61ef627c5 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -138,7 +138,7 @@ Set these CI secrets and variables as needed: | `CLOUDFLARE_API_TOKEN` | Required. Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. | | `CLOUDFLARE_ACCOUNT_ID` | When the token can access several accounts. | | `DATABASE_URL` | PostgreSQL and MySQL provisioning and migrations. | -| `CLOUDFLARE_WORKERS_SUBDOMAIN` | When the Worker has no version preview URL. Use `my-team` or `my-team.workers.dev`. | +| `CLOUDFLARE_WORKERS_SUBDOMAIN` | When workers.dev is enabled and the version has no preview URL. Use `my-team` or `my-team.workers.dev`. An account subdomain does not create a readiness URL when `workers_dev` is false. | | `CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` | When Cloudflare Access protects the readiness URL. | | `CF_ACCESS_READINESS_ORIGIN` | When Access protects a route or Custom Domain and `workers_dev` is `false`. See [Cloudflare Access](#cloudflare-access). | @@ -174,7 +174,7 @@ Set production server values with `void secret put `. Void preserves exist ### Readiness and rollback -Void normally verifies an uploaded version before sending it traffic. If readiness or trigger updates fail, it restores the previous deployment. Finish or cancel any gradual rollout in Cloudflare before deploying through Void. +Void normally verifies an uploaded version before sending it traffic. A Worker with workers.dev turned off and no route or Custom Domain has no URL Void can check, so that deploy skips the readiness check and activates the uploaded version. If readiness or trigger updates fail, it restores the previous deployment. Finish or cancel any gradual rollout in Cloudflare before deploying through Void. For an existing Durable Object Worker that Cloudflare cannot stage, review the changes and use: diff --git a/docs/reference/cli/deploy.md b/docs/reference/cli/deploy.md index 1a91d9147..663c1115a 100644 --- a/docs/reference/cli/deploy.md +++ b/docs/reference/cli/deploy.md @@ -92,7 +92,7 @@ Void signs you in through your browser when needed and saves the selected accoun | `--skip-build` | Reuse existing static, SPA, or SSG output; unavailable for Worker apps | | `--project` | Unavailable; the Worker and account come from `void.config.ts` and `void.lock.json` | | Named environments | Unavailable; use the top-level `cloudflare` config | -| `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when versions have no preview URL; cached locally after a deploy | +| `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when a workers.dev Worker has no preview URL; cached locally after a deploy. It does not apply when `workers_dev` is false | | `CF_ACCESS_READINESS_ORIGIN` | Lets Access credentials reach the readiness check on a route or Custom Domain, for example `https://app.example.com` | | `--require-email` | Fail instead of deploying without email when the email step cannot run, as in CI | | `DATABASE_URL` | Required in the deploy environment for PostgreSQL or MySQL provisioning and migrations | diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 279acfd65..f9188574a 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -77,7 +77,8 @@ export type CloudflareVersionActivation = | { ok: true; versionId: string; - previewUrl: string; + /** Absent when the Worker has no preview, workers.dev hostname, or route Void can reach. */ + previewUrl?: string; previousVersionId: string | null; workersDevSubdomain?: string; preservedTriggers?: boolean; @@ -133,6 +134,11 @@ export type CloudflareVersionActivation = * credentials because `readinessHeadersOrigin` did not name the origin. */ customRouteReadinessOrigin?: string; + /** + * Whether this Worker enables workers.dev. An account subdomain does not create a readiness + * URL when this is false. + */ + workersDev?: boolean; }; type ProductionDeployment = Readonly<{ @@ -1344,72 +1350,11 @@ async function activateCloudflareVersionWithSecretsFile( ? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain) : customRouteReadinessUrl(desiredTriggerConfig.routes); readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev && readinessUrl !== undefined; - if (!readinessUrl) { - return { - ok: false, - reason: 'preview-unavailable', - versionId, - previousVersionId: previous.versionId, - }; - } - const fallbackSubdomain = extractWorkersDevSubdomain(readinessUrl); - if (fallbackSubdomain && fallbackSubdomain === options.readinessHeadersWorkersDevSubdomain) { - authenticatedWorkersDevSubdomain = fallbackSubdomain; - } - const stagingFailure = await stageCandidateForReadiness( - options, - previous.versionId, - versionId, - message, - ); - if (stagingFailure) { - return stagingFailure; - } - stagedVersionOverride = true; - } - - const trustedReadinessOrigin = normalizeReadinessAccessOrigin(options.readinessHeadersOrigin); - // A route hostname is not guaranteed to reach this Worker. Send Access credentials to a custom - // route only when the operator named its exact origin. - const withholdsCustomRouteHeaders = (url: string) => - readinessUsesCustomRoute && !isTrustedReadinessOrigin(url, trustedReadinessOrigin); - const probe = (url: string) => - probeReadiness( - `${url.replace(/\/$/, '')}/__void/ready`, - options.workerName, - withholdsCustomRouteHeaders(url) ? undefined : options.readinessHeaders, - authenticatedWorkersDevSubdomain, - readinessUsesCustomRoute ? trustedReadinessOrigin : undefined, - stagedVersionOverride ? versionId : undefined, - options.fetch ?? globalThis.fetch, - options.sleep ?? delay, - options.readinessAttempts ?? 6, - Number.POSITIVE_INFINITY, - options.requireVersionMetadata ? versionId : undefined, - ); - options.onProgress?.('Waiting for the Worker to become ready...'); - let ready = await probe(readinessUrl); - - // Account-wide Access default-deny commonly blocks Wrangler's generated preview alias even - // when the stable `..workers.dev` destination has an application admitting the - // operator or a service token. The preview response gives us the account subdomain, so preserve - // the same no-traffic readiness invariant by staging the uploaded version at 0% and selecting it - // explicitly through the stable, policy-covered hostname. This requires a prior active version - // whose traffic can remain untouched while the new version is staged; a truly empty first - // deployment has no safe zero-percent fallback. - if ( - ready === 'access-denied' && - !stagedVersionOverride && - previous.versionId !== null && - !options.requirePreviewForReadiness - ) { - const stableReadinessUrl = desiredTriggerConfig.workers_dev - ? workersDevReadinessUrl( - options.workerName, - extractWorkersDevSubdomain(readinessUrl) ?? options.workersDevSubdomain, - ) - : customRouteReadinessUrl(desiredTriggerConfig.routes); - if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) { + if (readinessUrl) { + const fallbackSubdomain = extractWorkersDevSubdomain(readinessUrl); + if (fallbackSubdomain && fallbackSubdomain === options.readinessHeadersWorkersDevSubdomain) { + authenticatedWorkersDevSubdomain = fallbackSubdomain; + } const stagingFailure = await stageCandidateForReadiness( options, previous.versionId, @@ -1419,28 +1364,96 @@ async function activateCloudflareVersionWithSecretsFile( if (stagingFailure) { return stagingFailure; } - readinessUrl = stableReadinessUrl; - authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl); - readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev; stagedVersionOverride = true; - ready = await probe(readinessUrl); + } else if (desiredTriggerConfig.workers_dev) { + return { + ok: false, + reason: 'preview-unavailable', + versionId, + previousVersionId: previous.versionId, + workersDev: true, + }; + } + // workers.dev is off and no route reaches this Worker, so there is no readiness URL. + // A first deploy already publishes that Worker without a probe. Activate this upload + // the same way instead of stopping before traffic moves. + } + + if (readinessUrl) { + const trustedReadinessOrigin = normalizeReadinessAccessOrigin(options.readinessHeadersOrigin); + // A route hostname is not guaranteed to reach this Worker. Send Access credentials to a custom + // route only when the operator named its exact origin. + const withholdsCustomRouteHeaders = (url: string) => + readinessUsesCustomRoute && !isTrustedReadinessOrigin(url, trustedReadinessOrigin); + const probe = (url: string) => + probeReadiness( + `${url.replace(/\/$/, '')}/__void/ready`, + options.workerName, + withholdsCustomRouteHeaders(url) ? undefined : options.readinessHeaders, + authenticatedWorkersDevSubdomain, + readinessUsesCustomRoute ? trustedReadinessOrigin : undefined, + stagedVersionOverride ? versionId : undefined, + options.fetch ?? globalThis.fetch, + options.sleep ?? delay, + options.readinessAttempts ?? 6, + Number.POSITIVE_INFINITY, + options.requireVersionMetadata ? versionId : undefined, + ); + options.onProgress?.('Waiting for the Worker to become ready...'); + let ready = await probe(readinessUrl); + + // Account-wide Access default-deny commonly blocks Wrangler's generated preview alias even + // when the stable `..workers.dev` destination has an application admitting the + // operator or a service token. The preview response gives us the account subdomain, so preserve + // the same no-traffic readiness invariant by staging the uploaded version at 0% and selecting it + // explicitly through the stable, policy-covered hostname. This requires a prior active version + // whose traffic can remain untouched while the new version is staged; a truly empty first + // deployment has no safe zero-percent fallback. + if ( + ready === 'access-denied' && + !stagedVersionOverride && + previous.versionId !== null && + !options.requirePreviewForReadiness + ) { + const stableReadinessUrl = desiredTriggerConfig.workers_dev + ? workersDevReadinessUrl( + options.workerName, + extractWorkersDevSubdomain(readinessUrl) ?? options.workersDevSubdomain, + ) + : customRouteReadinessUrl(desiredTriggerConfig.routes); + if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) { + const stagingFailure = await stageCandidateForReadiness( + options, + previous.versionId, + versionId, + message, + ); + if (stagingFailure) { + return stagingFailure; + } + readinessUrl = stableReadinessUrl; + authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl); + readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev; + stagedVersionOverride = true; + ready = await probe(readinessUrl); + } + } + if (ready !== 'ready') { + return { + ok: false, + reason: ready === 'access-denied' ? 'readiness-access-denied' : 'readiness-failed', + versionId, + previousVersionId: previous.versionId, + ...(ready === 'access-denied' && withholdsCustomRouteHeaders(readinessUrl) + ? customRouteReadinessOriginField(readinessUrl) + : {}), + ...(stagedVersionOverride + ? await restorePreviousVersion(options, previous.versionId, 'readiness failed', [ + stagedVersionPercentages(previous.versionId, versionId), + ]) + : { rollback: 'not-needed' as const }), + }; } - } - if (ready !== 'ready') { - return { - ok: false, - reason: ready === 'access-denied' ? 'readiness-access-denied' : 'readiness-failed', - versionId, - previousVersionId: previous.versionId, - ...(ready === 'access-denied' && withholdsCustomRouteHeaders(readinessUrl) - ? customRouteReadinessOriginField(readinessUrl) - : {}), - ...(stagedVersionOverride - ? await restorePreviousVersion(options, previous.versionId, 'readiness failed', [ - stagedVersionPercentages(previous.versionId, versionId), - ]) - : { rollback: 'not-needed' as const }), - }; } if (!options.verifyUploader()) { return { @@ -1548,9 +1561,8 @@ async function activateCloudflareVersionWithSecretsFile( ok: true, versionId, previousVersionId: previous.versionId, - previewUrl: publishedCloudflareUrl(options.workerName, desiredTriggerConfig, readinessUrl), preservedTriggers: true, - ...workersDevSubdomainResult(readinessUrl), + ...activationUrlFields(options.workerName, desiredTriggerConfig, readinessUrl), }; } const knownTriggerConfigs: Record = { @@ -1661,7 +1673,6 @@ async function activateCloudflareVersionWithSecretsFile( return { ok: true, versionId, - previewUrl: publishedCloudflareUrl(options.workerName, desiredTriggerConfig, readinessUrl), previousVersionId: previous.versionId, ...(desiredTriggerConfig.preserve ? { preservedTriggers: true } : {}), ...(!desiredTriggerConfig.preserve @@ -1673,7 +1684,7 @@ async function activateCloudflareVersionWithSecretsFile( }, } : {}), - ...workersDevSubdomainResult(readinessUrl), + ...activationUrlFields(options.workerName, desiredTriggerConfig, readinessUrl), }; } @@ -2055,6 +2066,7 @@ async function bootstrapFirstDeployment( reason: atomic ? 'atomic-output-invalid' : 'bootstrap-output-invalid', previousVersionId, ...(versionId ? { versionId } : {}), + workersDev: desiredTriggerConfig.workers_dev, }; } if (!options.verifyUploader()) { @@ -2822,6 +2834,21 @@ function workersDevReadinessUrl(workerName: string, value: string | undefined): return `https://${workerName}.${subdomain}.workers.dev`; } +/** URL fields for a successful activation. Empty when nothing reaches the Worker. */ +function activationUrlFields( + workerName: string, + triggerConfig: CloudflareTriggerConfig, + readinessUrl: string | undefined, +): Pick, 'previewUrl' | 'workersDevSubdomain'> { + if (!readinessUrl) { + return {}; + } + return { + previewUrl: publishedCloudflareUrl(workerName, triggerConfig, readinessUrl), + ...workersDevSubdomainResult(readinessUrl), + }; +} + /** Resolve the stable URL that owns production traffic after triggers are published. */ function publishedCloudflareUrl( workerName: string, diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 36e3a2b42..ec045aeb8 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -647,6 +647,7 @@ describe('Cloudflare version activation', () => { reason: atomic ? 'atomic-output-invalid' : 'bootstrap-output-invalid', versionId: NEXT, previousVersionId: CURRENT, + workersDev: true, }); expect(fixture.events).not.toContain('triggers'); expect(fixture.fetcher).not.toHaveBeenCalled(); @@ -2785,7 +2786,66 @@ describe('Cloudflare version activation', () => { workerName: 'my-worker', message: encodeCloudflareVersionMessage([]), }), - ).resolves.toMatchObject({ ok: false, reason: 'preview-unavailable', versionId: NEXT }); + ).resolves.toMatchObject({ + ok: false, + reason: 'preview-unavailable', + versionId: NEXT, + workersDev: true, + }); + expect( + runWrangler.mock.calls.some(([args]) => args[0] === 'versions' && args[1] === 'deploy'), + ).toBe(false); + }); + + it('activates a later deploy without a readiness probe when the Worker has no entry URL', async () => { + const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); + const fetcher = vi.fn(async () => Response.json({ ready: true })); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'my-worker', + workersDevSubdomain: 'example', + message: noWorkersDevMessage(), + fetch: fetcher, + }); + expect(result).toMatchObject({ + ok: true, + versionId: NEXT, + previousVersionId: CURRENT, + }); + expect(result.ok ? result.previewUrl : 'failed').toBeUndefined(); + expect(fetcher).not.toHaveBeenCalled(); + const deployCalls = runWrangler.mock.calls.filter( + ([args]) => args[0] === 'versions' && args[1] === 'deploy', + ); + expect(deployCalls).toHaveLength(1); + expect(deployCalls[0]![0]).toContain(`${NEXT}@100`); + expect(deployCalls[0]![0].join(' ')).not.toContain(`${NEXT}@0`); + expect(runWrangler.mock.calls.some(([args]) => args[0] === 'triggers')).toBe(true); + expect(runWrangler.mock.calls.some(([args]) => args[0] === 'deploy')).toBe(false); + }); + + it('keeps refusing a workers.dev deploy that has no preview URL and no account subdomain', async () => { + const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); + const fetcher = vi.fn(async () => Response.json({ ready: true })); + await expect( + activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'my-worker', + message: encodeCloudflareVersionMessage([], triggersWith({ preview_urls: false })), + fetch: fetcher, + }), + ).resolves.toMatchObject({ + ok: false, + reason: 'preview-unavailable', + versionId: NEXT, + workersDev: true, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect( + runWrangler.mock.calls.some(([args]) => args[0] === 'versions' && args[1] === 'deploy'), + ).toBe(false); }); it('probes a zero-percent Durable Object version through a production workers.dev URL', async () => { diff --git a/packages/void/src/cli/deploy-cloudflare-helpers.ts b/packages/void/src/cli/deploy-cloudflare-helpers.ts index 681e689d5..a33ee28b5 100644 --- a/packages/void/src/cli/deploy-cloudflare-helpers.ts +++ b/packages/void/src/cli/deploy-cloudflare-helpers.ts @@ -456,7 +456,9 @@ export function recordCloudflareActivation(options: { completeCloudflareLink(root); } acknowledgeWranglerManagedQueueRemovals(configPath, options.queueConsumerCleanup); - rememberCloudflareWorkersDevSubdomain(root, options.accountId, execution.previewUrl); + if (execution.previewUrl) { + rememberCloudflareWorkersDevSubdomain(root, options.accountId, execution.previewUrl); + } if (execution.trafficConfig) { writeWranglerPublishedTrafficSnapshot( configPath, @@ -577,6 +579,13 @@ function formatCloudflareActivationFailure( if (result.diagnostic) { return `deploy: candidate readiness cannot be verified.\n\n${result.diagnostic}`; } + if (result.workersDev === false) { + return [ + 'deploy: the uploaded Worker Version has no URL Void can use to verify readiness.', + 'workers.dev is off for this Worker, so an account subdomain does not create one.', + 'Production traffic is unchanged.', + ].join('\n'); + } return [ 'deploy: the uploaded Worker Version has no preview URL, so Void cannot verify readiness.', 'Set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain (for example "my-team")', @@ -600,7 +609,7 @@ function formatCloudflareActivationFailure( // enables workers.dev during trigger synchronization. return formatCloudflarePublishWithoutUrl( `Cloudflare published Worker version ${result.versionId} atomically`, - `If the app uses workers.dev, set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain ${WORKERS_SUBDOMAIN_EXAMPLE}.`, + workersDevReadinessRecovery(result.workersDev, 'atomic'), ); } return 'deploy: Cloudflare published atomically but did not report its version id. Inspect `void project status` immediately.'; @@ -640,7 +649,7 @@ function formatCloudflareActivationFailure( // URL, which stays off until a trigger synchronization succeeds. return formatCloudflarePublishWithoutUrl( `Cloudflare published the first Worker deployment as version ${result.versionId}`, - `If the app uses workers.dev, enable workers.dev for this Worker in the Cloudflare dashboard. Then set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain ${WORKERS_SUBDOMAIN_EXAMPLE}.`, + workersDevReadinessRecovery(result.workersDev, 'bootstrap'), ); } return [ @@ -720,6 +729,29 @@ function formatCloudflareReadinessRestoration( const WORKERS_SUBDOMAIN_EXAMPLE = '(for example "my-team", or "my-team.fed" for FedRAMP High)'; +/** + * Tell the operator how to recover a publish whose URL Void could not determine. + * An account subdomain only becomes a readiness URL when workers.dev is on. + */ +function workersDevReadinessRecovery( + workersDev: boolean | undefined, + kind: 'atomic' | 'bootstrap', +): string { + if (workersDev === false) { + return 'workers.dev is off for this Worker, so an account subdomain does not create a readiness URL.'; + } + switch (kind) { + case 'bootstrap': + return `If the app uses workers.dev, enable workers.dev for this Worker in the Cloudflare dashboard. Then set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain ${WORKERS_SUBDOMAIN_EXAMPLE}.`; + case 'atomic': + return `If the app uses workers.dev, set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain ${WORKERS_SUBDOMAIN_EXAMPLE}.`; + default: { + const unreachable: never = kind; + return unreachable; + } + } +} + /** Explain a live version whose readiness URL Void could not determine. */ function formatCloudflarePublishWithoutUrl(published: string, recovery: string): string { return [ diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index b6ecdd7a9..3f1195555 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1836,7 +1836,7 @@ ${install} CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }} # Required only for PostgreSQL/MySQL provisioning and migrations. DATABASE_URL: \${{ secrets.DATABASE_URL }} - # Required in fresh checkouts when a Worker Version has no preview URL. + # Account subdomain for workers.dev readiness when a version has no preview URL. CLOUDFLARE_WORKERS_SUBDOMAIN: \${{ vars.CLOUDFLARE_WORKERS_SUBDOMAIN }} # Required only when Cloudflare Access protects Worker readiness. CF_ACCESS_CLIENT_ID: \${{ secrets.CF_ACCESS_CLIENT_ID }} diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 28e3269f9..d78388f4b 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -278,6 +278,52 @@ describe('Cloudflare activation diagnostics', () => { }, ); + it('does not suggest an account subdomain when workers.dev is off', () => { + const versionId = '22222222-2222-4222-8222-222222222222'; + for (const reason of [ + 'preview-unavailable', + 'bootstrap-output-invalid', + 'atomic-output-invalid', + ] as const) { + const message = formatCloudflareActivationError({ + ok: false, + reason, + versionId, + workersDev: false, + }); + expect(message).not.toContain('CLOUDFLARE_WORKERS_SUBDOMAIN'); + expect(message).not.toContain('enable workers.dev'); + } + }); + + it('keeps the workers.dev subdomain hint when workers.dev is on', () => { + const versionId = '22222222-2222-4222-8222-222222222222'; + const preview = formatCloudflareActivationError({ + ok: false, + reason: 'preview-unavailable', + versionId, + workersDev: true, + }); + expect(preview).toContain('Set CLOUDFLARE_WORKERS_SUBDOMAIN'); + expect(preview).toContain('production traffic is unchanged'); + const bootstrap = formatCloudflareActivationError({ + ok: false, + reason: 'bootstrap-output-invalid', + versionId, + workersDev: true, + }); + expect(bootstrap).toContain('CLOUDFLARE_WORKERS_SUBDOMAIN'); + expect(bootstrap).toContain('enable workers.dev for this Worker'); + const atomic = formatCloudflareActivationError({ + ok: false, + reason: 'atomic-output-invalid', + versionId, + workersDev: true, + }); + expect(atomic).toContain('CLOUDFLARE_WORKERS_SUBDOMAIN'); + expect(atomic).not.toContain('enable workers.dev'); + }); + /** A project whose root and upload configs are separate files. */ function lookupProject(rootRegion: string | undefined) { const root = makeProject({ @@ -1203,6 +1249,32 @@ describe('existing Cloudflare site handoff', () => { expect(lookups).toEqual([{ ok: true, subdomain: 'team.fed' }]); }); + it('refuses an atomic deploy when the Worker has no Durable Object migration history', async () => { + const root = site(); + const activateVersion = vi.fn(); + const assertDurableObjectLifecycle = vi.fn(); + const { deps } = harness({ + runBuild: () => build(root), + activateVersion, + assertDurableObjectLifecycle, + }); + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + try { + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare', atomic: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(output).toHaveBeenCalledWith( + expect.stringContaining( + 'atomic deployment is only for Workers with Durable Object migration history', + ), + ); + expect(activateVersion).not.toHaveBeenCalled(); + expect(assertDurableObjectLifecycle).not.toHaveBeenCalled(); + } finally { + output.mockRestore(); + } + }); + it('allows an explicit atomic deploy after the Durable Object migration preflight', async () => { const root = site(); const configPath = join(root, 'wrangler.jsonc'); From c69b5f3a6cab1fdb9823ad79fb58941870f8dc88 Mon Sep 17 00:00:00 2001 From: froQ Date: Wed, 4 Nov 2026 15:15:53 +0000 Subject: [PATCH 2/4] style(docs): align Cloudflare deploy tables Reformat the deploy reference and Cloudflare guide tables so the format check accepts them. Co-authored-by: sayhola --- docs/integrations/cloudflare.md | 14 +++++++------- docs/reference/cli/deploy.md | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 61ef627c5..92df13e27 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -133,14 +133,14 @@ Apps using email need `email.from` in `void.config.ts`. Void reviews the domain' Set these CI secrets and variables as needed: -| Name | When needed | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | -| `CLOUDFLARE_API_TOKEN` | Required. Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. | -| `CLOUDFLARE_ACCOUNT_ID` | When the token can access several accounts. | -| `DATABASE_URL` | PostgreSQL and MySQL provisioning and migrations. | +| Name | When needed | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | Required. Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. | +| `CLOUDFLARE_ACCOUNT_ID` | When the token can access several accounts. | +| `DATABASE_URL` | PostgreSQL and MySQL provisioning and migrations. | | `CLOUDFLARE_WORKERS_SUBDOMAIN` | When workers.dev is enabled and the version has no preview URL. Use `my-team` or `my-team.workers.dev`. An account subdomain does not create a readiness URL when `workers_dev` is false. | -| `CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` | When Cloudflare Access protects the readiness URL. | -| `CF_ACCESS_READINESS_ORIGIN` | When Access protects a route or Custom Domain and `workers_dev` is `false`. See [Cloudflare Access](#cloudflare-access). | +| `CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` | When Cloudflare Access protects the readiness URL. | +| `CF_ACCESS_READINESS_ORIGIN` | When Access protects a route or Custom Domain and `workers_dev` is `false`. See [Cloudflare Access](#cloudflare-access). | For email, run `void email setup --platform cloudflare` locally, commit `void.lock.json`, and pass `--require-email` in CI. The token also needs Email Routing Edit and Email Sending Edit. diff --git a/docs/reference/cli/deploy.md b/docs/reference/cli/deploy.md index 663c1115a..0a8745c6d 100644 --- a/docs/reference/cli/deploy.md +++ b/docs/reference/cli/deploy.md @@ -86,16 +86,16 @@ void deploy --platform cloudflare --require-email # fail instead of deploying Void signs you in through your browser when needed and saves the selected account. In CI, set `CLOUDFLARE_API_TOKEN` and, if the token can access several accounts, `CLOUDFLARE_ACCOUNT_ID`. -| Option or setting | Cloudflare behavior | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| `--dir`, `--spa` | Deploy static output through a small Worker and Workers Assets | -| `--skip-build` | Reuse existing static, SPA, or SSG output; unavailable for Worker apps | -| `--project` | Unavailable; the Worker and account come from `void.config.ts` and `void.lock.json` | -| Named environments | Unavailable; use the top-level `cloudflare` config | +| Option or setting | Cloudflare behavior | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `--dir`, `--spa` | Deploy static output through a small Worker and Workers Assets | +| `--skip-build` | Reuse existing static, SPA, or SSG output; unavailable for Worker apps | +| `--project` | Unavailable; the Worker and account come from `void.config.ts` and `void.lock.json` | +| Named environments | Unavailable; use the top-level `cloudflare` config | | `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when a workers.dev Worker has no preview URL; cached locally after a deploy. It does not apply when `workers_dev` is false | -| `CF_ACCESS_READINESS_ORIGIN` | Lets Access credentials reach the readiness check on a route or Custom Domain, for example `https://app.example.com` | -| `--require-email` | Fail instead of deploying without email when the email step cannot run, as in CI | -| `DATABASE_URL` | Required in the deploy environment for PostgreSQL or MySQL provisioning and migrations | +| `CF_ACCESS_READINESS_ORIGIN` | Lets Access credentials reach the readiness check on a route or Custom Domain, for example `https://app.example.com` | +| `--require-email` | Fail instead of deploying without email when the email step cannot run, as in CI | +| `DATABASE_URL` | Required in the deploy environment for PostgreSQL or MySQL provisioning and migrations | The token needs Workers Scripts: Edit, read access to bound resources, and edit access to products Void provisions. Commit `void.lock.json` after the first deploy before using other machines or CI. From 44d005a859d153baf1d387bcacb93b72759e1c0d Mon Sep 17 00:00:00 2001 From: froQ Date: Thu, 5 Nov 2026 07:16:42 +0000 Subject: [PATCH 3/4] fix(deploy): skip readiness only when a Worker has no HTTP routes A route that does not cover /__void/ready still has HTTP traffic. Fail that deploy before activation, and skip the probe only when workers.dev is off and the route list is empty. --- packages/deploy-cloudflare/src/version.ts | 8 +++-- .../test/unit/version.test.ts | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index f9188574a..e1c933ed5 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -1365,16 +1365,18 @@ async function activateCloudflareVersionWithSecretsFile( return stagingFailure; } stagedVersionOverride = true; - } else if (desiredTriggerConfig.workers_dev) { + } else if (desiredTriggerConfig.workers_dev || desiredTriggerConfig.routes.length > 0) { + // A non-empty route list still has HTTP traffic even when no pattern covers + // /__void/ready, such as `*.example.com/*` or `app.example.com/api/*`. return { ok: false, reason: 'preview-unavailable', versionId, previousVersionId: previous.versionId, - workersDev: true, + workersDev: desiredTriggerConfig.workers_dev, }; } - // workers.dev is off and no route reaches this Worker, so there is no readiness URL. + // workers.dev is off and the route list is empty, so this Worker has no HTTP entry. // A first deploy already publishes that Worker without a probe. Activate this upload // the same way instead of stopping before traffic moves. } diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index ec045aeb8..f37902458 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -2825,6 +2825,42 @@ describe('Cloudflare version activation', () => { expect(runWrangler.mock.calls.some(([args]) => args[0] === 'deploy')).toBe(false); }); + it.each(['*.example.com/*', 'app.example.com/api/*'])( + 'refuses a later deploy when %s has HTTP traffic but no probe URL', + async (pattern) => { + const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); + const fetcher = vi.fn(async () => Response.json({ ready: true })); + await expect( + activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'my-worker', + workersDevSubdomain: 'example', + message: encodeCloudflareVersionMessage( + [], + triggersWith({ + workers_dev: false, + preview_urls: false, + routes: [{ pattern }], + }), + ), + fetch: fetcher, + }), + ).resolves.toMatchObject({ + ok: false, + reason: 'preview-unavailable', + versionId: NEXT, + workersDev: false, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect( + runWrangler.mock.calls.some( + ([args]) => (args[0] === 'versions' && args[1] === 'deploy') || args[0] === 'deploy', + ), + ).toBe(false); + }, + ); + it('keeps refusing a workers.dev deploy that has no preview URL and no account subdomain', async () => { const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); const fetcher = vi.fn(async () => Response.json({ ready: true })); From 3a46ef8b12ff16932ec20d6d9a7e6593156f6544 Mon Sep 17 00:00:00 2001 From: froQ Date: Thu, 5 Nov 2026 15:37:36 +0800 Subject: [PATCH 4/4] fix(deploy): refuse skip when preserveRoutes hides remote routes resolvePreservedTraffic strips preserveRoutes onto previous.routes, so an empty previous annotation made the no-HTTP skip fire even when local config asked to preserve remote routes. Keep the original preserveRoutes intent and fail with preview-unavailable instead. --- packages/deploy-cloudflare/src/version.ts | 18 ++++------ .../test/unit/version.test.ts | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index e1c933ed5..3d47e62ec 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -77,7 +77,6 @@ export type CloudflareVersionActivation = | { ok: true; versionId: string; - /** Absent when the Worker has no preview, workers.dev hostname, or route Void can reach. */ previewUrl?: string; previousVersionId: string | null; workersDevSubdomain?: string; @@ -134,10 +133,6 @@ export type CloudflareVersionActivation = * credentials because `readinessHeadersOrigin` did not name the origin. */ customRouteReadinessOrigin?: string; - /** - * Whether this Worker enables workers.dev. An account subdomain does not create a readiness - * URL when this is false. - */ workersDev?: boolean; }; @@ -1061,6 +1056,7 @@ async function activateCloudflareVersionWithSecretsFile( routes: previousMetadata.triggerConfig.routes, } : undefined); + const preserveRoutesRequested = desiredTriggerConfig.preserveRoutes === true; if (continuesOpaqueTriggerPreservation) { desiredTriggerConfig = { ...desiredTriggerConfig, preserve: true }; } else if (!options.preserveTriggers && desiredMetadata?.schema === 2) { @@ -1365,9 +1361,11 @@ async function activateCloudflareVersionWithSecretsFile( return stagingFailure; } stagedVersionOverride = true; - } else if (desiredTriggerConfig.workers_dev || desiredTriggerConfig.routes.length > 0) { - // A non-empty route list still has HTTP traffic even when no pattern covers - // /__void/ready, such as `*.example.com/*` or `app.example.com/api/*`. + } else if ( + desiredTriggerConfig.workers_dev || + desiredTriggerConfig.routes.length > 0 || + preserveRoutesRequested + ) { return { ok: false, reason: 'preview-unavailable', @@ -1376,9 +1374,6 @@ async function activateCloudflareVersionWithSecretsFile( workersDev: desiredTriggerConfig.workers_dev, }; } - // workers.dev is off and the route list is empty, so this Worker has no HTTP entry. - // A first deploy already publishes that Worker without a probe. Activate this upload - // the same way instead of stopping before traffic moves. } if (readinessUrl) { @@ -2836,7 +2831,6 @@ function workersDevReadinessUrl(workerName: string, value: string | undefined): return `https://${workerName}.${subdomain}.workers.dev`; } -/** URL fields for a successful activation. Empty when nothing reaches the Worker. */ function activationUrlFields( workerName: string, triggerConfig: CloudflareTriggerConfig, diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index f37902458..813b549d8 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -2861,6 +2861,40 @@ describe('Cloudflare version activation', () => { }, ); + it('refuses a later deploy when preserveRoutes leaves remote HTTP routes unknown', async () => { + const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); + const fetcher = vi.fn(async () => Response.json({ ready: true })); + await expect( + activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'my-worker', + workersDevSubdomain: 'example', + message: encodeCloudflareVersionMessage( + [], + triggersWith({ + workers_dev: false, + preview_urls: false, + routes: [], + preserveRoutes: true, + }), + ), + fetch: fetcher, + }), + ).resolves.toMatchObject({ + ok: false, + reason: 'preview-unavailable', + versionId: NEXT, + workersDev: false, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect( + runWrangler.mock.calls.some( + ([args]) => (args[0] === 'versions' && args[1] === 'deploy') || args[0] === 'deploy', + ), + ).toBe(false); + }); + it('keeps refusing a workers.dev deploy that has no preview URL and no account subdomain', async () => { const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); const fetcher = vi.fn(async () => Response.json({ ready: true }));