diff --git a/.changeset/force-deploy-conflicts.md b/.changeset/force-deploy-conflicts.md new file mode 100644 index 0000000000..4dae51e3d8 --- /dev/null +++ b/.changeset/force-deploy-conflicts.md @@ -0,0 +1,7 @@ +--- +"cf": minor +--- + +Add `--force` to `cf deploy` for CI conflict overrides + +Use `cf deploy --force` to upload a Worker when its last deployment came from the script API or its remote configuration conflicts with the local Build Output. Deploys remain strict by default. An aborted upload now exits without a success message, and the conflict error points to the supported `--force` option. diff --git a/packages/cli/src/__tests__/commands/deploy/helpers.ts b/packages/cli/src/__tests__/commands/deploy/helpers.ts index a7c220bc01..ee0b468f5d 100644 --- a/packages/cli/src/__tests__/commands/deploy/helpers.ts +++ b/packages/cli/src/__tests__/commands/deploy/helpers.ts @@ -309,6 +309,7 @@ export function mockWorkerUpload( captured: UploadCapture = {}, opts?: { dispatchNamespace?: string; + deploymentId?: string | null; onRequest?: (request: Request) => void; } ): UploadCapture { @@ -342,7 +343,10 @@ export function mockWorkerUpload( id: "test-script-id", etag: "test-etag", pipeline_hash: "test-pipeline-hash", - deployment_id: "test-version-id", + deployment_id: + opts?.deploymentId === undefined + ? "test-version-id" + : opts.deploymentId, tag: "test-tag", default_environment: { script: { diff --git a/packages/cli/src/__tests__/commands/deploy/index.test.ts b/packages/cli/src/__tests__/commands/deploy/index.test.ts index 83179415c7..a4184bce70 100644 --- a/packages/cli/src/__tests__/commands/deploy/index.test.ts +++ b/packages/cli/src/__tests__/commands/deploy/index.test.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import * as clack from "@clack/prompts"; import { mockConsoleMethods, runInTempDir, @@ -160,6 +161,23 @@ describe("cf deploy", () => { expect(std.out).toContain("test-worker"); }); + it("accepts a successful upload without a version ID", async () => { + const upload = mockWorkerUpload({}, { deploymentId: null }); + await seed({ + ".cloudflare/output/v0/config.json": buildOutputRootConfig(), + ".cloudflare/output/v0/workers/default/worker.config.json": + workerConfig(), + ".cloudflare/output/v0/workers/default/bundle/index.js": + "export default { fetch() { return new Response('ok'); } }", + }); + + const { exitCode } = await runCf(["deploy", "--prebuilt"]); + + expect(exitCode).toBe(0); + expect(upload.metadata?.main_module).toBe("index.js"); + expect(std.out).toContain("Deployed"); + }); + it("deploys a standard Container from Build Output", async () => { vi.stubEnv("WRANGLER_DOCKER_BIN", await seedDockerMock()); const upload = mockWorkerUpload(); @@ -1142,6 +1160,33 @@ describe("cf deploy", () => { }); describe("re-deploy existing worker", () => { + it("uploads an API-managed Worker in CI with --force", async () => { + const warn = vi.spyOn(clack.log, "warn").mockImplementation(() => {}); + try { + const upload = mockWorkerUpload(); + mockExistingWorker({ lastDeployedFrom: "api" }); + await seed({ + ".cloudflare/output/v0/config.json": buildOutputRootConfig(), + ".cloudflare/output/v0/workers/default/worker.config.json": + workerConfig(), + ".cloudflare/output/v0/workers/default/bundle/index.js": + "export default { fetch() { return new Response('ok'); } }", + }); + + const { exitCode } = await runCf(["deploy", "--prebuilt", "--force"], { + CI: "true", + }); + + expect(exitCode).toBe(0); + expect(upload.metadata?.main_module).toBe("index.js"); + expect(warn).toHaveBeenCalledWith( + "Using --force may overwrite conflicting remote Worker changes." + ); + } finally { + warn.mockRestore(); + } + }); + it("aborts in strict mode when last deployed from api", async () => { const upload = mockWorkerUpload(); vi.stubEnv("WRANGLER_DOCKER_BIN", await seedDockerMock()); @@ -1188,11 +1233,13 @@ describe("cf deploy", () => { }), }); - await runCf(["deploy"]); + const { exitCode } = await runCf(["deploy"]); // strict mode + non-interactive rejects the overwrite; no upload happens + expect(exitCode).toBe(1); expect(upload.metadata).toBeUndefined(); expect(std.err).toContain("Aborting"); + expect(std.out + std.err).not.toContain("Deploy complete"); expect(readDockerCommands()).toEqual([]); }); }); @@ -1235,21 +1282,27 @@ describe("cf deploy", () => { }); it("does not upload the worker", async () => { - const upload = mockWorkerUpload(); - await seed({ - ".cloudflare/output/v0/config.json": buildOutputRootConfig(), - ".cloudflare/output/v0/workers/default/worker.config.json": - workerConfig(), - ".cloudflare/output/v0/workers/default/bundle/index.js": - "export default { fetch() { return new Response('ok'); } }", - }); + const warn = vi.spyOn(clack.log, "warn").mockImplementation(() => {}); + try { + const upload = mockWorkerUpload(); + await seed({ + ".cloudflare/output/v0/config.json": buildOutputRootConfig(), + ".cloudflare/output/v0/workers/default/worker.config.json": + workerConfig(), + ".cloudflare/output/v0/workers/default/bundle/index.js": + "export default { fetch() { return new Response('ok'); } }", + }); - const { exitCode } = await runCf(["deploy", "--dry-run"]); + const { exitCode } = await runCf(["deploy", "--dry-run", "--force"]); - expect(exitCode).toBe(0); - expect(upload.metadata).toBeUndefined(); - expect(std.out).toContain("--dry-run: exiting now."); - expect(std.out).not.toContain("Deployed"); + expect(exitCode).toBe(0); + expect(upload.metadata).toBeUndefined(); + expect(std.out).toContain("--dry-run: exiting now."); + expect(std.out).not.toContain("Deployed"); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } }); it("makes no API requests and needs no credentials", async () => { diff --git a/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts b/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts index 6f46788a43..b1f48bd755 100644 --- a/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts +++ b/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts @@ -117,6 +117,18 @@ describe("hand-written command metadata", () => { }); }); + it("publishes the deploy force flag and its short alias", () => { + expect(commands.get("cf deploy")?.options).toContainEqual({ + name: "force", + alias: "f", + type: "boolean", + required: false, + description: + "Allow upload in CI despite conflicting remote Worker changes", + default: false, + }); + }); + it("publishes Preview prebuilt metadata", () => { expect(commands.get("cf previews deploy")?.options).toContainEqual({ name: "prebuilt", diff --git a/packages/cli/src/__tests__/lib/deploy-input.test.ts b/packages/cli/src/__tests__/lib/deploy-input.test.ts index 58999d8a17..624f27766f 100644 --- a/packages/cli/src/__tests__/lib/deploy-input.test.ts +++ b/packages/cli/src/__tests__/lib/deploy-input.test.ts @@ -299,6 +299,7 @@ describe("transforms Build Output Specification into deploy input", () => { "dry-run": false, "secrets-file": undefined, prebuilt: false, + force: false, quiet: false, local: false, "persist-to": undefined, diff --git a/packages/cli/src/commands/_generated/_meta/commands.json b/packages/cli/src/commands/_generated/_meta/commands.json index 55a63965cc..7a709e3f0a 100644 --- a/packages/cli/src/commands/_generated/_meta/commands.json +++ b/packages/cli/src/commands/_generated/_meta/commands.json @@ -43177,6 +43177,14 @@ "description": "Deploy existing Build Output Specification files without building", "default": false }, + { + "name": "force", + "type": "boolean", + "required": false, + "description": "Allow upload in CI despite conflicting remote Worker changes", + "default": false, + "alias": "f" + }, { "name": "tag", "type": "string", diff --git a/packages/cli/src/commands/_generated/_meta/hand-written-commands.json b/packages/cli/src/commands/_generated/_meta/hand-written-commands.json index 45d89baf50..3a32850159 100644 --- a/packages/cli/src/commands/_generated/_meta/hand-written-commands.json +++ b/packages/cli/src/commands/_generated/_meta/hand-written-commands.json @@ -876,6 +876,14 @@ "description": "Deploy existing Build Output Specification files without building", "default": false }, + { + "name": "force", + "type": "boolean", + "required": false, + "description": "Allow upload in CI despite conflicting remote Worker changes", + "default": false, + "alias": "f" + }, { "name": "tag", "type": "string", diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts index 7b16db14a0..52399f4429 100644 --- a/packages/cli/src/commands/deploy/index.ts +++ b/packages/cli/src/commands/deploy/index.ts @@ -1,3 +1,4 @@ +import * as clack from "@clack/prompts"; import { runUpload, sharedUploadBuilder } from "./shared.js"; import type { CommonYargsOptions, InferArgs } from "../../lib/cli-types.js"; import type { Argv, CommandModule } from "yargs"; @@ -10,6 +11,13 @@ function builder(yargs: Argv) { "Automatically provision resources for bindings that need them", default: true, }) + .option("force", { + type: "boolean", + alias: "f", + description: + "Allow upload in CI despite conflicting remote Worker changes", + default: false, + }) .option("dispatch-namespace", { type: "string", description: @@ -30,6 +38,11 @@ const deployCommand: CommandModule = { describe: "Deploy a worker to Cloudflare", builder, handler: async (argv) => { + if (argv.force && !argv["dry-run"]) { + clack.log.warn( + "Using --force may overwrite conflicting remote Worker changes." + ); + } await runUpload(argv, { command: "Deploy" }); }, }; diff --git a/packages/cli/src/commands/deploy/meta.json b/packages/cli/src/commands/deploy/meta.json index 1764501813..8f370ee52a 100644 --- a/packages/cli/src/commands/deploy/meta.json +++ b/packages/cli/src/commands/deploy/meta.json @@ -15,6 +15,14 @@ "description": "Deploy existing Build Output Specification files without building", "default": false }, + { + "name": "force", + "type": "boolean", + "required": false, + "description": "Allow upload in CI despite conflicting remote Worker changes", + "default": false, + "alias": "f" + }, { "name": "tag", "type": "string", diff --git a/packages/cli/src/commands/deploy/shared.ts b/packages/cli/src/commands/deploy/shared.ts index 05271cb438..4afc059e6e 100644 --- a/packages/cli/src/commands/deploy/shared.ts +++ b/packages/cli/src/commands/deploy/shared.ts @@ -22,6 +22,7 @@ import { selectBuildOutputWorker, validateBuildOutputMode, } from "../../lib/build-output.js"; +import { CliExit } from "../../lib/cli-exit.js"; import { getAccountId } from "../../lib/context.js"; import { createDeployContext } from "../../lib/deploy-context.js"; import { @@ -80,6 +81,7 @@ export type SharedUploadArgs = InferArgs; type UploadCommand = { command: "Deploy" } | { command: "Version upload" }; type UploadArgs = SharedUploadArgs & { provision?: boolean; + force?: boolean; "preview-alias"?: string; "containers-rollout"?: "immediate" | "gradual" | "none"; }; @@ -205,6 +207,10 @@ async function uploadBuildOutput(argv: UploadArgs, ctx: UploadCommand) { analyseBundle: undefined, })); } + // A successful upload can also return no version ID; strict-mode aborts set exitCode. + if (versionId === null && process.exitCode === 1 && !argv["dry-run"]) { + throw new CliExit(1); + } if ( versionId !== null && diff --git a/packages/cli/src/lib/deploy-input.ts b/packages/cli/src/lib/deploy-input.ts index 1a8867cf02..82c9eb1205 100644 --- a/packages/cli/src/lib/deploy-input.ts +++ b/packages/cli/src/lib/deploy-input.ts @@ -26,6 +26,7 @@ import type { type DeployUploadArgs = SharedUploadArgs & { provision?: boolean; + force?: boolean; "dispatch-namespace"?: string; "containers-rollout"?: "immediate" | "gradual" | "none"; }; @@ -116,7 +117,7 @@ export function createDeployProps( routes: resolveRoutes(config), logpush: config.logpush, dispatchNamespace: argv["dispatch-namespace"], - strict: true, + strict: !argv.force, legacyAssetPaths: undefined, oldAssetTtl: undefined, };