From 9ec766b5cc492281035ea52e552ac1efd884ad1d Mon Sep 17 00:00:00 2001 From: David Gustys Date: Wed, 21 Oct 2026 17:15:04 +0300 Subject: [PATCH 1/4] fix(deploy): bypass deployment checks for automatic rollbacks Cloudflare rejects a deployment that rolls back to an older version after a secret changed (error 10220) unless it is forced. Void's automatic recovery after failed readiness, activation or trigger synchronization therefore failed whenever the failed version changed a secret, leaving the Worker at previous@100 plus failed@0 and blocking later deploys with deployment-changed. Add an optional bypassDeploymentChecks flag to versions.deploy, mapped to cf's --bypass-deployment-checks (force=true), and set it only on automatic recovery paths that have already verified ownership of current traffic: restorePreviousVersion, the trigger-synchronization rollback, and platform deployment restoration. Normal deploys and user-requested rollbacks are unchanged. --- packages/deploy-cloudflare/src/operations.ts | 1 + packages/deploy-cloudflare/src/version.ts | 7 +- .../test/unit/operations.test.ts | 85 ++++++++++++++++++- packages/void/src/cli/cf-version-runner.ts | 1 + .../src/cli/cloudflare-operation-process.ts | 1 + .../void/src/cli/cloudflare-operations.ts | 1 + .../src/cli/platform-installer-workers.ts | 1 + .../void/test/unit/cf-version-runner.test.ts | 38 +++++++++ .../unit/cloudflare-operation-process.test.ts | 63 ++++++++++++++ .../test/unit/cloudflare-operations.test.ts | 19 +++-- .../void/test/unit/platform-installer.test.ts | 1 + 11 files changed, 208 insertions(+), 10 deletions(-) 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..3e37a4710 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -2534,7 +2534,7 @@ async function deployVersionPercentages( options: Pick< Parameters[0], 'runWrangler' | 'workerName' | 'preserveTriggers' | 'configPath' - >, + > & { bypassDeploymentChecks?: boolean }, percentages: Array, message: string, ): Promise { @@ -2557,6 +2557,7 @@ async function deployVersionPercentages( configPath: options.configPath, workerName: options.workerName, message, + ...(options.bypassDeploymentChecks ? { bypassDeploymentChecks: true } : {}), }, { capture: false }, ); @@ -2569,6 +2570,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 +2584,7 @@ export async function deployCloudflareVersionTraffic(options: { configPath: path, workerName: options.workerName, message: options.message, + ...(options.bypassDeploymentChecks ? { bypassDeploymentChecks: true } : {}), }, { capture: false }, ); @@ -2615,7 +2618,7 @@ async function restorePreviousVersion( return 'unsafe'; } const result = await deployVersionPercentages( - options, + { ...options, bypassDeploymentChecks: true }, [`${previousVersionId}@100`], `Void automatic rollback after ${cause}.`, ); diff --git a/packages/deploy-cloudflare/test/unit/operations.test.ts b/packages/deploy-cloudflare/test/unit/operations.test.ts index 504277194..00e99526e 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'; @@ -6,9 +9,10 @@ import { activateCloudflareVersion, encodeCloudflareVersionMessage } from '../.. const current = '11111111-1111-4111-8111-111111111111'; const next = '22222222-2222-4222-8222-222222222222'; -function setup(data: unknown) { +function setup(data: unknown, failure?: 'activation' | 'triggers') { 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 +26,45 @@ 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 ( + 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,8 +95,57 @@ 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', + ); }); +it.each([ + { 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)( + 'bypasses deployment checks only for automatic $failure recovery with preserveTriggers=$preserveTriggers', + async ({ failure, preserveTriggers, reason }) => { + 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, + ); + 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 }), + }); + expect(result).toMatchObject({ ok: false, reason, rollback: 'succeeded' }); + const deployments = calls.filter((operation) => operation.type === 'versions.deploy'); + expect(deployments.at(-1)).toMatchObject({ + versions: [{ versionId: current, percentage: 100 }], + bypassDeploymentChecks: true, + }); + for (const operation of deployments.slice(0, -1)) { + expect(operation).not.toHaveProperty('bypassDeploymentChecks'); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + it('rejects missing structured upload identity before activation', async () => { const { calls, runWrangler } = setup({ unrelated: 'value' }); const result = await activateCloudflareVersion({ 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..b3102649d 100644 --- a/packages/void/src/cli/cloudflare-operations.ts +++ b/packages/void/src/cli/cloudflare-operations.ts @@ -537,6 +537,7 @@ export async function activateCloudflareExistingVersion( configPath: context.configPath, workerName: context.workerName, message: `Void automatic rollback after trigger synchronization failed.`, + bypassDeploymentChecks: true, }, { capture: false }, ); diff --git a/packages/void/src/cli/platform-installer-workers.ts b/packages/void/src/cli/platform-installer-workers.ts index 60cab9551..14eb525bd 100644 --- a/packages/void/src/cli/platform-installer-workers.ts +++ b/packages/void/src/cli/platform-installer-workers.ts @@ -1258,6 +1258,7 @@ export async function restorePlatformWorkerDeployment(input: { percentage: version.percentage, })), message: `Void platform restoration ${input.restoration.id}`, + bypassDeploymentChecks: true, }, input.accountId, input.directory, 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-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..37308d812 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,17 @@ 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 }], + bypassDeploymentChecks: true, + }), + ]); + expect( + runner.mock.calls.find(([args]) => args.type === 'versions.deploy')![0], + ).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-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 4bc563a20..839b9d2c2 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -1869,6 +1869,7 @@ describe('platform installer', () => { { versionId: 'version-b', percentage: 75 }, ], message: 'Void platform restoration deployment-intervening', + bypassDeploymentChecks: true, }); expect(commands.some((args) => args.type.endsWith('.delete'))).toBe(false); }); From 2f061c85a13566aa500e7aa6719167c17e68d64e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 26 Oct 2026 16:19:23 +0800 Subject: [PATCH 2/4] fix(deploy): force automatic rollbacks only after Cloudflare refuses an old secret version Try the automatic app rollback without force first, as Wrangler's rollback does. Only when Cloudflare answers 10220 (a secret changed since the previous version was active), read the owner again and retry with force, so a secret changed outside Void is not overwritten and any other Cloudflare check still applies. Keep the trigger-sync rollback and the platform installer unforced. The first returns to the version that served traffic seconds before, so a 10220 there comes from someone else. The second would undo #569: a credential update rolls forward instead of restoring old secrets. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/deploy-cloudflare/src/version.ts | 40 +++-- .../test/unit/operations.test.ts | 145 +++++++++++++----- .../void/src/cli/cloudflare-operations.ts | 3 +- .../src/cli/platform-installer-workers.ts | 3 +- .../test/unit/cloudflare-operations.test.ts | 11 +- .../unit/platform-install-cutover.test.ts | 6 + .../void/test/unit/platform-installer.test.ts | 1 - 7 files changed, 149 insertions(+), 60 deletions(-) diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 3e37a4710..80c7ad91e 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<{ @@ -2608,20 +2609,35 @@ async function restorePreviousVersion( if (!options.verifyUploader()) { return 'unsafe'; } - const owner = await readCurrentProductionVersion(options); - if ( - owner.kind !== 'ok' || - !expectedOwnedTraffic.some((expected) => - productionVersionPercentagesEqual(owner.versions, expected), - ) - ) { + const ownsExpectedTraffic = async () => { + const owner = await readCurrentProductionVersion(options); + return ( + owner.kind === 'ok' && + expectedOwnedTraffic.some((expected) => + productionVersionPercentagesEqual(owner.versions, expected), + ) + ); + }; + if (!(await ownsExpectedTraffic())) { return 'unsafe'; } - const result = await deployVersionPercentages( - { ...options, bypassDeploymentChecks: true }, - [`${previousVersionId}@100`], - `Void automatic rollback after ${cause}.`, - ); + const restore = (bypassDeploymentChecks: boolean) => + deployVersionPercentages( + { ...options, bypassDeploymentChecks }, + [`${previousVersionId}@100`], + `Void automatic rollback after ${cause}.`, + ); + let 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 secret changed by someone else is not overwritten. + if (result.errorCode === CLOUDFLARE_SECRET_CHANGED_ERROR) { + if (!(await ownsExpectedTraffic())) { + return 'unsafe'; + } + result = await restore(true); + } return isCleanCommandExit(result) ? 'succeeded' : 'failed'; } diff --git a/packages/deploy-cloudflare/test/unit/operations.test.ts b/packages/deploy-cloudflare/test/unit/operations.test.ts index 00e99526e..51c78008f 100644 --- a/packages/deploy-cloudflare/test/unit/operations.test.ts +++ b/packages/deploy-cloudflare/test/unit/operations.test.ts @@ -8,8 +8,14 @@ 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, failure?: 'activation' | 'triggers') { +function setup( + data: unknown, + failure?: 'activation' | 'triggers', + // How Cloudflare answers a deploy of the previous version without force. + rollback?: 'secret-changed' | 'secret-changed-elsewhere' | 'error', +) { const calls: Array = []; let uploaded = false; let triggerFailed = false; @@ -47,6 +53,20 @@ function setup(data: unknown, failure?: 'activation' | 'triggers') { 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 === 'secret-changed-elsewhere') { + versions = [{ version_id: other, percentage: 100 }]; + } + return { ...ok(), status: 1, errorCode: 10220 }; + } if ( failure === 'activation' && operation.versions.some( @@ -100,52 +120,101 @@ it('activates structured upload data independently of provider presentation', as ); }); -it.each([ +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)( - 'bypasses deployment checks only for automatic $failure recovery with preserveTriggers=$preserveTriggers', - async ({ failure, preserveTriggers, reason }) => { - 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, - ); - 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 }), - }); - expect(result).toMatchObject({ ok: false, reason, rollback: 'succeeded' }); - const deployments = calls.filter((operation) => operation.type === 'versions.deploy'); - expect(deployments.at(-1)).toMatchObject({ - versions: [{ versionId: current, percentage: 100 }], - bypassDeploymentChecks: true, - }); - for (const operation of deployments.slice(0, -1)) { - expect(operation).not.toHaveProperty('bypassDeploymentChecks'); - } - } finally { - rmSync(directory, { recursive: true, force: true }); +] 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(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' }); + 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 over a secret changed outside Void', async () => { + const { result, rollbacks } = await recover(recoveries[0], 'secret-changed-elsewhere'); + expect(result).toMatchObject({ ok: false, reason: 'readiness-failed', rollback: 'unsafe' }); + 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(rollbacks).toHaveLength(1); + expect(rollbacks[0]).not.toHaveProperty('bypassDeploymentChecks'); +}); + it('rejects missing structured upload identity before activation', async () => { const { calls, runWrangler } = setup({ unrelated: 'value' }); const result = await activateCloudflareVersion({ diff --git a/packages/void/src/cli/cloudflare-operations.ts b/packages/void/src/cli/cloudflare-operations.ts index b3102649d..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, @@ -537,7 +539,6 @@ export async function activateCloudflareExistingVersion( configPath: context.configPath, workerName: context.workerName, message: `Void automatic rollback after trigger synchronization failed.`, - bypassDeploymentChecks: true, }, { capture: false }, ); diff --git a/packages/void/src/cli/platform-installer-workers.ts b/packages/void/src/cli/platform-installer-workers.ts index 14eb525bd..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', @@ -1258,7 +1260,6 @@ export async function restorePlatformWorkerDeployment(input: { percentage: version.percentage, })), message: `Void platform restoration ${input.restoration.id}`, - bypassDeploymentChecks: true, }, input.accountId, input.directory, diff --git a/packages/void/test/unit/cloudflare-operations.test.ts b/packages/void/test/unit/cloudflare-operations.test.ts index 37308d812..d35c81d4e 100644 --- a/packages/void/test/unit/cloudflare-operations.test.ts +++ b/packages/void/test/unit/cloudflare-operations.test.ts @@ -375,14 +375,11 @@ describe('Cloudflare version operations', () => { 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 }], - bypassDeploymentChecks: true, - }), + expect.objectContaining({ versions: [{ versionId: 'v2', percentage: 100 }] }), ]); - expect( - runner.mock.calls.find(([args]) => args.type === 'versions.deploy')![0], - ).not.toHaveProperty('bypassDeploymentChecks'); + 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/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 839b9d2c2..4bc563a20 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -1869,7 +1869,6 @@ describe('platform installer', () => { { versionId: 'version-b', percentage: 75 }, ], message: 'Void platform restoration deployment-intervening', - bypassDeploymentChecks: true, }); expect(commands.some((args) => args.type.endsWith('.delete'))).toBe(false); }); From 1a210549d701798ba1bd03752a1fc6d435bf8dbc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 26 Oct 2026 16:27:58 +0800 Subject: [PATCH 3/4] test(deploy): name the outside-deploy rollback case for what it checks Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/deploy-cloudflare/src/version.ts | 2 +- packages/deploy-cloudflare/test/unit/operations.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 80c7ad91e..5fddaa453 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -2631,7 +2631,7 @@ async function restorePreviousVersion( // 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 secret changed by someone else is not overwritten. + // owns traffic, so a deployment made by someone else is not overwritten. if (result.errorCode === CLOUDFLARE_SECRET_CHANGED_ERROR) { if (!(await ownsExpectedTraffic())) { return 'unsafe'; diff --git a/packages/deploy-cloudflare/test/unit/operations.test.ts b/packages/deploy-cloudflare/test/unit/operations.test.ts index 51c78008f..90dc11f33 100644 --- a/packages/deploy-cloudflare/test/unit/operations.test.ts +++ b/packages/deploy-cloudflare/test/unit/operations.test.ts @@ -14,7 +14,7 @@ function setup( data: unknown, failure?: 'activation' | 'triggers', // How Cloudflare answers a deploy of the previous version without force. - rollback?: 'secret-changed' | 'secret-changed-elsewhere' | 'error', + rollback?: 'secret-changed' | 'deployed-elsewhere' | 'error', ) { const calls: Array = []; let uploaded = false; @@ -62,7 +62,7 @@ function setup( if (rollback === 'error') { return { ...ok(), status: 1, errorCode: 10000 }; } - if (rollback === 'secret-changed-elsewhere') { + if (rollback === 'deployed-elsewhere') { versions = [{ version_id: other, percentage: 100 }]; } return { ...ok(), status: 1, errorCode: 10220 }; @@ -201,8 +201,8 @@ it.each(recoveries)( }, ); -it('does not force a restore over a secret changed outside Void', async () => { - const { result, rollbacks } = await recover(recoveries[0], 'secret-changed-elsewhere'); +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(rollbacks).toHaveLength(1); expect(rollbacks[0]).not.toHaveProperty('bypassDeploymentChecks'); From 1df0efe46de87ab355db14d00362851f14ba0583 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 26 Oct 2026 16:52:43 +0800 Subject: [PATCH 4/4] fix(deploy): say when a forced rollback put the old secret values back When Cloudflare refused the automatic restore over a secret change and Void forced it, the activation result now carries `restoredOlderSecrets: true`, and the deploy error tells the user that the previous version runs with its old secret values and that deploying again applies the new ones. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/deploy-cloudflare/src/version.ts | 62 ++++++++++++------- .../test/unit/operations.test.ts | 10 ++- .../void/src/cli/deploy-cloudflare-helpers.ts | 9 +++ .../void/test/unit/cloudflare-deploy.test.ts | 19 ++++++ playground/astro/.gitignore | 1 + 5 files changed, 77 insertions(+), 24 deletions(-) diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 5fddaa453..d951b63d6 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -108,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. */ @@ -1385,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()) { @@ -1398,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 }), }; } @@ -1461,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 }), }; } @@ -1578,7 +1584,7 @@ async function activateCloudflareVersionWithSecretsFile( triggerRepair: triggerRepairOutcome(repair), }; } - const rollback = await restorePreviousVersionAndTriggers( + const restore = await restorePreviousVersionAndTriggers( options, previous.versionId, 'trigger activation failed', @@ -1592,7 +1598,7 @@ async function activateCloudflareVersionWithSecretsFile( status: triggers.status, versionId, previousVersionId: previous.versionId, - rollback, + ...restore, }; } @@ -1642,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 @@ -1660,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, @@ -1683,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( @@ -2594,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], @@ -2602,12 +2616,12 @@ 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'; + return { rollback: 'unsafe' }; } const ownsExpectedTraffic = async () => { const owner = await readCurrentProductionVersion(options); @@ -2619,7 +2633,7 @@ async function restorePreviousVersion( ); }; if (!(await ownsExpectedTraffic())) { - return 'unsafe'; + return { rollback: 'unsafe' }; } const restore = (bypassDeploymentChecks: boolean) => deployVersionPercentages( @@ -2627,18 +2641,20 @@ async function restorePreviousVersion( [`${previousVersionId}@100`], `Void automatic rollback after ${cause}.`, ); - let result = await restore(false); + 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 'unsafe'; + return { rollback: 'unsafe' }; } - result = await restore(true); + return isCleanCommandExit(await restore(true)) + ? { rollback: 'succeeded', restoredOlderSecrets: true } + : { rollback: 'failed' }; } - return isCleanCommandExit(result) ? 'succeeded' : '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 90dc11f33..3592d0eb3 100644 --- a/packages/deploy-cloudflare/test/unit/operations.test.ts +++ b/packages/deploy-cloudflare/test/unit/operations.test.ts @@ -172,6 +172,7 @@ it.each(recoveries)( 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'); @@ -183,7 +184,12 @@ 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' }); + expect(result).toMatchObject({ + ok: false, + reason: recovery.reason, + rollback: 'succeeded', + restoredOlderSecrets: true, + }); expect(rollbacks).toEqual([ expect.not.objectContaining({ bypassDeploymentChecks: true }), expect.objectContaining({ bypassDeploymentChecks: true }), @@ -204,6 +210,7 @@ it.each(recoveries)( 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'); }); @@ -211,6 +218,7 @@ it('does not force a restore after someone else deploys', async () => { 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'); }); 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/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/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