diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index be801e95f..c6ccb4b31 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -39,6 +39,7 @@ export type CloudflareOperation = type: 'versions.deploy'; versions: Array<{ versionId: string; percentage: number }>; message?: string; + bypassDeploymentChecks?: boolean; } & WorkerTarget) | ({ type: 'versions.upload' | 'worker.deploy'; diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index c040b8706..d951b63d6 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -18,6 +18,7 @@ import type { CloudflareUploadResult } from './operations.ts'; import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; const DURABLE_MIGRATION_VERSION_ERROR = 10211; +const CLOUDFLARE_SECRET_CHANGED_ERROR = 10220; export type CloudflareVersionMetadata = | Readonly<{ @@ -107,6 +108,12 @@ export type CloudflareVersionActivation = versionId?: string; previousVersionId?: string | null; rollback?: 'not-needed' | 'succeeded' | 'failed' | 'unavailable' | 'unsafe'; + /** + * Cloudflare refused to restore the previous version because a secret + * changed with this deploy, so Void forced it. The previous version runs + * with its earlier secret values. + */ + restoredOlderSecrets?: true; /** Version that owned production after a concurrent operation superseded this command. */ activeVersionId?: string | null; /** Whether Void restored the active version's immutable trigger snapshot. */ @@ -1384,11 +1391,11 @@ async function activateCloudflareVersionWithSecretsFile( reason: ready === 'access-denied' ? 'readiness-access-denied' : 'readiness-failed', versionId, previousVersionId: previous.versionId, - rollback: stagedVersionOverride + ...(stagedVersionOverride ? await restorePreviousVersion(options, previous.versionId, 'readiness failed', [ stagedVersionPercentages(previous.versionId, versionId), ]) - : 'not-needed', + : { rollback: 'not-needed' as const }), }; } if (!options.verifyUploader()) { @@ -1397,14 +1404,14 @@ async function activateCloudflareVersionWithSecretsFile( reason: 'uploader-changed', versionId, previousVersionId: previous.versionId, - rollback: stagedVersionOverride + ...(stagedVersionOverride ? await restorePreviousVersion( options, previous.versionId, 'uploader verification failed', [stagedVersionPercentages(previous.versionId, versionId)], ) - : 'not-needed', + : { rollback: 'not-needed' as const }), }; } @@ -1460,12 +1467,12 @@ async function activateCloudflareVersionWithSecretsFile( status: activate.status, versionId, previousVersionId: previous.versionId, - rollback: stagedVersionOverride + ...(stagedVersionOverride ? await restorePreviousVersion(options, previous.versionId, 'version activation failed', [ stagedVersionPercentages(previous.versionId, versionId), [{ versionId, percentage: 100 }], ]) - : 'not-needed', + : { rollback: 'not-needed' as const }), }; } @@ -1577,7 +1584,7 @@ async function activateCloudflareVersionWithSecretsFile( triggerRepair: triggerRepairOutcome(repair), }; } - const rollback = await restorePreviousVersionAndTriggers( + const restore = await restorePreviousVersionAndTriggers( options, previous.versionId, 'trigger activation failed', @@ -1591,7 +1598,7 @@ async function activateCloudflareVersionWithSecretsFile( status: triggers.status, versionId, previousVersionId: previous.versionId, - rollback, + ...restore, }; } @@ -1641,15 +1648,15 @@ async function restorePreviousVersionAndTriggers( failedTriggerConfig: CloudflareTriggerConfig, failedVersionId: string, capturedTriggerConfig?: CloudflareTriggerConfig, -): Promise<'succeeded' | 'failed' | 'unavailable' | 'unsafe'> { +): Promise { const traffic = await restorePreviousVersion(options, previousVersionId, cause, [ [{ versionId: failedVersionId, percentage: 100 }], ]); - if (traffic !== 'succeeded' || !previousVersionId) { + if (traffic.rollback !== 'succeeded' || !previousVersionId) { return traffic; } if (!options.verifyUploader()) { - return 'unsafe'; + return { ...traffic, rollback: 'unsafe' }; } // Traffic attachments can change between uploads (for example `void domain // add`). Restore the snapshot captured before this deployment, not the older @@ -1659,17 +1666,17 @@ async function restorePreviousVersionAndTriggers( if (!targetTriggerConfig) { const metadata = await readCloudflareVersionMetadata(options, previousVersionId); if (metadata?.schema !== 2) { - return 'unavailable'; + return { ...traffic, rollback: 'unavailable' }; } targetTriggerConfig = metadata.triggerConfig; } // A preserve marker leaves routes, schedules, queue consumers and workflows opaque, so there // is no trigger snapshot to restore. Traffic is already back on the previous version. if (targetTriggerConfig.preserve) { - return 'unavailable'; + return { ...traffic, rollback: 'unavailable' }; } if (!options.verifyUploader()) { - return 'unsafe'; + return { ...traffic, rollback: 'unsafe' }; } const triggers = await stabilizeCloudflareVersionTriggers({ runWrangler: options.runWrangler, @@ -1682,7 +1689,10 @@ async function restorePreviousVersionAndTriggers( publishEmptyCustomDomains: options.publishEmptyCustomDomains, publishEmptyCrons: options.publishEmptyCrons, }); - return triggers.ok && triggers.targetOwnsTraffic ? 'succeeded' : 'failed'; + return { + ...traffic, + rollback: triggers.ok && triggers.targetOwnsTraffic ? 'succeeded' : 'failed', + }; } async function readCloudflareVersionMetadata( @@ -2534,7 +2544,7 @@ async function deployVersionPercentages( options: Pick< Parameters[0], 'runWrangler' | 'workerName' | 'preserveTriggers' | 'configPath' - >, + > & { bypassDeploymentChecks?: boolean }, percentages: Array, message: string, ): Promise { @@ -2557,6 +2567,7 @@ async function deployVersionPercentages( configPath: options.configPath, workerName: options.workerName, message, + ...(options.bypassDeploymentChecks ? { bypassDeploymentChecks: true } : {}), }, { capture: false }, ); @@ -2569,6 +2580,7 @@ export async function deployCloudflareVersionTraffic(options: { workerName: string; percentages: Array; message: string; + bypassDeploymentChecks?: boolean; }): Promise { const config = pickWranglerTriggerContext(parseRootWranglerConfig(options.configPath)); const directory = mkdtempSync(join(tmpdir(), 'void-version-traffic-')); @@ -2582,6 +2594,7 @@ export async function deployCloudflareVersionTraffic(options: { configPath: path, workerName: options.workerName, message: options.message, + ...(options.bypassDeploymentChecks ? { bypassDeploymentChecks: true } : {}), }, { capture: false }, ); @@ -2590,6 +2603,11 @@ export async function deployCloudflareVersionTraffic(options: { } } +type PreviousVersionRestore = Readonly<{ + rollback: 'succeeded' | 'failed' | 'unavailable' | 'unsafe'; + restoredOlderSecrets?: true; +}>; + async function restorePreviousVersion( options: Pick< Parameters[0], @@ -2598,28 +2616,45 @@ async function restorePreviousVersion( previousVersionId: string | null, cause: string, expectedOwnedTraffic: ReadonlyArray>, -): Promise<'succeeded' | 'failed' | 'unavailable' | 'unsafe'> { +): Promise { if (!previousVersionId) { - return 'unavailable'; + return { rollback: 'unavailable' }; } if (!options.verifyUploader()) { - return 'unsafe'; - } - const owner = await readCurrentProductionVersion(options); - if ( - owner.kind !== 'ok' || - !expectedOwnedTraffic.some((expected) => - productionVersionPercentagesEqual(owner.versions, expected), - ) - ) { - return 'unsafe'; + return { rollback: 'unsafe' }; } - const result = await deployVersionPercentages( - options, - [`${previousVersionId}@100`], - `Void automatic rollback after ${cause}.`, - ); - return isCleanCommandExit(result) ? 'succeeded' : 'failed'; + const ownsExpectedTraffic = async () => { + const owner = await readCurrentProductionVersion(options); + return ( + owner.kind === 'ok' && + expectedOwnedTraffic.some((expected) => + productionVersionPercentagesEqual(owner.versions, expected), + ) + ); + }; + if (!(await ownsExpectedTraffic())) { + return { rollback: 'unsafe' }; + } + const restore = (bypassDeploymentChecks: boolean) => + deployVersionPercentages( + { ...options, bypassDeploymentChecks }, + [`${previousVersionId}@100`], + `Void automatic rollback after ${cause}.`, + ); + const result = await restore(false); + // Cloudflare refuses to move traffic back to a version older than a secret + // change (10220). When the failed version made that change, force the + // restore, as Wrangler's rollback does. Check again first that Void still + // owns traffic, so a deployment made by someone else is not overwritten. + if (result.errorCode === CLOUDFLARE_SECRET_CHANGED_ERROR) { + if (!(await ownsExpectedTraffic())) { + return { rollback: 'unsafe' }; + } + return isCleanCommandExit(await restore(true)) + ? { rollback: 'succeeded', restoredOlderSecrets: true } + : { rollback: 'failed' }; + } + return { rollback: isCleanCommandExit(result) ? 'succeeded' : 'failed' }; } function workersDevReadinessUrl(workerName: string, value: string | undefined): string | undefined { diff --git a/packages/deploy-cloudflare/test/unit/operations.test.ts b/packages/deploy-cloudflare/test/unit/operations.test.ts index 504277194..3592d0eb3 100644 --- a/packages/deploy-cloudflare/test/unit/operations.test.ts +++ b/packages/deploy-cloudflare/test/unit/operations.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { expect, it, vi } from 'vite-plus/test'; import type { CloudflareCommandResult, CloudflareCommandRunner } from '../../src/execute.ts'; import type { CloudflareOperation } from '../../src/operations.ts'; @@ -5,10 +8,17 @@ import { activateCloudflareVersion, encodeCloudflareVersionMessage } from '../.. const current = '11111111-1111-4111-8111-111111111111'; const next = '22222222-2222-4222-8222-222222222222'; +const other = '33333333-3333-4333-8333-333333333333'; -function setup(data: unknown) { +function setup( + data: unknown, + failure?: 'activation' | 'triggers', + // How Cloudflare answers a deploy of the previous version without force. + rollback?: 'secret-changed' | 'deployed-elsewhere' | 'error', +) { const calls: Array = []; let uploaded = false; + let triggerFailed = false; let versions = [{ version_id: current, percentage: 100 }]; const runWrangler: CloudflareCommandRunner = vi.fn((operation: CloudflareOperation) => { calls.push(operation); @@ -22,17 +32,59 @@ function setup(data: unknown) { case 'deployments.status': return ok({ versions }); case 'versions.list': - return ok([{ id: current, number: 1 }, ...(uploaded ? [{ id: next, number: 2 }] : [])]); + return ok([ + { + id: current, + number: 1, + annotations: { + 'workers/message': encodeCloudflareVersionMessage([], { + workers_dev: true, + preview_urls: true, + routes: [], + crons: ['0 * * * *'], + queueConsumers: [], + workflows: [], + }), + }, + }, + ...(uploaded ? [{ id: next, number: 2 }] : []), + ]); case 'versions.upload': uploaded = true; return ok(data); case 'versions.deploy': + if ( + rollback && + !operation.bypassDeploymentChecks && + operation.versions.length === 1 && + operation.versions[0]?.versionId === current + ) { + if (rollback === 'error') { + return { ...ok(), status: 1, errorCode: 10000 }; + } + if (rollback === 'deployed-elsewhere') { + versions = [{ version_id: other, percentage: 100 }]; + } + return { ...ok(), status: 1, errorCode: 10220 }; + } + if ( + failure === 'activation' && + operation.versions.some( + (version) => version.versionId === next && version.percentage === 100, + ) + ) { + return { ...ok(), status: 1 }; + } versions = operation.versions.map(({ versionId, percentage }) => ({ version_id: versionId, percentage, })); return ok(); case 'triggers.deploy': + if (failure === 'triggers' && !triggerFailed) { + triggerFailed = true; + return { ...ok(), status: 1 }; + } return ok(); default: throw new Error(`Unexpected operation ${operation.type}.`); @@ -63,6 +115,112 @@ it('activates structured upload data independently of provider presentation', as type: 'versions.deploy', versions: [{ versionId: next, percentage: 100 }], }); + expect(calls.find((operation) => operation.type === 'versions.deploy')).not.toHaveProperty( + 'bypassDeploymentChecks', + ); +}); + +const recoveries = [ + { failure: 'readiness', preserveTriggers: false, reason: 'readiness-failed' }, + { failure: 'readiness', preserveTriggers: true, reason: 'readiness-failed' }, + { failure: 'activation', preserveTriggers: false, reason: 'activation-failed' }, + { failure: 'activation', preserveTriggers: true, reason: 'activation-failed' }, + { failure: 'triggers', preserveTriggers: false, reason: 'triggers-failed' }, +] as const; + +async function recover( + { failure, preserveTriggers }: (typeof recoveries)[number], + rollback?: Parameters[2], +) { + const directory = mkdtempSync(join(tmpdir(), 'void-rollback-checks-')); + const configPath = join(directory, 'wrangler.json'); + writeFileSync(configPath, JSON.stringify({ name: 'site' })); + try { + const { calls, runWrangler } = setup( + { versionId: next }, + failure === 'readiness' ? undefined : failure, + rollback, + ); + const result = await activateCloudflareVersion({ + runWrangler, + configPath, + workerName: 'site', + workersDevSubdomain: 'example', + preserveTriggers, + verifyUploader: () => true, + message: encodeCloudflareVersionMessage([]), + readinessAttempts: 1, + fetch: async () => + failure === 'readiness' + ? new Response('not ready', { status: 503 }) + : Response.json({ ready: true }), + }); + const rollbacks = calls.filter( + (operation) => + operation.type === 'versions.deploy' && + operation.versions.length === 1 && + operation.versions[0]?.versionId === current, + ); + return { calls, result, rollbacks }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +it.each(recoveries)( + 'restores $failure failures without force when Cloudflare accepts it (preserveTriggers=$preserveTriggers)', + async (recovery) => { + const { calls, result, rollbacks } = await recover(recovery); + expect(result).toMatchObject({ ok: false, reason: recovery.reason, rollback: 'succeeded' }); + expect(result).not.toHaveProperty('restoredOlderSecrets'); + expect(rollbacks).toHaveLength(1); + for (const operation of calls) { + expect(operation).not.toHaveProperty('bypassDeploymentChecks'); + } + }, +); + +it.each(recoveries)( + 'forces the $failure restore after Cloudflare refuses an old secret version (preserveTriggers=$preserveTriggers)', + async (recovery) => { + const { calls, result, rollbacks } = await recover(recovery, 'secret-changed'); + expect(result).toMatchObject({ + ok: false, + reason: recovery.reason, + rollback: 'succeeded', + restoredOlderSecrets: true, + }); + expect(rollbacks).toEqual([ + expect.not.objectContaining({ bypassDeploymentChecks: true }), + expect.objectContaining({ bypassDeploymentChecks: true }), + ]); + // Void checks that it still owns traffic between the refusal and the forced retry. + const refused = calls.indexOf(rollbacks[0]!); + expect( + calls.slice(refused, calls.indexOf(rollbacks[1]!)).map((operation) => operation.type), + ).toContain('deployments.status'); + expect( + calls.filter( + (operation) => operation.type === 'versions.deploy' && operation.bypassDeploymentChecks, + ), + ).toEqual([rollbacks[1]]); + }, +); + +it('does not force a restore after someone else deploys', async () => { + const { result, rollbacks } = await recover(recoveries[0], 'deployed-elsewhere'); + expect(result).toMatchObject({ ok: false, reason: 'readiness-failed', rollback: 'unsafe' }); + expect(result).not.toHaveProperty('restoredOlderSecrets'); + expect(rollbacks).toHaveLength(1); + expect(rollbacks[0]).not.toHaveProperty('bypassDeploymentChecks'); +}); + +it('does not force a restore that failed for another reason', async () => { + const { result, rollbacks } = await recover(recoveries[0], 'error'); + expect(result).toMatchObject({ ok: false, reason: 'readiness-failed', rollback: 'failed' }); + expect(result).not.toHaveProperty('restoredOlderSecrets'); + expect(rollbacks).toHaveLength(1); + expect(rollbacks[0]).not.toHaveProperty('bypassDeploymentChecks'); }); it('rejects missing structured upload identity before activation', async () => { diff --git a/packages/void/src/cli/cf-version-runner.ts b/packages/void/src/cli/cf-version-runner.ts index 54cfe8063..15e43e249 100644 --- a/packages/void/src/cli/cf-version-runner.ts +++ b/packages/void/src/cli/cf-version-runner.ts @@ -676,6 +676,7 @@ export function createCfVersionRunner(options: { }), ] : []), + ...(operation.bypassDeploymentChecks ? ['--bypass-deployment-checks'] : []), ], command.capture, ); diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 844fb7e23..ebf91d53f 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -1015,6 +1015,7 @@ async function workerHistory(): Promise { versions, ...(operation.message ? { annotations: { 'workers/message': operation.message } } : {}), }), + ...(operation.bypassDeploymentChecks ? ['--bypass-deployment-checks'] : []), ]); } default: diff --git a/packages/void/src/cli/cloudflare-operations.ts b/packages/void/src/cli/cloudflare-operations.ts index f2728a4d9..7c5ae7c52 100644 --- a/packages/void/src/cli/cloudflare-operations.ts +++ b/packages/void/src/cli/cloudflare-operations.ts @@ -528,6 +528,8 @@ export async function activateCloudflareExistingVersion( ); } let recoveryFailure: string | null = null; + // Not forced: `current` served traffic until this rollback, so Cloudflare + // refuses it (10220) only when someone else has changed a secret since. try { await run( context, diff --git a/packages/void/src/cli/deploy-cloudflare-helpers.ts b/packages/void/src/cli/deploy-cloudflare-helpers.ts index 62559f058..72afd4bf1 100644 --- a/packages/void/src/cli/deploy-cloudflare-helpers.ts +++ b/packages/void/src/cli/deploy-cloudflare-helpers.ts @@ -471,6 +471,15 @@ export async function activateCloudflareVersionWithProgress( export function formatCloudflareActivationError( result: Exclude, +): string { + const message = formatCloudflareActivationFailure(result); + return result.restoredOlderSecrets + ? `${message}\nCloudflare refused to restore the previous version because this deploy changed a secret, so Void forced the restore. The previous version runs with its old secret values; deploy again to apply the new ones.` + : message; +} + +function formatCloudflareActivationFailure( + result: Exclude, ): string { const status = result.status === undefined ? '' : ` (exit ${String(result.status)})`; const cloudflareCode = diff --git a/packages/void/src/cli/platform-installer-workers.ts b/packages/void/src/cli/platform-installer-workers.ts index 60cab9551..ebb87bdee 100644 --- a/packages/void/src/cli/platform-installer-workers.ts +++ b/packages/void/src/cli/platform-installer-workers.ts @@ -1249,6 +1249,8 @@ export async function restorePlatformWorkerDeployment(input: { `Worker ${input.workerName} changed concurrently before deployment restoration; refusing to overwrite the newer deployment.`, ); } + // Not forced: when Cloudflare refuses a version that holds old secrets + // (10220), a credential update rolls forward on retry instead. const restored = await input.runner( { type: 'versions.deploy', diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 61105472c..b400006cf 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -2575,6 +2575,44 @@ it('preserves activation metadata and sorts cf version history for the existing } }); +it.each([undefined, false, true])( + 'passes bypassDeploymentChecks=%s only when true with and without a deployment body', + async (bypassDeploymentChecks) => { + const prepared = await output(); + try { + const calls: Array> = []; + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + runCf: (args) => { + calls.push(args); + return { status: 0, stdout: '', stderr: '' }; + }, + }); + for (const message of [undefined, 'Void automatic rollback after readiness failed.']) { + const result = await runner( + { + type: 'versions.deploy', + workerName: 'void-test', + versions: [{ versionId: '00000000-0000-0000-0000-000000000001', percentage: 100 }], + message, + bypassDeploymentChecks, + }, + { capture: false }, + ); + expect(result.status).toBe(0); + const args = calls.at(-1)!; + expect(args.includes('--bypass-deployment-checks')).toBe(bypassDeploymentChecks === true); + expect(args.includes('--body')).toBe(message !== undefined); + expect(args).toContain('--versions'); + } + } finally { + prepared.dispose(); + } + }, +); + it.each(['newest-first', 'oldest-first'] as const)( 'reads the actual latest ten versions when cf pages %s', async (order) => { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 549f5bd43..89a6bb3e3 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -282,6 +282,25 @@ describe('Cloudflare activation diagnostics', () => { ).toContain('did not invoke the changed Wrangler binary'); }); + it.each(['readiness-failed', 'triggers-failed', 'uploader-changed'] as const)( + 'says when a forced %s restore put the old secret values back', + (reason) => { + const forced = formatCloudflareActivationError({ + ok: false, + reason, + rollback: 'succeeded', + restoredOlderSecrets: true, + }); + expect(forced).toContain('raffic was restored to the previous version'); + expect(forced).toContain( + 'The previous version runs with its old secret values; deploy again to apply the new ones.', + ); + expect( + formatCloudflareActivationError({ ok: false, reason, rollback: 'succeeded' }), + ).not.toContain('secret'); + }, + ); + it('explains the account default-deny case when first-deploy readiness is blocked', () => { const message = formatCloudflareActivationError({ ok: false, diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index 54bf926bf..e2a222be0 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -217,6 +217,69 @@ it.each([false, true])( }, ); +it.each([undefined, false, true])( + 'sends bypassDeploymentChecks=%s through cf alongside the deployment body', + async (bypassDeploymentChecks) => { + const directory = temporaryDirectory('void-cf-deployment-checks-'); + const requests: Array<{ url: string | undefined; body: unknown }> = []; + const api = await listen( + createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + requests.push({ url: request.url, body: JSON.parse(body) }); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ success: true, result: { id: 'deployment-id' } })); + }), + ); + const preload = join(directory, 'capture-cf.mjs'); + const callsPath = join(directory, 'calls.json'); + writeFileSync( + preload, + `import child from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { writeFileSync } from 'node:fs'; +const spawnSync = child.spawnSync; +child.spawnSync = (binary, args, options) => { + writeFileSync(${JSON.stringify(callsPath)}, JSON.stringify(args.slice(1))); + return spawnSync(binary, args, options); +}; +syncBuiltinESMExports(); +`, + ); + const versionId = '11111111-1111-4111-8111-111111111111'; + const message = 'Void automatic rollback after readiness failed.'; + const output = await runOperation( + directory, + { + type: 'versions.deploy', + workerName: 'test-worker', + versions: [{ versionId, percentage: 100 }], + message, + bypassDeploymentChecks, + }, + tokenEnv(api), + preload, + ); + expect(output.status, output.stderr).toBe(0); + expect(decodeCloudflareOperationResult({ ...output, status: 0 }).status).toBe(0); + const args = JSON.parse(readFileSync(callsPath, 'utf8')) as Array; + expect(args.includes('--bypass-deployment-checks')).toBe(bypassDeploymentChecks === true); + expect(args).toContain('--body'); + expect(requests).toEqual([ + { + url: `/client/v4/accounts/test-account/workers/scripts/test-worker/deployments${bypassDeploymentChecks === true ? '?force=true' : ''}`, + body: { + strategy: 'percentage', + versions: [{ version_id: versionId, percentage: 100 }], + annotations: { 'workers/message': message }, + }, + }, + ]); + }, +); + it.each([200, 503])( 'reports HTTP %i without loading the build toolchain when domain cleanup returns non-JSON', async (status) => { diff --git a/packages/void/test/unit/cloudflare-operations.test.ts b/packages/void/test/unit/cloudflare-operations.test.ts index e4065e4fe..d35c81d4e 100644 --- a/packages/void/test/unit/cloudflare-operations.test.ts +++ b/packages/void/test/unit/cloudflare-operations.test.ts @@ -83,6 +83,7 @@ describe('Cloudflare operational parsing', () => { if (args.type === 'versions.deploy') { const config = JSON.parse(readFileSync(args.configPath!, 'utf8')); expect(config).toEqual({ account_id: 'account-1', name: 'my-worker' }); + expect(args).not.toHaveProperty('bypassDeploymentChecks'); active = 'original'; return ok(); } @@ -321,6 +322,7 @@ describe('Cloudflare version operations', () => { expect(activation[0].type === 'versions.deploy' ? activation[0].message : undefined).toBe( 'void:{"schema":2,"migrations":["0000_init.sql"],"triggerConfig":{"workers_dev":true,"preview_urls":true,"routes":[],"crons":["0 * * * *"],"queueConsumers":[],"workflows":[]}}', ); + expect(activation[0]).not.toHaveProperty('bypassDeploymentChecks'); expect(runner.mock.calls.some(([args]) => args.type === 'triggers.deploy')).toBe(true); }); @@ -370,12 +372,14 @@ describe('Cloudflare version operations', () => { ), ).rejects.toThrow(/traffic and triggers were restored to v2/); expect( - runner.mock.calls.some( - ([args]) => - args.type === 'versions.deploy' && - args.versions.some((version) => version.versionId === 'v2' && version.percentage === 100), - ), - ).toBe(true); + runner.mock.calls.filter(([args]) => args.type === 'versions.deploy').map(([args]) => args), + ).toEqual([ + expect.objectContaining({ versions: [{ versionId: 'v1', percentage: 100 }] }), + expect.objectContaining({ versions: [{ versionId: 'v2', percentage: 100 }] }), + ]); + for (const [args] of runner.mock.calls) { + expect(args).not.toHaveProperty('bypassDeploymentChecks'); + } expect(runner.mock.calls.filter(([args]) => args.type === 'triggers.deploy')).toHaveLength(2); expect(runner.mock.calls.filter(([args]) => args.type === 'triggers.clear-crons')).toHaveLength( 1, diff --git a/packages/void/test/unit/platform-install-cutover.test.ts b/packages/void/test/unit/platform-install-cutover.test.ts index 234f0a755..3c70d2657 100644 --- a/packages/void/test/unit/platform-install-cutover.test.ts +++ b/packages/void/test/unit/platform-install-cutover.test.ts @@ -674,6 +674,12 @@ it('still restores a failed cutover without credential updates', async () => { args.type === 'versions.deploy' && args.message?.startsWith('Void platform restoration'), ), ).toBe(true); + // Neither the activations nor the restorations skip Cloudflare's checks. + expect( + cloud.commands.filter( + ({ args }) => args.type === 'versions.deploy' && args.bypassDeploymentChecks !== undefined, + ), + ).toEqual([]); }); function stagedKeys(manifest: PlatformInstallationManifest, generation: number) { diff --git a/playground/astro/.gitignore b/playground/astro/.gitignore index dc1c8fb66..b580d5b7c 100644 --- a/playground/astro/.gitignore +++ b/playground/astro/.gitignore @@ -3,3 +3,4 @@ dist/ .void/ .wrangler/ node_modules/ +src/middleware.ts