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
- Push conventional commit touching
functions/xdeployment/. Release-please creates PR with title chore(main): release xdeployment 5.0.0. ✅
- Push another conventional commit touching the same path.
- 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-814 — GitHub.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
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):
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:
- Push branch changes (commit + force-push the ref).
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.
With
separate-pull-requests: truein 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
Steps to reproduce the error
functions/xdeployment/. Release-please creates PR with titlechore(main): release xdeployment 5.0.0. ✅In the same run, another package's existing PR (
xr-types/xappregistration, titlechore(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
pull-request-title-pattern, both titles parse cleanly to expected component valuesEnvironment
actions/create-github-app-token@v3Claude 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: true422 on PR updateWhere 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.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 byheadBranchName === headRefNameis a plain string compare and works fine for both):Then
maybeUpdateExistingPullRequest(src/manifest.ts:1090) bails out only when bodies are byte-identical:PR #9 remained the sameline in the logs.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 insrc/util/code-suggester/github/open-pull-request.tson thefeat: remove dep on code-suggesterbranch — an "exact port" of v5 according to the commit message):updatePullRequestreusessuggesterCreatePullRequest. After it pushes the new commit (theUpdating reference …log line in the report), it falls through toopenPullRequest, 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 returns422 A pull request already exists.The architectural issue
updatePullRequestis fundamentally misusingsuggesterCreatePullRequest. The "update an existing PR" path should be: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)). ButsuggesterCreatePullRequestis 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-
8c5e2aeversion of this code (visible viagit show 8c5e2ae~1:src/github.ts) had exactly the same shape —createPullRequestfrom code-suggester followed byoctokit.pulls.update— so this isn't a new regression; it's a longstanding fragility thatseparate-pull-requests: trueexposes more often because every package whose body changes hits this path independently.Suggested fix
In
src/github.ts:770-814, drop thesuggesterCreatePullRequestcall from the update path and replace it with a direct branch-push (the commit-and-push portion of code-suggester) plus the existinggitHubApi.updatePullRequest(number, title, body)PATCH. The "is there an existing PR?" lookup is already handled correctly upstream inmanifest.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-suggesterbranch (commitfb16623) 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.listhead-filter lookup misses the existing PR in the first place — it could be ahead.labelcasing mismatch between the configuredorg-hereand 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.