From 40e8fec7b326d91687d28e14a05a5c2d405cb843 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 3 Aug 2026 17:07:10 +0200 Subject: [PATCH] feat(create-pr): add reuse_branch to keep one self-updating pull request create-pr appends a random suffix to branch_name on every run, so each run that finds a diff opens a new branch and a new pull request. While an earlier one is still unmerged, every subsequent run legitimately still sees a diff against the base branch and opens another pull request for it. They accumulate, and once a release lands they all go permanently conflicting. In dfinity/icp-js-core this produced 8 changelog pull requests for 2 distinct changelog states: 3 with byte-identical diffs, and 5 that went conflicting after v6.0.0 shipped and had to be closed unresolved. dfinity/pic-js currently has 6 open, 3 of them conflicting. Adds a reuse_branch input. When enabled, branch_name is used as-is, the branch is reset to the current commit and force pushed, and the pull request already open for it is updated rather than a second one being created. When there is nothing left to propose, that pull request is closed instead of being left to go stale. The input defaults to false, so existing callers such as create-release-pr keep the current behaviour. The generate-changelog reusable workflow opts in by default, since a single self-updating changelog pull request is the intent there. A plain --force is used rather than --force-with-lease: the branch is reset from the base branch on every run, so the local ref never matches the remote one and a lease check would always reject. Closes #77 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-changelog.yaml | 6 ++ actions/assemble-docs/dist/index.js | 8 +- actions/create-pr/action.yaml | 6 ++ actions/create-pr/dist/index.js | 85 +++++++++++++++++--- actions/create-pr/src/create-commit.ts | 10 ++- actions/create-pr/src/create-pull-request.ts | 54 +++++++++++++ actions/create-pr/src/main.ts | 57 ++++++++++++- actions/extract-version/dist/index.js | 8 +- actions/submit-docs/dist/index.js | 8 +- lib/action-utils/src/git.ts | 26 +++++- 10 files changed, 237 insertions(+), 31 deletions(-) diff --git a/.github/workflows/generate-changelog.yaml b/.github/workflows/generate-changelog.yaml index 75caf29..e2bb4f1 100644 --- a/.github/workflows/generate-changelog.yaml +++ b/.github/workflows/generate-changelog.yaml @@ -19,6 +19,11 @@ on: required: false default: 'main' type: string + reuse_branch: + description: 'Keep a single self-updating changelog pull request instead of opening a new one on every run.' + required: false + default: true + type: boolean pull_request_title: description: 'The title of the pull request.' @@ -110,6 +115,7 @@ jobs: with: branch_name: ${{ inputs.branch_name }} base_branch_name: ${{ inputs.base_branch_name }} + reuse_branch: ${{ inputs.reuse_branch }} pull_request_title: ${{ inputs.pull_request_title }} pull_request_body: ${{ inputs.pull_request_body }} author_name: ${{ inputs.author_name }} diff --git a/actions/assemble-docs/dist/index.js b/actions/assemble-docs/dist/index.js index 7deb253..ad8744a 100644 --- a/actions/assemble-docs/dist/index.js +++ b/actions/assemble-docs/dist/index.js @@ -21269,11 +21269,11 @@ var require_dist = __commonJS({ exec2(`git config user.email "${authorEmail}"`); exec2(`git commit -m "${message}"`); } - function gitCheckoutBranch(branch) { - exec2(`git checkout -b ${branch}`); + function gitCheckoutBranch(branch, { reset = false } = {}) { + exec2(`git checkout ${reset ? "-B" : "-b"} ${branch}`); } - function gitPushBranch(branch) { - exec2(`git push -u origin ${branch}`); + function gitPushBranch(branch, { force = false } = {}) { + exec2(`git push ${force ? "--force " : ""}-u origin ${branch}`); } function gitHasChanges() { const output = exec2("git status --porcelain"); diff --git a/actions/create-pr/action.yaml b/actions/create-pr/action.yaml index 113f198..bf99d78 100644 --- a/actions/create-pr/action.yaml +++ b/actions/create-pr/action.yaml @@ -11,6 +11,10 @@ inputs: description: 'The name of the base branch to create a pull request against.' required: false default: 'main' + reuse_branch: + description: 'Reuse branch_name as-is and update the pull request already open for it, instead of creating a new randomly suffixed branch on every run. When there is nothing left to propose, the open pull request is closed.' + required: false + default: 'false' pull_request_title: description: 'The title of the pull request.' @@ -45,6 +49,8 @@ outputs: description: 'The number of the created pull request.' pull_request_created: description: 'Whether the pull request was created.' + pull_request_updated: + description: 'Whether an already open pull request was updated instead of a new one being created. Only ever true when reuse_branch is enabled.' runs: using: node24 diff --git a/actions/create-pr/dist/index.js b/actions/create-pr/dist/index.js index d846f0c..a07c74b 100644 --- a/actions/create-pr/dist/index.js +++ b/actions/create-pr/dist/index.js @@ -22084,7 +22084,7 @@ var require_dist = __commonJS({ generateRandomSuffix: () => generateRandomSuffix2, getInput: () => getInput22, getNumberInput: () => getNumberInput, - getOptInput: () => getOptInput, + getOptInput: () => getOptInput2, gitAdd: () => gitAdd2, gitCheckoutBranch: () => gitCheckoutBranch2, gitCommit: () => gitCommit2, @@ -22137,11 +22137,11 @@ var require_dist = __commonJS({ exec2(`git config user.email "${authorEmail}"`); exec2(`git commit -m "${message}"`); } - function gitCheckoutBranch2(branch) { - exec2(`git checkout -b ${branch}`); + function gitCheckoutBranch2(branch, { reset = false } = {}) { + exec2(`git checkout ${reset ? "-B" : "-b"} ${branch}`); } - function gitPushBranch2(branch) { - exec2(`git push -u origin ${branch}`); + function gitPushBranch2(branch, { force = false } = {}) { + exec2(`git push ${force ? "--force " : ""}-u origin ${branch}`); } function gitHasChanges2() { const output = exec2("git status --porcelain"); @@ -22151,7 +22151,7 @@ var require_dist = __commonJS({ function getInput22(name) { return core.getInput(name, { required: true, trimWhitespace: true }); } - function getOptInput(name, defaultValue) { + function getOptInput2(name, defaultValue) { return core.getInput(name, { required: false, trimWhitespace: true }) || defaultValue; } function getNumberInput(name) { @@ -25933,6 +25933,37 @@ async function createPullRequest({ number: res.data.number }; } +async function findOpenPullRequest({ + octokit, + owner, + repo, + head, + base +}) { + const res = await octokit.rest.pulls.list({ + owner, + repo, + base, + head: `${owner}:${head}`, + state: "open" + }); + const [pullRequest] = res.data; + return pullRequest ? { number: pullRequest.number } : void 0; +} +async function closePullRequest({ + octokit, + owner, + repo, + number +}) { + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: number, + state: "closed" + }); + info(`Closed pull request #${number}`); +} // src/create-commit.ts init_core(); @@ -25941,12 +25972,13 @@ function createCommit({ message, head, authorName, - authorEmail + authorEmail, + reuseBranch = false }) { - (0, import_action_utils.gitCheckoutBranch)(head); + (0, import_action_utils.gitCheckoutBranch)(head, { reset: reuseBranch }); (0, import_action_utils.gitAdd)(); (0, import_action_utils.gitCommit)(message, authorName, authorEmail); - (0, import_action_utils.gitPushBranch)(head); + (0, import_action_utils.gitPushBranch)(head, { force: reuseBranch }); info(`Created git commit on branch ${head}`); } @@ -25955,7 +25987,9 @@ async function run() { try { const authorName = (0, import_action_utils2.getInput)("author_name"); const authorEmail = (0, import_action_utils2.getInput)("author_email"); - const head = `${(0, import_action_utils2.getInput)("branch_name")}-${(0, import_action_utils2.generateRandomSuffix)(6)}`; + const reuseBranch = (0, import_action_utils2.getOptInput)("reuse_branch", "false") === "true"; + const branchName = (0, import_action_utils2.getInput)("branch_name"); + const head = reuseBranch ? branchName : `${branchName}-${(0, import_action_utils2.generateRandomSuffix)(6)}`; const base = (0, import_action_utils2.getInput)("base_branch_name"); const message = (0, import_action_utils2.getInput)("commit_message"); const title = (0, import_action_utils2.getInput)("pull_request_title"); @@ -25968,14 +26002,42 @@ async function run() { "No changes detected, skipping commit and pull request creation" ); setOutput("pull_request_created", false); + setOutput("pull_request_updated", false); + if (reuseBranch) { + const obsolete = await findOpenPullRequest({ + octokit, + owner, + repo, + head, + base + }); + if (obsolete) { + await closePullRequest({ + octokit, + owner, + repo, + number: obsolete.number + }); + setOutput("pull_request_number", obsolete.number); + } + } return; } createCommit({ authorEmail, authorName, head, - message + message, + reuseBranch }); + const existing = reuseBranch ? await findOpenPullRequest({ octokit, owner, repo, head, base }) : void 0; + if (existing) { + info(`Updated pull request #${existing.number}`); + setOutput("pull_request_number", existing.number); + setOutput("pull_request_created", false); + setOutput("pull_request_updated", true); + return; + } const res = await createPullRequest({ octokit, owner, @@ -25987,6 +26049,7 @@ async function run() { }); setOutput("pull_request_number", res.number); setOutput("pull_request_created", true); + setOutput("pull_request_updated", false); } catch (error2) { if (error2 instanceof Error) { setFailed(error2.message); diff --git a/actions/create-pr/src/create-commit.ts b/actions/create-pr/src/create-commit.ts index daebd2b..6afa44c 100644 --- a/actions/create-pr/src/create-commit.ts +++ b/actions/create-pr/src/create-commit.ts @@ -11,6 +11,11 @@ export interface CreateCommitOptions { head: string; authorName: string; authorEmail: string; + /** + * Reuse a long-lived branch rather than a fresh one, resetting it to the + * current commit and force pushing it. + */ + reuseBranch?: boolean; } export function createCommit({ @@ -18,11 +23,12 @@ export function createCommit({ head, authorName, authorEmail, + reuseBranch = false, }: CreateCommitOptions): void { - gitCheckoutBranch(head); + gitCheckoutBranch(head, { reset: reuseBranch }); gitAdd(); gitCommit(message, authorName, authorEmail); - gitPushBranch(head); + gitPushBranch(head, { force: reuseBranch }); core.info(`Created git commit on branch ${head}`); } diff --git a/actions/create-pr/src/create-pull-request.ts b/actions/create-pr/src/create-pull-request.ts index 7ebd1aa..3e0eeef 100644 --- a/actions/create-pr/src/create-pull-request.ts +++ b/actions/create-pr/src/create-pull-request.ts @@ -39,3 +39,57 @@ export async function createPullRequest({ number: res.data.number, }; } + +export interface FindOpenPullRequestOptions { + octokit: Octokit; + owner: string; + repo: string; + head: string; + base: string; +} + +/** + * Finds the open pull request for the given head and base branches, if any. + */ +export async function findOpenPullRequest({ + octokit, + owner, + repo, + head, + base, +}: FindOpenPullRequestOptions): Promise { + const res = await octokit.rest.pulls.list({ + owner, + repo, + base, + head: `${owner}:${head}`, + state: 'open', + }); + + const [pullRequest] = res.data; + + return pullRequest ? { number: pullRequest.number } : undefined; +} + +export interface ClosePullRequestOptions { + octokit: Octokit; + owner: string; + repo: string; + number: number; +} + +export async function closePullRequest({ + octokit, + owner, + repo, + number, +}: ClosePullRequestOptions): Promise { + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: number, + state: 'closed', + }); + + core.info(`Closed pull request #${number}`); +} diff --git a/actions/create-pr/src/main.ts b/actions/create-pr/src/main.ts index b90b311..f461307 100644 --- a/actions/create-pr/src/main.ts +++ b/actions/create-pr/src/main.ts @@ -3,16 +3,28 @@ import * as github from '@actions/github'; import { generateRandomSuffix, getInput, + getOptInput, gitHasChanges, } from '@dfinity/action-utils'; -import { createPullRequest } from './create-pull-request'; +import { + closePullRequest, + createPullRequest, + findOpenPullRequest, +} from './create-pull-request'; import { createCommit } from './create-commit'; export async function run(): Promise { try { const authorName = getInput('author_name'); const authorEmail = getInput('author_email'); - const head = `${getInput('branch_name')}-${generateRandomSuffix(6)}`; + const reuseBranch = getOptInput('reuse_branch', 'false') === 'true'; + const branchName = getInput('branch_name'); + // A fresh branch per run leaves a pull request behind on every run where an + // earlier one has not been merged yet. Reusing a single branch keeps one + // self-updating pull request instead. + const head = reuseBranch + ? branchName + : `${branchName}-${generateRandomSuffix(6)}`; const base = getInput('base_branch_name'); const message = getInput('commit_message'); const title = getInput('pull_request_title'); @@ -27,6 +39,30 @@ export async function run(): Promise { 'No changes detected, skipping commit and pull request creation', ); core.setOutput('pull_request_created', false); + core.setOutput('pull_request_updated', false); + + // There is nothing left to propose, so a pull request opened by an earlier + // run is obsolete. Left open it goes stale and eventually conflicts. + if (reuseBranch) { + const obsolete = await findOpenPullRequest({ + octokit, + owner, + repo, + head, + base, + }); + + if (obsolete) { + await closePullRequest({ + octokit, + owner, + repo, + number: obsolete.number, + }); + core.setOutput('pull_request_number', obsolete.number); + } + } + return; } @@ -35,8 +71,24 @@ export async function run(): Promise { authorName, head, message, + reuseBranch, }); + // The force push above has already updated any open pull request for this + // branch, so creating another one would fail. + const existing = reuseBranch + ? await findOpenPullRequest({ octokit, owner, repo, head, base }) + : undefined; + + if (existing) { + core.info(`Updated pull request #${existing.number}`); + core.setOutput('pull_request_number', existing.number); + core.setOutput('pull_request_created', false); + core.setOutput('pull_request_updated', true); + + return; + } + const res = await createPullRequest({ octokit, owner, @@ -49,6 +101,7 @@ export async function run(): Promise { core.setOutput('pull_request_number', res.number); core.setOutput('pull_request_created', true); + core.setOutput('pull_request_updated', false); } catch (error) { if (error instanceof Error) { core.setFailed(error.message); diff --git a/actions/extract-version/dist/index.js b/actions/extract-version/dist/index.js index bd0f3fc..78ffe2d 100644 --- a/actions/extract-version/dist/index.js +++ b/actions/extract-version/dist/index.js @@ -21269,11 +21269,11 @@ var require_dist = __commonJS({ exec3(`git config user.email "${authorEmail}"`); exec3(`git commit -m "${message}"`); } - function gitCheckoutBranch(branch) { - exec3(`git checkout -b ${branch}`); + function gitCheckoutBranch(branch, { reset = false } = {}) { + exec3(`git checkout ${reset ? "-B" : "-b"} ${branch}`); } - function gitPushBranch(branch) { - exec3(`git push -u origin ${branch}`); + function gitPushBranch(branch, { force = false } = {}) { + exec3(`git push ${force ? "--force " : ""}-u origin ${branch}`); } function gitHasChanges() { const output = exec3("git status --porcelain"); diff --git a/actions/submit-docs/dist/index.js b/actions/submit-docs/dist/index.js index 81938e3..70e2750 100644 --- a/actions/submit-docs/dist/index.js +++ b/actions/submit-docs/dist/index.js @@ -22137,11 +22137,11 @@ var require_dist = __commonJS({ exec2(`git config user.email "${authorEmail}"`); exec2(`git commit -m "${message}"`); } - function gitCheckoutBranch(branch) { - exec2(`git checkout -b ${branch}`); + function gitCheckoutBranch(branch, { reset = false } = {}) { + exec2(`git checkout ${reset ? "-B" : "-b"} ${branch}`); } - function gitPushBranch2(branch) { - exec2(`git push -u origin ${branch}`); + function gitPushBranch2(branch, { force = false } = {}) { + exec2(`git push ${force ? "--force " : ""}-u origin ${branch}`); } function gitHasChanges2() { const output = exec2("git status --porcelain"); diff --git a/lib/action-utils/src/git.ts b/lib/action-utils/src/git.ts index 2cc52d6..6740ed9 100644 --- a/lib/action-utils/src/git.ts +++ b/lib/action-utils/src/git.ts @@ -14,12 +14,30 @@ export function gitCommit( exec(`git commit -m "${message}"`); } -export function gitCheckoutBranch(branch: string): void { - exec(`git checkout -b ${branch}`); +export interface GitCheckoutBranchOptions { + /** + * Reset the branch to the current commit if it already exists, instead of + * failing. Required when reusing a long-lived branch. + */ + reset?: boolean; } -export function gitPushBranch(branch: string): void { - exec(`git push -u origin ${branch}`); +export function gitCheckoutBranch( + branch: string, + { reset = false }: GitCheckoutBranchOptions = {}, +): void { + exec(`git checkout ${reset ? '-B' : '-b'} ${branch}`); +} + +export interface GitPushBranchOptions { + force?: boolean; +} + +export function gitPushBranch( + branch: string, + { force = false }: GitPushBranchOptions = {}, +): void { + exec(`git push ${force ? '--force ' : ''}-u origin ${branch}`); } export function gitHasChanges(): boolean {