Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/force-deploy-conflicts.md
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current @cloudflare/deploy-helpers strict CI path still emits its hard-coded Wrangler advice to remove --strict; cf does not expose that flag. This changeset therefore promises behavior that this PR does not ship. Remove the claim until the upstream dependency and strictModeAbortMessage wiring are included.

Suggested change
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.
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.

6 changes: 5 additions & 1 deletion packages/cli/src/__tests__/commands/deploy/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export function mockWorkerUpload(
captured: UploadCapture = {},
opts?: {
dispatchNamespace?: string;
deploymentId?: string | null;
onRequest?: (request: Request) => void;
}
): UploadCapture {
Expand Down Expand Up @@ -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: {
Expand Down
81 changes: 67 additions & 14 deletions packages/cli/src/__tests__/commands/deploy/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import * as clack from "@clack/prompts";
import {
mockConsoleMethods,
runInTempDir,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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([]);
});
});
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/lib/deploy-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/_generated/_meta/commands.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/cli/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +11,13 @@ function builder(yargs: Argv<CommonYargsOptions>) {
"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:
Expand All @@ -30,6 +38,11 @@ const deployCommand: CommandModule<CommonYargsOptions, DeployArgs> = {
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" });
},
};
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/deploy/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/deploy/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -80,6 +81,7 @@ export type SharedUploadArgs = InferArgs<typeof sharedUploadBuilder>;
type UploadCommand = { command: "Deploy" } | { command: "Version upload" };
type UploadArgs = SharedUploadArgs & {
provision?: boolean;
force?: boolean;
"preview-alias"?: string;
"containers-rollout"?: "immediate" | "gradual" | "none";
};
Expand Down Expand Up @@ -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 &&
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/deploy-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {

type DeployUploadArgs = SharedUploadArgs & {
provision?: boolean;
force?: boolean;
"dispatch-namespace"?: string;
"containers-rollout"?: "immediate" | "gradual" | "none";
};
Expand Down Expand Up @@ -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,
};
Expand Down
Loading