diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs
index 2acf9e735ce..48e828c81a3 100644
--- a/actions/setup/js/create_pull_request.cjs
+++ b/actions/setup/js/create_pull_request.cjs
@@ -273,6 +273,48 @@ function generatePatchPreview(patchContent) {
return `\n\n${summary}
\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n `;
}
+/**
+ * Check whether the remote branch already exists and, if so, either fail loudly
+ * (when preserve-branch-name is enabled) or rename the local branch by appending
+ * a random hex suffix.
+ * @param {string} branchName - Current local branch name.
+ * @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled.
+ * @returns {Promise} The (possibly renamed) branch name to use going forward.
+ * @throws {Error} If the remote branch exists and preserve-branch-name is true.
+ */
+async function handleRemoteBranchCollision(branchName, preserveBranchName) {
+ let remoteBranchExists = false;
+ try {
+ const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
+ if (stdout.trim()) {
+ remoteBranchExists = true;
+ }
+ } catch (checkError) {
+ core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
+ }
+
+ if (!remoteBranchExists) {
+ return branchName;
+ }
+
+ if (preserveBranchName) {
+ throw new Error(
+ `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` +
+ `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` +
+ `branch name, or disable preserve-branch-name to allow a random suffix to be appended.`
+ );
+ }
+
+ core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
+ const extraHex = crypto.randomBytes(4).toString("hex");
+ const oldBranch = branchName;
+ const renamedBranch = `${branchName}-${extraHex}`;
+ // Rename local branch
+ await exec.exec(`git branch -m ${oldBranch} ${renamedBranch}`);
+ core.info(`Renamed branch to ${renamedBranch}`);
+ return renamedBranch;
+}
+
/**
* Main handler factory for create_pull_request
* Returns a message handler function that processes individual create_pull_request messages
@@ -971,26 +1013,7 @@ async function main(config = {}) {
// Push the commits from the bundle to the remote branch
try {
- // Check if remote branch already exists (optional precheck)
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
-
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- // Rename local branch
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
+ branchName = await handleRemoteBranchCollision(branchName, preserveBranchName);
await pushSignedCommits({
githubClient,
@@ -1199,26 +1222,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
// Push the applied commits to the branch (with fallback to issue creation on failure)
try {
- // Check if remote branch already exists (optional precheck)
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
-
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- // Rename local branch
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
+ branchName = await handleRemoteBranchCollision(branchName, preserveBranchName);
await pushSignedCommits({
githubClient,
@@ -1362,26 +1366,7 @@ ${patchPreview}`;
await exec.exec(`git commit --allow-empty -m "Initialize"`);
core.info("Created empty commit");
- // Check if remote branch already exists (optional precheck)
- let remoteBranchExists = false;
- try {
- const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
- if (stdout.trim()) {
- remoteBranchExists = true;
- }
- } catch (checkError) {
- core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
- }
-
- if (remoteBranchExists) {
- core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
- const extraHex = crypto.randomBytes(4).toString("hex");
- const oldBranch = branchName;
- branchName = `${branchName}-${extraHex}`;
- // Rename local branch
- await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
- core.info(`Renamed branch to ${branchName}`);
- }
+ branchName = await handleRemoteBranchCollision(branchName, preserveBranchName);
await pushSignedCommits({
githubClient,
@@ -1408,6 +1393,7 @@ ${patchPreview}`;
return {
success: false,
error,
+ error_type: "push_failed",
};
}
} else {
diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs
index 97868b46a57..ad1da7f9b81 100644
--- a/actions/setup/js/create_pull_request.test.cjs
+++ b/actions/setup/js/create_pull_request.test.cjs
@@ -1574,6 +1574,63 @@ describe("create_pull_request - patch apply fallback to original base commit", (
expect(result.error).toBe("Failed to apply patch");
expect(global.core.warning).toHaveBeenCalledWith("No base_commit recorded in safe output entry - fallback not possible");
});
+
+ it("should fail loudly when preserve-branch-name is true and remote branch already exists", async () => {
+ // Simulate the remote branch existing (ls-remote returns content)
+ global.exec = {
+ exec: vi.fn().mockResolvedValue(0),
+ getExecOutput: vi.fn().mockImplementation((cmd, args) => {
+ const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`;
+ if (cmdStr.includes("ls-remote --heads origin")) {
+ return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" });
+ }
+ return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
+ }),
+ };
+
+ const { main } = require("./create_pull_request.cjs");
+ const handler = await main({ preserve_branch_name: true, fallback_as_issue: false });
+
+ const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "preserve-me", base_commit: MOCK_BASE_COMMIT_SHA }, {});
+
+ expect(result.success).toBe(false);
+ expect(result.error_type).toBe("push_failed");
+ expect(result.error).toContain('Remote branch "preserve-me" already exists');
+ expect(result.error).toContain("preserve-branch-name is enabled");
+ // Critical: should NOT have warned about appending random suffix (silent bypass)
+ const warningCalls = global.core.warning.mock.calls.map(call => String(call[0]));
+ expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false);
+ });
+
+ it("should append random suffix when preserve-branch-name is false and remote branch already exists", async () => {
+ let renameCalled = false;
+ global.exec = {
+ exec: vi.fn().mockImplementation((cmd, args) => {
+ const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`;
+ if (cmdStr.includes("git branch -m")) {
+ renameCalled = true;
+ }
+ return Promise.resolve(0);
+ }),
+ getExecOutput: vi.fn().mockImplementation((cmd, args) => {
+ const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`;
+ if (cmdStr.includes("ls-remote --heads origin")) {
+ return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/some-branch\n", stderr: "" });
+ }
+ return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
+ }),
+ };
+
+ const { main } = require("./create_pull_request.cjs");
+ const handler = await main({});
+
+ const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "some-branch", base_commit: MOCK_BASE_COMMIT_SHA }, {});
+
+ expect(result.success).toBe(true);
+ expect(renameCalled).toBe(true);
+ const warningCalls = global.core.warning.mock.calls.map(call => String(call[0]));
+ expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(true);
+ });
});
describe("create_pull_request - copilot assignee on fallback issues", () => {
diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md
index 4583974d3d7..af777a6be7c 100644
--- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md
+++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md
@@ -83,6 +83,8 @@ The `excluded-files` field accepts a list of glob patterns. Each matching file i
The `preserve-branch-name` field, when set to `true`, omits the random hex salt suffix that is normally appended to the agent-specified branch name. This is useful when the target repository enforces branch naming conventions such as Jira keys in uppercase (e.g., `bugfix/BR-329-red` instead of `bugfix/br-329-red-cde2a954`). Invalid characters are always replaced for security, and casing is always preserved regardless of this setting. Defaults to `false`.
+When `preserve-branch-name: true` and the agent-supplied branch name already exists on the remote, the workflow fails with an explicit error rather than silently appending a random suffix. To resolve, delete the existing remote branch, choose a different branch name, or disable `preserve-branch-name` to allow collision-avoidance via a random suffix.
+
The `draft` field is a **configuration policy**, not a default. Whatever value is set in the workflow frontmatter is always used — the agent cannot override it at runtime.
By default, when a workflow is triggered from an issue, the `create-pull-request` handler automatically appends `- Fixes #N` to the PR description if no closing keyword is already present. This causes GitHub to auto-close the triggering issue when the PR is merged. Set `auto-close-issue: false` to opt out of this behavior — useful for partial-work PRs, multi-PR workflows, or any case where the PR should reference but not close the issue.