googleapis / googleapis/release-please

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

Open
#2,773 0 comments 2 reactions 1 assignee Claimed by @chingor13 View on GitHub
priority: p3 type: bug
Dominant language
TypeScript
Stars
7.5k
Forks
588
Avg merge
12h 16m
Merged PRs (30d)
7

Description

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`

```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`
```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
```yaml
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
✔ Successfully updated reference release-please--branches--main--components--xdeployment to
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-814` — `GitHub.updatePullRequest`. This is the function called when release-please decides an existing PR needs its body refreshed.

```ts
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):

```ts
// 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:

```ts
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 `updateExistingPullRequest` → `github.updatePullRequest` → `suggesterCreatePullRequest`.

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):

```ts
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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.