diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index a7c1e2d10..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. | -| `CLOUDFLARE_WORKERS_SUBDOMAIN` | When the Worker has no version preview URL. Use `my-team` or `my-team.workers.dev`. | -| `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). | +| 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). | 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. @@ -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..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 | -| `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when versions have no preview URL; cached locally after a deploy | -| `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 | +| 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 | 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. diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 279acfd65..3d47e62ec 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -77,7 +77,7 @@ export type CloudflareVersionActivation = | { ok: true; versionId: string; - previewUrl: string; + previewUrl?: string; previousVersionId: string | null; workersDevSubdomain?: string; preservedTriggers?: boolean; @@ -133,6 +133,7 @@ export type CloudflareVersionActivation = * credentials because `readinessHeadersOrigin` did not name the origin. */ customRouteReadinessOrigin?: string; + workersDev?: boolean; }; type ProductionDeployment = Readonly<{ @@ -1055,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) { @@ -1344,72 +1346,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 +1360,97 @@ 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 || + desiredTriggerConfig.routes.length > 0 || + preserveRoutesRequested + ) { + return { + ok: false, + reason: 'preview-unavailable', + versionId, + previousVersionId: previous.versionId, + workersDev: desiredTriggerConfig.workers_dev, + }; } } - 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 (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 (!options.verifyUploader()) { return { @@ -1548,9 +1558,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 +1670,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 +1681,7 @@ async function activateCloudflareVersionWithSecretsFile( }, } : {}), - ...workersDevSubdomainResult(readinessUrl), + ...activationUrlFields(options.workerName, desiredTriggerConfig, readinessUrl), }; } @@ -2055,6 +2063,7 @@ async function bootstrapFirstDeployment( reason: atomic ? 'atomic-output-invalid' : 'bootstrap-output-invalid', previousVersionId, ...(versionId ? { versionId } : {}), + workersDev: desiredTriggerConfig.workers_dev, }; } if (!options.verifyUploader()) { @@ -2822,6 +2831,20 @@ function workersDevReadinessUrl(workerName: string, value: string | undefined): return `https://${workerName}.${subdomain}.workers.dev`; } +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..813b549d8 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,136 @@ 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.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('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 })); + 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');