Skip to content
Merged
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
106 changes: 46 additions & 60 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,48 @@ function generatePatchPreview(patchContent) {
return `\n\n<details><summary>${summary}</summary>\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n</details>`;
}

/**
* 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<string>} 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -1408,6 +1393,7 @@ ${patchPreview}`;
return {
success: false,
error,
error_type: "push_failed",
};
}
} else {
Expand Down
57 changes: 57 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down