From 67818ee5078353177cf18b08b33ef3c3dd4838dc Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 3 Aug 2026 17:07:10 +0200 Subject: [PATCH 01/10] 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 5da7429..fa149bd 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.' @@ -153,6 +158,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 0defcd9..fc39c81 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 { From e95239ffbc2a7b1577b53f32defa4b0b21eb3f78 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 10:00:50 +0200 Subject: [PATCH 02/10] fix(generate-changelog): serialise runs that share the reused branch The reused branch is force pushed, so two runs racing on it leave the pull request holding whichever finished last rather than the newest state of the base branch. Also documents reuse_branch and the outputs it affects, which the action and workflow READMEs were missing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-changelog.yaml | 7 +++++++ actions/create-pr/README.md | 24 ++++++++++++----------- actions/create-pr/action.yaml | 2 +- workflows/generate-changelog/README.md | 3 +++ 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/generate-changelog.yaml b/.github/workflows/generate-changelog.yaml index fa149bd..778f80f 100644 --- a/.github/workflows/generate-changelog.yaml +++ b/.github/workflows/generate-changelog.yaml @@ -85,6 +85,13 @@ on: token_private_key: description: 'A GitHub App private key used to generate an access token to create a pull request.' +# The changelog branch is reused and force pushed, so two runs racing on the same +# branch would leave the pull request holding whichever finished last rather than +# the newest state of the base branch. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: generate_changelog: runs-on: ubuntu-latest diff --git a/actions/create-pr/README.md b/actions/create-pr/README.md index a2f0c47..53d3714 100644 --- a/actions/create-pr/README.md +++ b/actions/create-pr/README.md @@ -14,21 +14,23 @@ packages/some-package/package.json ## Action inputs -| Input | Description | Default | -| -------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------- | -| `branch_name` | The name of the branch to create the pull request from. | `'patch'` | -| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | -| `pull_request_title` | The title of the pull request. | `'chore: automated by GitHub actions'` | -| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action.'` | -| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | -| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | -| `commit_message` | The message of the commit. | `'chore: automated by GitHub actions'` | -| `token` | Access token to manage the pull request. | `${{ GITHUB_TOKEN }}` | +| Input | Description | Default | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `branch_name` | The name of the branch to create the pull request from. | `'patch'` | +| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | +| `reuse_branch` | 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. | `'false'` | +| `pull_request_title` | The title of the pull request. | `'chore: automated by GitHub actions'` | +| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action.'` | +| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | +| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | +| `commit_message` | The message of the commit. | `'chore: automated by GitHub actions'` | +| `token` | Access token to manage the pull request. | `${{ GITHUB_TOKEN }}` | ## Action outputs -- `pull_request_number`: The number of the created pull request. +- `pull_request_number`: The number of the pull request that was created, updated or closed. Empty when there was nothing to propose and no pull request was open. - `pull_request_created`: A boolean indicating whether the pull request was created. This will be `false` is there are no changes to commit. +- `pull_request_updated`: A boolean indicating whether an already open pull request was updated instead of a new one being created. Only ever `true` when `reuse_branch` is enabled. ## Example usage diff --git a/actions/create-pr/action.yaml b/actions/create-pr/action.yaml index bf99d78..8b014b4 100644 --- a/actions/create-pr/action.yaml +++ b/actions/create-pr/action.yaml @@ -46,7 +46,7 @@ inputs: outputs: pull_request_number: - description: 'The number of the created pull request.' + description: 'The number of the pull request that was created, updated or closed. Empty when there was nothing to propose and no pull request was open.' pull_request_created: description: 'Whether the pull request was created.' pull_request_updated: diff --git a/workflows/generate-changelog/README.md b/workflows/generate-changelog/README.md index 23509dd..99de30c 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -10,12 +10,15 @@ CHANGELOG.md Release commits are skipped. A release bumps the project version without adding anything a changelog entry could be generated from, so Commitizen has nothing to do and exits non-zero. Both that and an empty changelog are treated as a no-op rather than a failure. +Only one run per branch happens at a time: the changelog branch is reused and force pushed, so concurrent runs are cancelled in favour of the most recent one. + ## Workflow inputs | Input | Description | Default | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `branch_name` | The name of the branch to create the pull request from. | `'patch'` | | `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | +| `reuse_branch` | Keep a single self-updating changelog pull request instead of opening a new one on every run. | `true` | | `pull_request_title` | The title of the pull request. | `'chore: automated by GitHub actions'` | | `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action.'` | | `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | From e741b2b6fc1eb11636ba17c06f3d56b043a0a36b Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 11:31:48 +0200 Subject: [PATCH 03/10] fix(action-utils): run git without a shell The git helpers interpolated branch names, commit messages and author details into a string passed to execSync, which runs it through a shell. A branch name of 'x; touch /tmp/pwned; #' executed the injected command, and quoting the values only narrows that rather than closing it. Arguments now go straight to git, so an input cannot be read as shell syntax. Also fixes two wording problems raised in review. Co-Authored-By: Claude Opus 5 (1M context) --- actions/assemble-docs/dist/index.js | 21 ++++++++++++++------- actions/create-pr/README.md | 2 +- actions/create-pr/dist/index.js | 21 ++++++++++++++------- actions/extract-version/dist/index.js | 21 ++++++++++++++------- actions/submit-docs/dist/index.js | 21 ++++++++++++++------- lib/action-utils/src/command.ts | 11 ++++++++++- lib/action-utils/src/git.ts | 20 ++++++++++++-------- workflows/generate-changelog/README.md | 2 +- 8 files changed, 80 insertions(+), 39 deletions(-) diff --git a/actions/assemble-docs/dist/index.js b/actions/assemble-docs/dist/index.js index fc39c81..dff85be 100644 --- a/actions/assemble-docs/dist/index.js +++ b/actions/assemble-docs/dist/index.js @@ -21213,6 +21213,7 @@ var require_dist = __commonJS({ absolutePath: () => absolutePath2, deleteFile: () => deleteFile2, exec: () => exec2, + execFile: () => execFile, generateRandomSuffix: () => generateRandomSuffix, getInput: () => getInput22, getNumberInput: () => getNumberInput, @@ -21233,6 +21234,9 @@ var require_dist = __commonJS({ function exec2(command) { return (0, import_child_process.execSync)(command).toString(); } + function execFile(file, args) { + return (0, import_child_process.execFileSync)(file, args).toString(); + } var ALPHANUM = "abcdefghijklmnopqrstuvwxyz0123456789"; function generateRandomSuffix(length) { let result = ""; @@ -21261,22 +21265,25 @@ var require_dist = __commonJS({ function absolutePath2(p) { return p.startsWith("/") ? p : import_node_path2.default.join(process.cwd(), p); } + function git(args) { + return execFile("git", args); + } function gitAdd() { - exec2(`git add .`); + git(["add", "."]); } function gitCommit(message, authorName, authorEmail) { - exec2(`git config user.name "${authorName}"`); - exec2(`git config user.email "${authorEmail}"`); - exec2(`git commit -m "${message}"`); + git(["config", "user.name", authorName]); + git(["config", "user.email", authorEmail]); + git(["commit", "-m", message]); } function gitCheckoutBranch(branch, { reset = false } = {}) { - exec2(`git checkout ${reset ? "-B" : "-b"} ${branch}`); + git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch(branch, { force = false } = {}) { - exec2(`git push ${force ? "--force " : ""}-u origin ${branch}`); + git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); } function gitHasChanges() { - const output = exec2("git status --porcelain"); + const output = git(["status", "--porcelain"]); return output.trim().length > 0; } var core = __toESM2((init_core(), __toCommonJS(core_exports))); diff --git a/actions/create-pr/README.md b/actions/create-pr/README.md index 53d3714..77d5fb9 100644 --- a/actions/create-pr/README.md +++ b/actions/create-pr/README.md @@ -29,7 +29,7 @@ packages/some-package/package.json ## Action outputs - `pull_request_number`: The number of the pull request that was created, updated or closed. Empty when there was nothing to propose and no pull request was open. -- `pull_request_created`: A boolean indicating whether the pull request was created. This will be `false` is there are no changes to commit. +- `pull_request_created`: A boolean indicating whether the pull request was created. This will be `false` if there are no changes to commit. - `pull_request_updated`: A boolean indicating whether an already open pull request was updated instead of a new one being created. Only ever `true` when `reuse_branch` is enabled. ## Example usage diff --git a/actions/create-pr/dist/index.js b/actions/create-pr/dist/index.js index a07c74b..29d877f 100644 --- a/actions/create-pr/dist/index.js +++ b/actions/create-pr/dist/index.js @@ -22081,6 +22081,7 @@ var require_dist = __commonJS({ absolutePath: () => absolutePath, deleteFile: () => deleteFile, exec: () => exec2, + execFile: () => execFile, generateRandomSuffix: () => generateRandomSuffix2, getInput: () => getInput22, getNumberInput: () => getNumberInput, @@ -22101,6 +22102,9 @@ var require_dist = __commonJS({ function exec2(command) { return (0, import_child_process.execSync)(command).toString(); } + function execFile(file, args) { + return (0, import_child_process.execFileSync)(file, args).toString(); + } var ALPHANUM = "abcdefghijklmnopqrstuvwxyz0123456789"; function generateRandomSuffix2(length) { let result = ""; @@ -22129,22 +22133,25 @@ var require_dist = __commonJS({ function absolutePath(p) { return p.startsWith("/") ? p : import_node_path.default.join(process.cwd(), p); } + function git(args) { + return execFile("git", args); + } function gitAdd2() { - exec2(`git add .`); + git(["add", "."]); } function gitCommit2(message, authorName, authorEmail) { - exec2(`git config user.name "${authorName}"`); - exec2(`git config user.email "${authorEmail}"`); - exec2(`git commit -m "${message}"`); + git(["config", "user.name", authorName]); + git(["config", "user.email", authorEmail]); + git(["commit", "-m", message]); } function gitCheckoutBranch2(branch, { reset = false } = {}) { - exec2(`git checkout ${reset ? "-B" : "-b"} ${branch}`); + git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch2(branch, { force = false } = {}) { - exec2(`git push ${force ? "--force " : ""}-u origin ${branch}`); + git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); } function gitHasChanges2() { - const output = exec2("git status --porcelain"); + const output = git(["status", "--porcelain"]); return output.trim().length > 0; } var core = __toESM2((init_core(), __toCommonJS(core_exports))); diff --git a/actions/extract-version/dist/index.js b/actions/extract-version/dist/index.js index 78ffe2d..1f1b1bb 100644 --- a/actions/extract-version/dist/index.js +++ b/actions/extract-version/dist/index.js @@ -21213,6 +21213,7 @@ var require_dist = __commonJS({ absolutePath: () => absolutePath, deleteFile: () => deleteFile, exec: () => exec3, + execFile: () => execFile, generateRandomSuffix: () => generateRandomSuffix, getInput: () => getInput22, getNumberInput: () => getNumberInput, @@ -21233,6 +21234,9 @@ var require_dist = __commonJS({ function exec3(command) { return (0, import_child_process.execSync)(command).toString(); } + function execFile(file, args) { + return (0, import_child_process.execFileSync)(file, args).toString(); + } var ALPHANUM = "abcdefghijklmnopqrstuvwxyz0123456789"; function generateRandomSuffix(length) { let result = ""; @@ -21261,22 +21265,25 @@ var require_dist = __commonJS({ function absolutePath(p) { return p.startsWith("/") ? p : import_node_path2.default.join(process.cwd(), p); } + function git(args) { + return execFile("git", args); + } function gitAdd() { - exec3(`git add .`); + git(["add", "."]); } function gitCommit(message, authorName, authorEmail) { - exec3(`git config user.name "${authorName}"`); - exec3(`git config user.email "${authorEmail}"`); - exec3(`git commit -m "${message}"`); + git(["config", "user.name", authorName]); + git(["config", "user.email", authorEmail]); + git(["commit", "-m", message]); } function gitCheckoutBranch(branch, { reset = false } = {}) { - exec3(`git checkout ${reset ? "-B" : "-b"} ${branch}`); + git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch(branch, { force = false } = {}) { - exec3(`git push ${force ? "--force " : ""}-u origin ${branch}`); + git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); } function gitHasChanges() { - const output = exec3("git status --porcelain"); + const output = git(["status", "--porcelain"]); return output.trim().length > 0; } var core = __toESM2((init_core(), __toCommonJS(core_exports))); diff --git a/actions/submit-docs/dist/index.js b/actions/submit-docs/dist/index.js index 70e2750..e795a5c 100644 --- a/actions/submit-docs/dist/index.js +++ b/actions/submit-docs/dist/index.js @@ -22081,6 +22081,7 @@ var require_dist = __commonJS({ absolutePath: () => absolutePath, deleteFile: () => deleteFile, exec: () => exec2, + execFile: () => execFile, generateRandomSuffix: () => generateRandomSuffix, getInput: () => getInput22, getNumberInput: () => getNumberInput, @@ -22101,6 +22102,9 @@ var require_dist = __commonJS({ function exec2(command) { return (0, import_child_process.execSync)(command).toString(); } + function execFile(file, args) { + return (0, import_child_process.execFileSync)(file, args).toString(); + } var ALPHANUM = "abcdefghijklmnopqrstuvwxyz0123456789"; function generateRandomSuffix(length) { let result = ""; @@ -22129,22 +22133,25 @@ var require_dist = __commonJS({ function absolutePath(p) { return p.startsWith("/") ? p : import_node_path.default.join(process.cwd(), p); } + function git(args) { + return execFile("git", args); + } function gitAdd2() { - exec2(`git add .`); + git(["add", "."]); } function gitCommit2(message, authorName, authorEmail) { - exec2(`git config user.name "${authorName}"`); - exec2(`git config user.email "${authorEmail}"`); - exec2(`git commit -m "${message}"`); + git(["config", "user.name", authorName]); + git(["config", "user.email", authorEmail]); + git(["commit", "-m", message]); } function gitCheckoutBranch(branch, { reset = false } = {}) { - exec2(`git checkout ${reset ? "-B" : "-b"} ${branch}`); + git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch2(branch, { force = false } = {}) { - exec2(`git push ${force ? "--force " : ""}-u origin ${branch}`); + git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); } function gitHasChanges2() { - const output = exec2("git status --porcelain"); + const output = git(["status", "--porcelain"]); return output.trim().length > 0; } var core = __toESM2((init_core(), __toCommonJS(core_exports))); diff --git a/lib/action-utils/src/command.ts b/lib/action-utils/src/command.ts index 67de311..1607bd4 100644 --- a/lib/action-utils/src/command.ts +++ b/lib/action-utils/src/command.ts @@ -1,9 +1,18 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; export function exec(command: string): string { return execSync(command).toString(); } +/** + * Runs a command with its arguments passed straight to it rather than through a + * shell, so that a value taken from an action input cannot be read as shell + * syntax. Prefer this over `exec` for anything built from an input. + */ +export function execFile(file: string, args: string[]): string { + return execFileSync(file, args).toString(); +} + const ALPHANUM = 'abcdefghijklmnopqrstuvwxyz0123456789'; export function generateRandomSuffix(length: number): string { diff --git a/lib/action-utils/src/git.ts b/lib/action-utils/src/git.ts index 6740ed9..a1c7693 100644 --- a/lib/action-utils/src/git.ts +++ b/lib/action-utils/src/git.ts @@ -1,7 +1,11 @@ -import { exec } from './command'; +import { execFile } from './command'; + +function git(args: string[]): string { + return execFile('git', args); +} export function gitAdd(): void { - exec(`git add .`); + git(['add', '.']); } export function gitCommit( @@ -9,9 +13,9 @@ export function gitCommit( authorName: string, authorEmail: string, ): void { - exec(`git config user.name "${authorName}"`); - exec(`git config user.email "${authorEmail}"`); - exec(`git commit -m "${message}"`); + git(['config', 'user.name', authorName]); + git(['config', 'user.email', authorEmail]); + git(['commit', '-m', message]); } export interface GitCheckoutBranchOptions { @@ -26,7 +30,7 @@ export function gitCheckoutBranch( branch: string, { reset = false }: GitCheckoutBranchOptions = {}, ): void { - exec(`git checkout ${reset ? '-B' : '-b'} ${branch}`); + git(['checkout', reset ? '-B' : '-b', branch]); } export interface GitPushBranchOptions { @@ -37,11 +41,11 @@ export function gitPushBranch( branch: string, { force = false }: GitPushBranchOptions = {}, ): void { - exec(`git push ${force ? '--force ' : ''}-u origin ${branch}`); + git(['push', ...(force ? ['--force'] : []), '-u', 'origin', branch]); } export function gitHasChanges(): boolean { - const output = exec('git status --porcelain'); + const output = git(['status', '--porcelain']); return output.trim().length > 0; } diff --git a/workflows/generate-changelog/README.md b/workflows/generate-changelog/README.md index 99de30c..dbc02d7 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -10,7 +10,7 @@ CHANGELOG.md Release commits are skipped. A release bumps the project version without adding anything a changelog entry could be generated from, so Commitizen has nothing to do and exits non-zero. Both that and an empty changelog are treated as a no-op rather than a failure. -Only one run per branch happens at a time: the changelog branch is reused and force pushed, so concurrent runs are cancelled in favour of the most recent one. +Only one run per branch happens at a time. Concurrent runs are cancelled in favour of the most recent one, so that runs cannot race each other when `reuse_branch` has them sharing one branch. ## Workflow inputs From 729d4a060e66186fd54610ba0b9c846b528246eb Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 12:18:46 +0200 Subject: [PATCH 04/10] fix(create-pr): refuse a branch_name that is the base branch Reusing a branch resets and force pushes it, so a branch_name equal to base_branch_name would have force pushed the base branch. The random suffix made that unreachable before, and reuse removes it. Co-Authored-By: Claude Opus 5 (1M context) --- actions/create-pr/dist/index.js | 5 +++++ actions/create-pr/src/main.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/actions/create-pr/dist/index.js b/actions/create-pr/dist/index.js index 29d877f..fcd9c32 100644 --- a/actions/create-pr/dist/index.js +++ b/actions/create-pr/dist/index.js @@ -26002,6 +26002,11 @@ async function run() { const title = (0, import_action_utils2.getInput)("pull_request_title"); const body = (0, import_action_utils2.getInput)("pull_request_body"); const token = (0, import_action_utils2.getInput)("token"); + if (head === base) { + throw new Error( + `branch_name resolves to the base branch '${base}'. Set branch_name to a different branch.` + ); + } const octokit = getOctokit(token); const { owner, repo } = context2.repo; if (!(0, import_action_utils2.gitHasChanges)()) { diff --git a/actions/create-pr/src/main.ts b/actions/create-pr/src/main.ts index f461307..54ab1ba 100644 --- a/actions/create-pr/src/main.ts +++ b/actions/create-pr/src/main.ts @@ -31,6 +31,14 @@ export async function run(): Promise { const body = getInput('pull_request_body'); const token = getInput('token'); + // Reusing a branch resets and force pushes it, so a branch_name that + // resolves to the base branch would overwrite the base branch's history. + if (head === base) { + throw new Error( + `branch_name resolves to the base branch '${base}'. Set branch_name to a different branch.`, + ); + } + const octokit = github.getOctokit(token); const { owner, repo } = github.context.repo; From d2c3dbdb4e13c703cb19b06df8196602a907146c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 12:59:17 +0200 Subject: [PATCH 05/10] fix(action-utils): end git push options before the branch name Passing arguments directly stops a shell reading them, but git still parses a leading dash as an option: `git push origin --mirror` pushes every ref rather than a branch of that name. git checkout -B needs no marker and rejects such a name itself, so the change is limited to the push. Co-Authored-By: Claude Opus 5 (1M context) --- actions/assemble-docs/dist/index.js | 2 +- actions/create-pr/dist/index.js | 2 +- actions/extract-version/dist/index.js | 2 +- actions/submit-docs/dist/index.js | 2 +- lib/action-utils/src/git.ts | 5 ++++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/actions/assemble-docs/dist/index.js b/actions/assemble-docs/dist/index.js index dff85be..c627ba4 100644 --- a/actions/assemble-docs/dist/index.js +++ b/actions/assemble-docs/dist/index.js @@ -21280,7 +21280,7 @@ var require_dist = __commonJS({ git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch(branch, { force = false } = {}) { - git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); + git(["push", ...force ? ["--force"] : [], "-u", "origin", "--", branch]); } function gitHasChanges() { const output = git(["status", "--porcelain"]); diff --git a/actions/create-pr/dist/index.js b/actions/create-pr/dist/index.js index fcd9c32..f342148 100644 --- a/actions/create-pr/dist/index.js +++ b/actions/create-pr/dist/index.js @@ -22148,7 +22148,7 @@ var require_dist = __commonJS({ git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch2(branch, { force = false } = {}) { - git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); + git(["push", ...force ? ["--force"] : [], "-u", "origin", "--", branch]); } function gitHasChanges2() { const output = git(["status", "--porcelain"]); diff --git a/actions/extract-version/dist/index.js b/actions/extract-version/dist/index.js index 1f1b1bb..3af0d26 100644 --- a/actions/extract-version/dist/index.js +++ b/actions/extract-version/dist/index.js @@ -21280,7 +21280,7 @@ var require_dist = __commonJS({ git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch(branch, { force = false } = {}) { - git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); + git(["push", ...force ? ["--force"] : [], "-u", "origin", "--", branch]); } function gitHasChanges() { const output = git(["status", "--porcelain"]); diff --git a/actions/submit-docs/dist/index.js b/actions/submit-docs/dist/index.js index e795a5c..64d3a7e 100644 --- a/actions/submit-docs/dist/index.js +++ b/actions/submit-docs/dist/index.js @@ -22148,7 +22148,7 @@ var require_dist = __commonJS({ git(["checkout", reset ? "-B" : "-b", branch]); } function gitPushBranch2(branch, { force = false } = {}) { - git(["push", ...force ? ["--force"] : [], "-u", "origin", branch]); + git(["push", ...force ? ["--force"] : [], "-u", "origin", "--", branch]); } function gitHasChanges2() { const output = git(["status", "--porcelain"]); diff --git a/lib/action-utils/src/git.ts b/lib/action-utils/src/git.ts index a1c7693..6333e32 100644 --- a/lib/action-utils/src/git.ts +++ b/lib/action-utils/src/git.ts @@ -41,7 +41,10 @@ export function gitPushBranch( branch: string, { force = false }: GitPushBranchOptions = {}, ): void { - git(['push', ...(force ? ['--force'] : []), '-u', 'origin', branch]); + // `--` keeps a branch name that begins with a dash from being read as an + // option: `git push origin --mirror` pushes every ref rather than a branch. + // `git checkout -B` needs no equivalent, and rejects such a name itself. + git(['push', ...(force ? ['--force'] : []), '-u', 'origin', '--', branch]); } export function gitHasChanges(): boolean { From 41fa692cb64a6575fb1caa4d33d9138a5c5da13f Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 13:20:42 +0200 Subject: [PATCH 06/10] docs(action-utils): explain why only the push ends option parsing The branch is a trailing positional on push and the value of an option on checkout, which is why only one of them needs the marker. Stating the mechanism rather than the conclusion, since the previous wording read as an unexplained asymmetry. Co-Authored-By: Claude Opus 5 (1M context) --- lib/action-utils/src/git.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/action-utils/src/git.ts b/lib/action-utils/src/git.ts index 6333e32..e61006c 100644 --- a/lib/action-utils/src/git.ts +++ b/lib/action-utils/src/git.ts @@ -30,6 +30,11 @@ export function gitCheckoutBranch( branch: string, { reset = false }: GitCheckoutBranchOptions = {}, ): void { + // No end-of-options marker here, unlike the push below: `-b` and `-B` take + // their value positionally, so the next argument is consumed as the branch + // name and never parsed as an option, even when it is a real one such as + // `-q`. A dash-leading name then fails git's own ref format check instead of + // changing what the command does. git(['checkout', reset ? '-B' : '-b', branch]); } @@ -41,9 +46,10 @@ export function gitPushBranch( branch: string, { force = false }: GitPushBranchOptions = {}, ): void { - // `--` keeps a branch name that begins with a dash from being read as an - // option: `git push origin --mirror` pushes every ref rather than a branch. - // `git checkout -B` needs no equivalent, and rejects such a name itself. + // The branch is a trailing positional here rather than the value of an + // option, so it does need `--` to end option parsing: without it, + // `git push origin --mirror` pushes every ref rather than a branch of that + // name. git(['push', ...(force ? ['--force'] : []), '-u', 'origin', '--', branch]); } From e6ccdf0f4c9a949de4c017ad142f01d2941e35aa Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 13:45:36 +0200 Subject: [PATCH 07/10] fix(generate-changelog): key concurrency on the branch being pushed The group used the triggering ref, but the contended resource is the reused head branch. Two refs invoking the workflow with the same branch_name landed in separate groups and could still force push it concurrently, which is the race the group was added to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-changelog.yaml | 10 ++++++---- workflows/generate-changelog/README.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/generate-changelog.yaml b/.github/workflows/generate-changelog.yaml index 778f80f..b2218af 100644 --- a/.github/workflows/generate-changelog.yaml +++ b/.github/workflows/generate-changelog.yaml @@ -85,11 +85,13 @@ on: token_private_key: description: 'A GitHub App private key used to generate an access token to create a pull request.' -# The changelog branch is reused and force pushed, so two runs racing on the same -# branch would leave the pull request holding whichever finished last rather than -# the newest state of the base branch. +# The changelog branch is reused and force pushed, so two runs racing on it would +# leave the pull request holding whichever finished last rather than the newest +# state of the base branch. The branch being pushed is what has to be serialised, +# so it is the key: a triggering ref would put runs that share one branch_name +# into separate groups and let them race anyway. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: generate-changelog-${{ inputs.branch_name }} cancel-in-progress: true jobs: diff --git a/workflows/generate-changelog/README.md b/workflows/generate-changelog/README.md index dbc02d7..20280c2 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -10,7 +10,7 @@ CHANGELOG.md Release commits are skipped. A release bumps the project version without adding anything a changelog entry could be generated from, so Commitizen has nothing to do and exits non-zero. Both that and an empty changelog are treated as a no-op rather than a failure. -Only one run per branch happens at a time. Concurrent runs are cancelled in favour of the most recent one, so that runs cannot race each other when `reuse_branch` has them sharing one branch. +This workflow sets its own `concurrency`, keyed on `branch_name`, so callers do not need to add one for this purpose. Concurrent runs that would push the same branch are cancelled in favour of the most recent one, which is what keeps them from racing when `reuse_branch` has them sharing one branch. ## Workflow inputs From ecb8083df3e54fc75068a4545bcaf180e41f514f Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 13:59:30 +0200 Subject: [PATCH 08/10] docs: correct the documented input defaults The reusable workflow's input table was copied from the create-pr action and never updated, so branch_name, the pull request title and body, and the commit message all documented the action's defaults rather than the workflow's. A caller relying on them would have got a different branch and different commit text than described. The action's token default was also written as ${{ GITHUB_TOKEN }}, which is not valid workflow syntax. Co-Authored-By: Claude Opus 5 (1M context) --- actions/create-pr/README.md | 4 ++-- workflows/generate-changelog/README.md | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/actions/create-pr/README.md b/actions/create-pr/README.md index 77d5fb9..846858a 100644 --- a/actions/create-pr/README.md +++ b/actions/create-pr/README.md @@ -2,7 +2,7 @@ This action creates a pull request from a branch to a target branch. -Any pull requests created as a result of actions that use the default token (`${{ GITHUB_TOKEN }}`) will not trigger any pipeline events. To ensure that any pipelines are triggered, a different token must be used. +Any pull requests created as a result of actions that use the default token (`${{ github.token }}`) will not trigger any pipeline events. To ensure that any pipelines are triggered, a different token must be used. Any files that will be changed and committed to the pull request must be listed in the `.github/repo_policies/BOT_APPROVED_FILES` file of the repository. For example: @@ -24,7 +24,7 @@ packages/some-package/package.json | `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | | `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | | `commit_message` | The message of the commit. | `'chore: automated by GitHub actions'` | -| `token` | Access token to manage the pull request. | `${{ GITHUB_TOKEN }}` | +| `token` | Access token to manage the pull request. | `${{ github.token }}` | ## Action outputs diff --git a/workflows/generate-changelog/README.md b/workflows/generate-changelog/README.md index 20280c2..fa18686 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -14,18 +14,18 @@ This workflow sets its own `concurrency`, keyed on `branch_name`, so callers do ## Workflow inputs -| Input | Description | Default | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `branch_name` | The name of the branch to create the pull request from. | `'patch'` | -| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | -| `reuse_branch` | Keep a single self-updating changelog pull request instead of opening a new one on every run. | `true` | -| `pull_request_title` | The title of the pull request. | `'chore: automated by GitHub actions'` | -| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action.'` | -| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | -| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | -| `commit_message` | The message of the commit. | `'chore: automated by GitHub actions'` | -| `release_commit_pattern` | Skip changelog generation when the head commit subject matches this extended regular expression. Set to an empty string to disable the check. | `'^chore:[[:space:]]release([[:space:]]\|$)'` | -| `token_app_id` | A GitHub App ID used to generate an access token to create a pull request. | _required_ | +| Input | Description | Default | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `branch_name` | The name of the branch to create the pull request from. | `'chore/generate-changelog'` | +| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | +| `reuse_branch` | Keep a single self-updating changelog pull request instead of opening a new one on every run. | `true` | +| `pull_request_title` | The title of the pull request. | `'chore: generate changelog'` | +| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action to generate changelogs.'` | +| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | +| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | +| `commit_message` | The message of the commit. | `'chore: generate changelog'` | +| `release_commit_pattern` | Skip changelog generation when the head commit subject matches this extended regular expression. Set to an empty string to disable the check. | `'^chore:[[:space:]]release([[:space:]]\|$)'` | +| `token_app_id` | A GitHub App ID used to generate an access token to create a pull request. | _required_ | ## Workflow secrets From c2fe97a8a379cdfabced2b6805b6b7a796641964 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 14:13:25 +0200 Subject: [PATCH 09/10] docs(create-pr): say that reuse_branch force pushes the branch The input described updating a pull request without mentioning that the branch is reset and force pushed first, which is the operationally significant part and the reason it suits only a branch owned by automation. Stated in the input description and in both READMEs, the reusable workflow included since it enables this by default. Co-Authored-By: Claude Opus 5 (1M context) --- actions/create-pr/README.md | 22 +++++++++++----------- actions/create-pr/action.yaml | 2 +- workflows/generate-changelog/README.md | 24 ++++++++++++------------ 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/actions/create-pr/README.md b/actions/create-pr/README.md index 846858a..ef55c36 100644 --- a/actions/create-pr/README.md +++ b/actions/create-pr/README.md @@ -14,17 +14,17 @@ packages/some-package/package.json ## Action inputs -| Input | Description | Default | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `branch_name` | The name of the branch to create the pull request from. | `'patch'` | -| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | -| `reuse_branch` | 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. | `'false'` | -| `pull_request_title` | The title of the pull request. | `'chore: automated by GitHub actions'` | -| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action.'` | -| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | -| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | -| `commit_message` | The message of the commit. | `'chore: automated by GitHub actions'` | -| `token` | Access token to manage the pull request. | `${{ github.token }}` | +| Input | Description | Default | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| `branch_name` | The name of the branch to create the pull request from. | `'patch'` | +| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | +| `reuse_branch` | Reuse branch_name as-is instead of creating a new randomly suffixed branch on every run. The branch is reset to the current commit and force pushed, and the pull request already open for it is updated rather than duplicated; when there is nothing left to propose that pull request is closed. Enable this only for a branch owned by automation, since whatever is already on it is overwritten. | `'false'` | +| `pull_request_title` | The title of the pull request. | `'chore: automated by GitHub actions'` | +| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action.'` | +| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | +| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | +| `commit_message` | The message of the commit. | `'chore: automated by GitHub actions'` | +| `token` | Access token to manage the pull request. | `${{ github.token }}` | ## Action outputs diff --git a/actions/create-pr/action.yaml b/actions/create-pr/action.yaml index 8b014b4..e3e3e24 100644 --- a/actions/create-pr/action.yaml +++ b/actions/create-pr/action.yaml @@ -12,7 +12,7 @@ inputs: 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.' + description: 'Reuse branch_name as-is instead of creating a new randomly suffixed branch on every run. The branch is reset to the current commit and force pushed, and the pull request already open for it is updated rather than duplicated; when there is nothing left to propose that pull request is closed. Enable this only for a branch owned by automation, since whatever is already on it is overwritten.' required: false default: 'false' diff --git a/workflows/generate-changelog/README.md b/workflows/generate-changelog/README.md index fa18686..0829f89 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -14,18 +14,18 @@ This workflow sets its own `concurrency`, keyed on `branch_name`, so callers do ## Workflow inputs -| Input | Description | Default | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `branch_name` | The name of the branch to create the pull request from. | `'chore/generate-changelog'` | -| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | -| `reuse_branch` | Keep a single self-updating changelog pull request instead of opening a new one on every run. | `true` | -| `pull_request_title` | The title of the pull request. | `'chore: generate changelog'` | -| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action to generate changelogs.'` | -| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | -| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | -| `commit_message` | The message of the commit. | `'chore: generate changelog'` | -| `release_commit_pattern` | Skip changelog generation when the head commit subject matches this extended regular expression. Set to an empty string to disable the check. | `'^chore:[[:space:]]release([[:space:]]\|$)'` | -| `token_app_id` | A GitHub App ID used to generate an access token to create a pull request. | _required_ | +| Input | Description | Default | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `branch_name` | The name of the branch to create the pull request from. | `'chore/generate-changelog'` | +| `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | +| `reuse_branch` | Keep a single self-updating changelog pull request instead of opening a new one on every run. The changelog branch is reset and force pushed each run, so it must not hold anything but generated changelog commits. | `true` | +| `pull_request_title` | The title of the pull request. | `'chore: generate changelog'` | +| `pull_request_body` | The body of the pull request. | `'This pull request was automatically created by a GitHub Action to generate changelogs.'` | +| `author_name` | The name of the author of the pull request and commit. | `${{ github.actor }}` | +| `author_email` | The email of the author of the pull request and commit. | `${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com` | +| `commit_message` | The message of the commit. | `'chore: generate changelog'` | +| `release_commit_pattern` | Skip changelog generation when the head commit subject matches this extended regular expression. Set to an empty string to disable the check. | `'^chore:[[:space:]]release([[:space:]]\|$)'` | +| `token_app_id` | A GitHub App ID used to generate an access token to create a pull request. | _required_ | ## Workflow secrets From c3afc0e8678d0da22e62b144277027dc367799fd Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 14:26:54 +0200 Subject: [PATCH 10/10] docs(generate-changelog): document the inputs the README omitted environment is required but was absent from both the input table and the example, so the example failed when copied. file_name, owner and repository were missing as well. The example also carried a caller concurrency block, which the workflow now provides itself keyed on the branch it pushes. Co-Authored-By: Claude Opus 5 (1M context) --- workflows/generate-changelog/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/workflows/generate-changelog/README.md b/workflows/generate-changelog/README.md index 0829f89..d2073e0 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -16,6 +16,7 @@ This workflow sets its own `concurrency`, keyed on `branch_name`, so callers do | Input | Description | Default | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `file_name` | The name of the file to write the changelog to. | `'CHANGELOG.md'` | | `branch_name` | The name of the branch to create the pull request from. | `'chore/generate-changelog'` | | `base_branch_name` | The name of the base branch to create a pull request against. | `'main'` | | `reuse_branch` | Keep a single self-updating changelog pull request instead of opening a new one on every run. The changelog branch is reset and force pushed each run, so it must not hold anything but generated changelog commits. | `true` | @@ -26,6 +27,9 @@ This workflow sets its own `concurrency`, keyed on `branch_name`, so callers do | `commit_message` | The message of the commit. | `'chore: generate changelog'` | | `release_commit_pattern` | Skip changelog generation when the head commit subject matches this extended regular expression. Set to an empty string to disable the check. | `'^chore:[[:space:]]release([[:space:]]\|$)'` | | `token_app_id` | A GitHub App ID used to generate an access token to create a pull request. | _required_ | +| `environment` | The name of the environment to use for the generate_changelog job. | _required_ | +| `owner` | The owner of the repository to create the pull request in. | `${{ github.repository_owner }}` | +| `repository` | The repository to create the pull request in. | `${{ github.event.repository.name }}` | ## Workflow secrets @@ -43,15 +47,12 @@ on: branches: - main -concurrency: - group: main-${{ github.workflow }} - cancel-in-progress: true - jobs: generate_changelog: uses: dfinity/ci-tools/.github/workflows/generate-changelog.yaml@main with: token_app_id: ${{ vars.PR_AUTOMATION_BOT_PUBLIC_APP_ID }} + environment: release secrets: token_private_key: ${{ secrets.PR_AUTOMATION_BOT_PUBLIC_PRIVATE_KEY }} ```