diff --git a/.github/workflows/generate-changelog.yaml b/.github/workflows/generate-changelog.yaml index 5da7429..b2218af 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.' @@ -80,6 +85,15 @@ 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 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: generate-changelog-${{ inputs.branch_name }} + cancel-in-progress: true + jobs: generate_changelog: runs-on: ubuntu-latest @@ -153,6 +167,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..c627ba4 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) { - exec2(`git checkout -b ${branch}`); + function gitCheckoutBranch(branch, { reset = false } = {}) { + git(["checkout", reset ? "-B" : "-b", branch]); } - function gitPushBranch(branch) { - exec2(`git push -u origin ${branch}`); + function gitPushBranch(branch, { force = false } = {}) { + 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 a2f0c47..ef55c36 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: @@ -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 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 -- `pull_request_number`: The number of the created pull request. -- `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_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` 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/action.yaml b/actions/create-pr/action.yaml index 113f198..e3e3e24 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 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' pull_request_title: description: 'The title of the pull request.' @@ -42,9 +46,11 @@ 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: + 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..f342148 100644 --- a/actions/create-pr/dist/index.js +++ b/actions/create-pr/dist/index.js @@ -22081,10 +22081,11 @@ var require_dist = __commonJS({ absolutePath: () => absolutePath, deleteFile: () => deleteFile, exec: () => exec2, + execFile: () => execFile, generateRandomSuffix: () => generateRandomSuffix2, getInput: () => getInput22, getNumberInput: () => getNumberInput, - getOptInput: () => getOptInput, + getOptInput: () => getOptInput2, gitAdd: () => gitAdd2, gitCheckoutBranch: () => gitCheckoutBranch2, gitCommit: () => gitCommit2, @@ -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,29 +22133,32 @@ 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) { - exec2(`git checkout -b ${branch}`); + function gitCheckoutBranch2(branch, { reset = false } = {}) { + git(["checkout", reset ? "-B" : "-b", branch]); } - function gitPushBranch2(branch) { - exec2(`git push -u origin ${branch}`); + function gitPushBranch2(branch, { force = false } = {}) { + 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))); 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 +25940,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 +25979,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,12 +25994,19 @@ 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"); 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)()) { @@ -25968,14 +26014,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 +26061,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..54ab1ba 100644 --- a/actions/create-pr/src/main.ts +++ b/actions/create-pr/src/main.ts @@ -3,22 +3,42 @@ 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'); 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; @@ -27,6 +47,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 +79,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 +109,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..3af0d26 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) { - exec3(`git checkout -b ${branch}`); + function gitCheckoutBranch(branch, { reset = false } = {}) { + git(["checkout", reset ? "-B" : "-b", branch]); } - function gitPushBranch(branch) { - exec3(`git push -u origin ${branch}`); + function gitPushBranch(branch, { force = false } = {}) { + 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 81938e3..64d3a7e 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) { - exec2(`git checkout -b ${branch}`); + function gitCheckoutBranch(branch, { reset = false } = {}) { + git(["checkout", reset ? "-B" : "-b", branch]); } - function gitPushBranch2(branch) { - exec2(`git push -u origin ${branch}`); + function gitPushBranch2(branch, { force = false } = {}) { + 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 2cc52d6..e61006c 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,21 +13,48 @@ 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 { + /** + * 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 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]); } -export function gitCheckoutBranch(branch: string): void { - exec(`git checkout -b ${branch}`); +export interface GitPushBranchOptions { + force?: boolean; } -export function gitPushBranch(branch: string): void { - exec(`git push -u origin ${branch}`); +export function gitPushBranch( + branch: string, + { force = false }: GitPushBranchOptions = {}, +): void { + // 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]); } 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 23509dd..d2073e0 100644 --- a/workflows/generate-changelog/README.md +++ b/workflows/generate-changelog/README.md @@ -10,19 +10,26 @@ 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. +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 -| 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'` | -| `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 | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `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` | +| `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_ | +| `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 @@ -40,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 }} ```