Skip to content

create-pull-request: preserve-branch-name silently bypassed when remote branch already exists #27454

@mrjf

Description

@mrjf

Summary

safe-outputs.create-pull-request.preserve-branch-name: true is silently bypassed when the agent-supplied branch name already exists on the remote. The collision handler unconditionally appends a random 8-char hex salt (-[0-9a-f]{8}) to the branch name, defeating the feature's stated purpose.

This is particularly painful for workflows that use long-running branches (one branch per program, accumulating commits across many iterations). Every iteration after the first creates a fresh, disconnected branch with a new salt suffix, producing a growing pile of unrelated PRs instead of a single PR accumulating commits.

Reproducer

  1. Configure a workflow with:
    safe-outputs:
      create-pull-request:
        preserve-branch-name: true
  2. Have the agent create a PR on branch my-branch. This works — PR opens on my-branch.
  3. Merge or close the PR without deleting the branch (the branch remains on the remote).
  4. On the next run, have the agent create another PR on my-branch.
  5. Observed: gh-aw emits ##[warning]Remote branch my-branch already exists - appending random suffix, renames the branch to my-branch-abc12345, and opens a new PR there.
  6. Expected: honor preserve-branch-name: true — either push onto the existing branch (creating a new PR pointing at the same branch, or failing cleanly if no open PR exists), or fail explicitly with an actionable error. Silent suffix-appending contradicts the documented behavior.

Example log excerpt

From a real run on a repo using autoloop-style long-running branches:

Using branch name from JSONL without salt suffix (preserve-branch-name enabled): autoloop/perf-comparison
Generated branch name: autoloop/perf-comparison
Base branch: main
Fetching base branch: main
Branch should not exist locally, creating new branch from base: autoloop/perf-comparison
Switched to a new branch 'autoloop/perf-comparison'
Created new branch from base: autoloop/perf-comparison
##[warning]Remote branch autoloop/perf-comparison already exists - appending random suffix
[command]/usr/bin/git branch -m autoloop/perf-comparison autoloop/perf-comparison-36d7559a
Renamed branch to autoloop/perf-comparison-36d7559a

Root cause

In actions/setup/js/create_pull_request.cjs:

  • Line 294: const preserveBranchName = config.preserve_branch_name === true;
  • Line 822: branchName = normalizeBranchName(branchName, preserveBranchName ? null : randomHex); — flag is honored during initial normalization.
  • Line 829: if (preserveBranchName) { ... } — logs the "without salt suffix" message.

Collision handling appears three times (signed-commits path ~L985, patch-apply path ~L1213, empty-commit path ~L1377). All three blocks are identical and do not reference preserveBranchName:

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}`;
  await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
  ...
}

So the salt is appended regardless of the flag.

Suggested fix

Gate each of the three collision blocks on !preserveBranchName. When preserveBranchName is true and the remote branch already exists, pick one of:

  1. Push onto the existing branch (matching the "long-running branch" intent users typically have when enabling this flag). If an open PR already points at the branch, add the commit there; if not, open a new PR targeting the existing branch.
  2. Fail loudly with a descriptive error naming the colliding branch and suggesting either deleting it or using push-to-pull-request-branch instead. Silent bypass is the worst option because the user sees a PR on a differently-named branch with no visible error — the flag appears to work initially and then silently stops.

At minimum, document the current behavior in the preserve-branch-name reference doc so users aren't surprised.

Context / references

  • Feature introduced in feat: add preserve-branch-name option to create-pull-request safe output #20788 (merged 2026-03-13)
  • Docs: docs/src/content/docs/reference/safe-outputs-pull-requests.md — describes preserve-branch-name as "omits the random hex salt suffix that is normally appended" with no mention of collision handling.
  • Config struct: pkg/workflow/create_pull_request.goPreserveBranchName field.

Happy to submit a PR gating the three collision blocks on !preserveBranchName if that's the preferred direction.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions