Skip to content

Validation Failed: A pull request already exists on PR update with separate-pull-requests: true (Go monorepo, v17.6.0) #2773

@Kerwood

Description

@Kerwood

With separate-pull-requests: true in a manifest config, release-please consistently fails when updating an existing release PR. It successfully pushes the new commit to the PR's branch, then attempts to create a PR for that same branch and 422s because the PR already exists. The PR's body/title is never refreshed; the action exits non-zero.

Tested on a Go monorepo, with GitHub App token, release-please-action@v5 (also reproduced on v4; both pull library v17.x).

release-please-config.json

{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "release-type": "go",
  "bootstrap-sha": "ae96428ae9378674dba1be3ad05e95179123bdcb",
  "include-component-in-tag": true,
  "tag-separator": "/",
  "separate-pull-requests": true,
  "pull-request-title-pattern": "chore${scope}: release ${component} ${version}",
  "packages": {
    "functions/xappregistration": { "component": "xappregistration" },
    "functions/xdeployment":      { "component": "xdeployment" },
    "xr-types/xappregistration":  { "component": "xr-types/xappregistration" },
    "xr-types/xdeployment":       { "component": "xr-types/xdeployment" },
    "xr-types-cli":               { "component": "xr-types-cli" }
  }
}

.release-please-manifest.json

{
  "functions/xappregistration": "0.0.0",
  "functions/xdeployment": "4.0.3",
  "xr-types/xappregistration": "0.0.0",
  "xr-types/xdeployment": "0.0.0",
  "xr-types-cli": "0.0.0"
}

Workflow job

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/create-github-app-token@v3
        id: app-token
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          owner: org-here
          repositories: |
            my-repo-here

      - uses: googleapis/release-please-action@v5
        with:
          token: ${{ steps.app-token.outputs.token }}
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json

Steps to reproduce the error

  1. Push conventional commit touching functions/xdeployment/. Release-please creates PR with title chore(main): release xdeployment 5.0.0. ✅
  2. Push another conventional commit touching the same path.
  3. Action logs:
✔ Looking for open release pull requests
✔ found 2 open release pull requests.
...
❯ component: xdeployment
❯ pull request title pattern: chore${scope}: release ${component} ${version}
...
✔ Updating reference heads/release-please--branches--main--components--xdeployment to <sha>
✔ Successfully updated reference release-please--branches--main--components--xdeployment to <sha>
Error: release-please failed: Validation Failed: {"resource":"PullRequest","code":"custom","message":"A pull request already exists for ..."}

In the same run, another package's existing PR (xr-types/xappregistration, title chore(main): release xr-types/xappregistration 1.0.0) is correctly identified, log shows ✔ PR #9 remained the same. Only the package being updated hits the duplicate-PR error.

Eliminated causes

  • App permissions: Contents/PRs/Issues all read+write, accepted on installation
  • Unparseable commits: cleaned all commits to strict conventional format, bug persists
  • Title pattern parsing: both surviving PRs were created fresh under the explicit pull-request-title-pattern, both titles parse cleanly to expected component values
  • Action version: v4 and v5 both reproduce
  • Stale PRs: closed all release PRs and deleted branches, recreated fresh, second commit still triggers the bug

Environment

  • release-please library: 17.6.0 (also 17.3.0)
  • release-please-action: v5 (also v4)
  • Token: GitHub App installation token via actions/create-github-app-token@v3

Claude Code Output

I tried to set Claude Code to find the issue in the code. This is the output.

Claude Code Output

Findings: separate-pull-requests: true 422 on PR update

Where the bug lives

src/github.ts:770-814GitHub.updatePullRequest. This is the function called when release-please decides an existing PR needs its body refreshed.

async updatePullRequest(number, releasePullRequest, targetBranch, options) {
  const changes = await this.buildChangeSet(...)
  ...
  const prNumber = await suggesterCreatePullRequest(this.octokit, changes, {
    upstreamOwner: this.repository.owner,
    upstreamRepo: this.repository.repo,
    title,
    branch: releasePullRequest.headRefName,
    description: body,
    primary: targetBranch,
    force: true,
    fork: options?.fork === false ? false : true,
    ...
  });
  ...
  return this.gitHubApi.updatePullRequest(number, title, body);
}

How the failing case reaches this function

For the user's two open PRs, both find the existing PR at src/manifest.ts:1040 (matching by headBranchName === headRefName is a plain string compare and works fine for both):

// src/manifest.ts:1040
const existing = openPullRequests.find(
  openPullRequest =>
    openPullRequest.headBranchName === pullRequest.headRefName
);
if (existing) {
  return this.alwaysUpdate
    ? await this.updateExistingPullRequest(existing, pullRequest)
    : await this.maybeUpdateExistingPullRequest(existing, pullRequest);
}

Then maybeUpdateExistingPullRequest (src/manifest.ts:1090) bails out only when bodies are byte-identical:

if (existing.body === pullRequest.body.toString()) {
  this.logger.info(`PR ... remained the same`);
  return undefined;
}
return await this.updateExistingPullRequest(existing, pullRequest);
  • xr-types/xappregistration: no new commits → body identical → early return → no call to code-suggester. This is the PR #9 remained the same line in the logs.
  • xdeployment: the new commit changes the changelog body → falls through to updateExistingPullRequestgithub.updatePullRequestsuggesterCreatePullRequest.

The bug only manifests on the package whose body actually changed — exactly what the report describes.

Why code-suggester then 422s

Inside code-suggester (logic ported in src/util/code-suggester/github/open-pull-request.ts on the feat: remove dep on code-suggester branch — an "exact port" of v5 according to the commit message):

const head = `${origin.owner}:${origin.branch}`;
const existingPullRequest = (
  await octokit.pulls.list({
    owner: upstream.owner,
    repo: origin.repo,
    head,
  })
).data.find(pr => pr.head.label === head);
if (existingPullRequest) { return existingPullRequest.number; }
const pullResponseData = (
  await octokit.pulls.create({ ... })
).data;

updatePullRequest reuses suggesterCreatePullRequest. After it pushes the new commit (the Updating reference … log line in the report), it falls through to openPullRequest, which is supposed to short-circuit when a PR already exists for ${owner}:${branch}. When that lookup misses, code-suggester goes ahead and POSTs /pulls — which is when GitHub returns 422 A pull request already exists.

The architectural issue

updatePullRequest is fundamentally misusing suggesterCreatePullRequest. The "update an existing PR" path should be:

  1. Push branch changes (commit + force-push the ref).
  2. octokit.pulls.update({pull_number: number, title, body}) to refresh the PR.

Step 2 is already happening at line 813 (return this.gitHubApi.updatePullRequest(number, title, body)). But suggesterCreatePullRequest is being called before that and brings along its own create-or-skip logic (openPullRequest). The whole flow only works when that lookup happens to detect the existing PR. In the failing case it doesn't, and the function tries to create a duplicate.

The pre-8c5e2ae version of this code (visible via git show 8c5e2ae~1:src/github.ts) had exactly the same shape — createPullRequest from code-suggester followed by octokit.pulls.update — so this isn't a new regression; it's a longstanding fragility that separate-pull-requests: true exposes more often because every package whose body changes hits this path independently.

Suggested fix

In src/github.ts:770-814, drop the suggesterCreatePullRequest call from the update path and replace it with a direct branch-push (the commit-and-push portion of code-suggester) plus the existing gitHubApi.updatePullRequest(number, title, body) PATCH. The "is there an existing PR?" lookup is already handled correctly upstream in manifest.ts:1040 — re-doing it inside code-suggester is the bit that breaks.

Worth noting: there is in-flight work on the feat: remove dep on code-suggester branch (commit fb16623) that inlines code-suggester verbatim — so the underlying fragility comes along for the ride. Fixing it on that branch (split push from PR-create) would resolve this cleanly without an external dependency.

Remaining uncertainty

The path and the wrong-fit function are clear. The one piece of remaining uncertainty is why code-suggester's pulls.list head-filter lookup misses the existing PR in the first place — it could be a head.label casing mismatch between the configured org-here and the actual org login GitHub returns, or just code-suggester v5 not surfacing the PR for that head. Either way, the fix is the same: the update path shouldn't rely on that lookup.

Metadata

Metadata

Assignees

Labels

priority: p3Desirable enhancement or fix. May not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

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