#!/usr/bin/env npx tsx /** * Creates a release preparation branch and opens a PR to merge changes from a * source branch into a target release branch. * * For primary releases this merges `main` into the latest `releases/vN` branch. * For backports this merges a newer release branch into an older one, handling * version number and changelog migration automatically. * * Usage: * update-release-branch.ts \ * --repository-nwo github/codeql-action \ * --source-branch main \ * --target-branch releases/v4 \ * --conductor username \ * [--is-primary-release] \ * [--dry-run] */ import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; import * as changelog from "./changelog"; import { DryRunOption, REPO_ROOT } from "./config"; import { getCurrentVersion, replaceVersionInPackageJson, withPackageJson, } from "./versions"; /** * NB: This exact commit message is used to find commits for reverting during backports. * Changing it requires a transition period where both old and new versions are supported. */ export const BACKPORT_COMMIT_MESSAGE = "Update version and changelog for v"; /** * Commit message used for rebuild commits, both those produced by this script and those produced * by the `Rebuild Action` workflow (`.github/workflows/rebuild.yml`). */ export const REBUILD_COMMIT_MESSAGE = "Rebuild"; /** The name of the git remote. */ const ORIGIN = "origin"; /** Environment variables checked (in order) for a GitHub API token. */ const TOKEN_ENVIRONMENT_VARIABLES = ["GH_TOKEN", "GITHUB_TOKEN"] as const; /** The expected prefix for release branch names. */ const RELEASE_BRANCH_PREFIX = "releases/v"; /** * Gets a GitHub API token from one of the supported environment variables. * @throws If none of the supported environment variables is set. */ export function getGitHubToken(): string { for (const name of TOKEN_ENVIRONMENT_VARIABLES) { const token = process.env[name]?.trim(); if (token) { return token; } } throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); } /** Options for {@link runCommand}. */ export interface RunCommandOptions extends DryRunOption { /** Options for `execFileSync`. */ execOptions?: ExecFileSyncOptions; } /** * Runs a command, streaming output to the console by default. * * @param command The name of the command to run. * @param args The arguments for the command. * @throws When the process exits with a non-zero exit code. * @param options How to run the command. */ export function runCommand( command: string, args: string[], options?: RunCommandOptions, ) { if (!options?.dryRun) { console.log(`Running \`${command} ${args.join(" ")}\`.`); return execFileSync(command, args, { stdio: "inherit", cwd: REPO_ROOT, ...options?.execOptions, }); } else { console.info( `[DRY RUN] Would have executed '${command} ${args.join(" ")}'`, ); return ""; } } /** Options for {@link runGit}. */ export interface RunGitOptions extends DryRunOption { /** When true, non-zero exit codes will not throw. */ allowNonZeroExitCode?: boolean; } /** * Runs `git` with the given `args` and returns the stdout. * * @param args - Arguments to pass to `git`. * @param options - Optional settings. * @throws If `git` does not exit successfully, unless * `options.allowNonZeroExitCode` is `true`. * @returns The trimmed stdout output. */ export function runGit(args: string[], options?: RunGitOptions): string { const execOptions: ExecFileSyncOptions = { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], }; try { const result = runCommand("git", args, { dryRun: options?.dryRun, execOptions, }) as string; return result.trimEnd(); } catch (error: unknown) { if (options?.allowNonZeroExitCode) { // execFileSync throws an object with `stdout` when the process exits // with a non-zero code. const execError = error as { stdout?: Buffer | string }; if (typeof execError.stdout === "string") { return execError.stdout.trimEnd(); } if (Buffer.isBuffer(execError.stdout)) { return execError.stdout.toString("utf8").trimEnd(); } return ""; } throw error; } } /** Returns true if the given branch exists on the origin remote. */ export function branchExistsOnRemote(branchName: string): boolean { const result = runGit(["ls-remote", "--heads", ORIGIN, branchName]); return result !== ""; } /** Represents commits returned by the GitHub API (relevant fields only). */ export interface GitHubCommit { sha: string; commit: { message: string; author: { date?: string } | null }; author: { login: string } | null; committer: { login: string } | null; parents: Array<{ sha: string }>; } /** Returns true if the commit is an automatic PR merge commit made by GitHub. */ export function isPrMergeCommit(commit: GitHubCommit): boolean { return commit.committer?.login === "web-flow" && commit.parents.length > 1; } /** * Gets a list of commits on the source branch that are not on the target branch, * excluding automatic PR merge commits. This will not include any commits that * exist on the target branch that aren't on the source branch. * * Uses `git log` to find the SHAs, then fetches each commit from the GitHub API * to obtain full metadata (author, parents, associated PRs, etc.). * * @param client - An authenticated GitHub API client. * @param owner - The repository owner. * @param repo - The repository name. * @param sourceBranch - The source branch name (without `origin/` prefix). * @param targetBranch - The target branch name (without `origin/` prefix). * @returns The list of non-merge commits unique to the source branch. */ export async function getCommitDifference( client: ApiClient, owner: string, repo: string, sourceBranch: string, targetBranch: string, ): Promise { const logOutput = runGit([ "log", "--pretty=format:%H", `${ORIGIN}/${targetBranch}..${ORIGIN}/${sourceBranch}`, ]); // An empty log output means no commits to merge. if (logOutput === "") { return []; } const shas = logOutput.split("\n"); // Fetch full commit objects from the API. console.info( `Fetching information about ${shas.length} commits from the API...`, ); const commits: GitHubCommit[] = []; for (const sha of shas) { const { data } = await client.rest.repos.getCommit({ owner, repo, ref: sha, }); commits.push(data as GitHubCommit); } // Filter out automatic PR merge commits. return commits.filter((c) => !isPrMergeCommit(c)); } /** Truncates a commit message for display. */ export function getTruncatedCommitMessage(message: string): string { const firstLine = message.split("\n")[0]; if (firstLine.length > 60) { return `${firstLine.slice(0, 57)}...`; } return firstLine; } /** Represents pull requests associated with a commit (relevant fields only). */ export interface AssociatedPullRequest { number: number; user: { login: string; site_admin: boolean } | null; merge_commit_sha: string | null; } /** * Gets the pull request that introduced a commit to the source branch. * Returns the earliest PR by number if multiple are associated. */ export async function getPrForCommit( client: ApiClient, owner: string, repo: string, commit: GitHubCommit, ): Promise { const prs = await client.paginate( client.rest.repos.listPullRequestsAssociatedWithCommit, { owner, repo, commit_sha: commit.sha, }, ); if (prs.length === 0) { return undefined; } // Return the earliest PR by number. const sorted = [...prs].sort((a, b) => a.number - b.number); return sorted[0]; } /** * Get the login of the person who merged a pull request. * Falls back to the commit author of the merge commit. * For most cases this will be the same as the author, but for PRs opened * by external contributors getting the merger will get us the GitHub * employee who reviewed and merged the PR. */ export async function getMergerOfPr( client: ApiClient, owner: string, repo: string, pr: AssociatedPullRequest, ): Promise { if (!pr.merge_commit_sha) { return "unknown"; } const { data: commit } = await client.rest.repos.getCommit({ owner, repo, ref: pr.merge_commit_sha, }); return commit.author?.login ?? "unknown"; } /** * Returns the PR author's login if they are GitHub staff (site_admin), * otherwise undefined. */ export function getPrAuthorIfStaff( pr: AssociatedPullRequest, ): string | undefined { if (pr.user?.site_admin) { return pr.user.login; } return undefined; } /** Parameters for {@link openPr}. */ interface OpenPrParams { client: ApiClient; owner: string; repo: string; commits: GitHubCommit[]; sourceBranchShortSha: string; newBranchName: string; sourceBranch: string; targetBranch: string; conductor: string; isPrimaryRelease: boolean; conflictedFiles: string[]; dryRun: boolean; } /** * Opens a pull request from the new branch to the target branch and assigns * the conductor. */ export async function openPr(params: OpenPrParams): Promise { const { client, owner, repo, commits, sourceBranchShortSha, newBranchName, sourceBranch, targetBranch, conductor, isPrimaryRelease, conflictedFiles, dryRun, } = params; // Sort the commits into those with and without associated PRs. const pullRequests: AssociatedPullRequest[] = []; const commitsWithoutPrs: GitHubCommit[] = []; console.info(`Finding PRs for ${commits.length} commits...`); for (const commit of commits) { const pr = await getPrForCommit(client, owner, repo, commit); if (!pr) { commitsWithoutPrs.push(commit); } else if (!pullRequests.some((p) => p.number === pr.number)) { pullRequests.push(pr); } } console.log(`Found ${pullRequests.length} pull requests.`); console.log( `Found ${commitsWithoutPrs.length} commits not in a pull request.`, ); // Sort PRs by number (ascending) and commits by date. pullRequests.sort((a, b) => a.number - b.number); commitsWithoutPrs.sort((a, b) => { const dateA = a.commit.author?.date ?? ""; const dateB = b.commit.author?.date ?? ""; return dateA.localeCompare(dateB); }); // Build the PR body. const body: string[] = []; body.push(`Merging ${sourceBranchShortSha} into \`${targetBranch}\`.`); body.push(""); body.push(`Conductor for this PR is @${conductor}.`); if (pullRequests.length > 0) { body.push(""); body.push("Contains the following pull requests:"); for (const pr of pullRequests) { const displayUser = getPrAuthorIfStaff(pr) ?? (await getMergerOfPr(client, owner, repo, pr)); body.push(`- #${pr.number} (@${displayUser})`); } } if (commitsWithoutPrs.length > 0) { body.push(""); body.push("Contains the following commits not from a pull request:"); for (const commit of commitsWithoutPrs) { const authorDesc = commit.author ? ` (@${commit.author.login})` : ""; body.push( `- ${commit.sha} - ${getTruncatedCommitMessage(commit.commit.message)}${authorDesc}`, ); } } body.push(""); body.push("Please do the following:"); if (conflictedFiles.length > 0) { body.push( " - [ ] Ensure `package.json` file contains the correct version.", ); body.push( " - [ ] Add a commit to this branch to resolve the merge conflicts in the following files:", ); for (const file of conflictedFiles) { body.push(` - \`${file}\``); } body.push( ` - [ ] Rebuild the Action locally (\`npm run build\`) and push any changes to the built output in \`lib\` as a separate commit named exactly \`${REBUILD_COMMIT_MESSAGE}\`.`, ); body.push( " - [ ] Ensure another maintainer has reviewed the additional commits you added to this branch to resolve the merge conflicts.", ); } body.push( " - [ ] Ensure the CHANGELOG displays the correct version and date.", ); body.push( " - [ ] Ensure the CHANGELOG includes all relevant, user-facing changes since the last release.", ); body.push( ` - [ ] Check that there are not any unexpected commits being merged into the \`${targetBranch}\` branch.`, ); body.push( " - [ ] Ensure the docs team is aware of any documentation changes that need to be released.", ); body.push( " - [ ] Approve running the full set of PR checks if you have not pushed any changes.", ); body.push( " - [ ] Approve and merge this PR. Make sure `Create a merge commit` is selected rather than `Squash and merge` or `Rebase and merge`.", ); if (isPrimaryRelease) { body.push( " - [ ] Merge the mergeback PR that will automatically be created once this PR is merged.", ); body.push( " - [ ] Merge all backport PRs to older release branches, that will automatically be created once this PR is merged.", ); } const title = `Merge ${sourceBranch} into ${targetBranch}`; if (dryRun) { console.info(`[DRY RUN] Would create PR: "${title}" with body:`); for (const line of body) { console.info(`[DRY RUN] > ${line}`); } console.info(`[DRY RUN] and assign it to @${conductor}`); return; } // Create the pull request. const { data: pr } = await client.rest.pulls.create({ owner, repo, title, body: body.join("\n"), head: newBranchName, base: targetBranch, }); console.log(`Created PR #${pr.number}`); // Assign the conductor. await client.rest.issues.addAssignees({ owner, repo, issue_number: pr.number, assignees: [conductor], }); console.log(`Assigned PR to ${conductor}`); } interface MainOptions { dryRun: boolean; repositoryNwo: string; sourceBranch: string; targetBranch: string; isPrimaryRelease: boolean; conductor: string; } function parseCliOptions(): MainOptions { const { values } = parseArgs({ options: { "dry-run": { type: "boolean", default: false }, "repository-nwo": { type: "string" }, "source-branch": { type: "string" }, "target-branch": { type: "string" }, "is-primary-release": { type: "boolean", default: false }, conductor: { type: "string" }, }, strict: true, }); if (!values["repository-nwo"]) { throw new Error("--repository-nwo is required"); } if (!values["source-branch"]) { throw new Error("--source-branch is required"); } if (!values["target-branch"]) { throw new Error("--target-branch is required"); } if (!values["conductor"]) { throw new Error("--conductor is required"); } return { dryRun: values["dry-run"], repositoryNwo: values["repository-nwo"], sourceBranch: values["source-branch"], targetBranch: values["target-branch"], isPrimaryRelease: values["is-primary-release"] ?? false, conductor: values["conductor"], }; } /** * Rebuilds the action (npm ci + npm run build) and commits any changes. */ export function rebuildAction(options: MainOptions): void { // For backports, the only source-level change vs the source branch is the new version number, // so we just need to refresh the version embedded in `lib/`. runCommand("npm", ["ci"]); runCommand("npm", ["run", "build"]); runGit(["add", "--all"], { dryRun: options.dryRun }); // `git diff --cached --quiet` exits 0 if there are no staged changes. try { execFileSync("git", ["diff", "--cached", "--quiet"]); console.log("Rebuild produced no changes; skipping Rebuild commit."); } catch { runGit(["commit", "-m", REBUILD_COMMIT_MESSAGE], { dryRun: options.dryRun, }); console.log("Created Rebuild commit."); } } /** * Prepares the new update/backport branch. * * @param options The options we are running with. * @param newBranchName The name of the new branch to create. * @param targetBranchMajorVersion The target branch's major version. * @param version The target version. */ export async function prepareNewBranch( options: MainOptions, newBranchName: string, targetBranchMajorVersion: string, version: string, ): Promise { // The process of creating the v{Older} release can run into merge conflicts. We commit the unresolved // conflicts so a maintainer can easily resolve them (vs erroring and requiring maintainers to // reconstruct the release manually) let conflictedFiles: string[] = []; if (!options.isPrimaryRelease) { // For backports, the source branch is also a release branch. const sourceBranchMajorVersion = options.sourceBranch.replace( RELEASE_BRANCH_PREFIX, "", ); // Start from the target branch. console.log( `Creating ${newBranchName} from the ${ORIGIN}/${options.targetBranch} branch`, ); runGit( ["checkout", "-b", newBranchName, `${ORIGIN}/${options.targetBranch}`], { dryRun: options.dryRun }, ); // Revert the commit that we made as part of the last release that updated the version number and // changelog to refer to {older}.x.x variants. This avoids merge conflicts in the changelog and // package.json files when we merge in the v{latest} branch. // This commit will not exist the first time we release the v{N-1} branch from the v{N} branch, so we // use `git log --grep` to conditionally revert the commit. console.log( "Reverting the version number and changelog updates from the last release to avoid conflicts", ); const vOlderUpdateCommits = runGit([ "log", "--grep", `^${BACKPORT_COMMIT_MESSAGE}`, "--format=%H", ]) .split("\n") .filter((s) => s !== ""); if (vOlderUpdateCommits.length > 0) { // Only revert the newest commit as older ones will already have been // reverted in previous releases. console.log(` Reverting ${vOlderUpdateCommits[0]}`); runGit(["revert", vOlderUpdateCommits[0], "--no-edit"], { dryRun: options.dryRun, }); // Also revert the "Rebuild" commit, whether created by this script or // by the `Rebuild Action` workflow. const rebuildCommits = runGit([ "log", "--grep", `^${REBUILD_COMMIT_MESSAGE}$`, "--format=%H", ]) .split("\n") .filter((s) => s !== ""); const rebuildCommit = rebuildCommits[0]; console.log(` Reverting ${rebuildCommit}`); runGit(["revert", rebuildCommit, "--no-edit"], { dryRun: options.dryRun, }); } else { console.log(" Nothing to revert."); } // Merge the source branch into the release prep branch. console.log( `Merging ${ORIGIN}/${options.sourceBranch} into the release prep branch`, ); runGit(["merge", `${ORIGIN}/${options.sourceBranch}`], { allowNonZeroExitCode: true, dryRun: options.dryRun, }); conflictedFiles = runGit(["diff", "--name-only", "--diff-filter", "U"]) .split("\n") .filter((s) => s !== ""); if (conflictedFiles.length > 0) { runGit(["add", "."], { dryRun: options.dryRun, }); runGit(["commit", "--no-edit"], { dryRun: options.dryRun, }); } // Migrate the package version number. console.log(`Setting version number to '${version}' in package.json`); withPackageJson((content) => { const currentPkgVersion = getCurrentVersion(content); if (currentPkgVersion) { return { content: replaceVersionInPackageJson( currentPkgVersion, version, content, ), value: currentPkgVersion, }; } return { value: currentPkgVersion }; }, options); runGit(["add", "package.json"], { dryRun: options.dryRun, }); // Migrate the changelog notes from the source major version to the target. console.log( `Migrating changelog notes from v${sourceBranchMajorVersion} to v${targetBranchMajorVersion}`, ); changelog.withChangelog( (contents) => changelog.processChangelogForBackports( sourceBranchMajorVersion, targetBranchMajorVersion, contents, ), options, ); runGit(["add", "CHANGELOG.md"], { dryRun: options.dryRun, }); runGit(["commit", "-m", `${BACKPORT_COMMIT_MESSAGE}${version}`], { dryRun: options.dryRun, }); } else { // For a standard (primary) release, there won't be new commits on the // target branch that aren't already on the source branch, so we can just // start from the source branch. runGit( ["checkout", "-b", newBranchName, `${ORIGIN}/${options.sourceBranch}`], { dryRun: options.dryRun, }, ); console.log("Updating changelog"); changelog.withChangelog( (contents) => changelog.setVersionAndDate(version, contents), { ...options, initChangelog: true }, ); runGit(["add", "CHANGELOG.md"], { dryRun: options.dryRun, }); runGit(["commit", "-m", `Update changelog for v${version}`], { dryRun: options.dryRun, }); } // For backports, rebuild the action unless there were merge conflicts. if (!options.isPrimaryRelease) { if (conflictedFiles.length === 0) { console.log("Rebuilding the Action."); rebuildAction(options); } else { console.log( `Skipping automatic rebuild because the merge produced conflicts in: ${conflictedFiles.join(", ")}`, ); } } return conflictedFiles; } async function main(): Promise { const options = parseCliOptions(); const token = getGitHubToken(); const client = getApiClient(token); if (!options.targetBranch.startsWith(RELEASE_BRANCH_PREFIX)) { throw new Error( `Expected target branch to start with '${RELEASE_BRANCH_PREFIX}', but got '${options.targetBranch}'.`, ); } if ( !options.isPrimaryRelease && !options.sourceBranch.startsWith(RELEASE_BRANCH_PREFIX) ) { throw new Error( `Expected source branch to start with '${RELEASE_BRANCH_PREFIX}' for backports, but got '${options.sourceBranch}'.`, ); } if (!options.repositoryNwo.includes("/")) { throw new Error( `Expected repository name with owner in 'owner/repo' format, but got '${options.repositoryNwo}'`, ); } const targetBranchMajorVersion = options.targetBranch.replace( RELEASE_BRANCH_PREFIX, "", ); const currentVersion = withPackageJson((content) => { return { value: getCurrentVersion(content) }; }, options); if (!currentVersion) { throw new Error("Failed to read current version from package.json"); } const [, vMinor, vPatch] = currentVersion.split("."); const version = `${targetBranchMajorVersion}.${vMinor}.${vPatch}`; console.log( `Considering difference between ${options.sourceBranch} and ${options.targetBranch}...`, ); const sourceBranchShortSha = runGit([ "rev-parse", "--short", `${ORIGIN}/${options.sourceBranch}`, ]); console.log( `Current head of ${options.sourceBranch} is ${sourceBranchShortSha}.`, ); const [owner, repo] = options.repositoryNwo.split("/"); const commits = await getCommitDifference( client, owner, repo, options.sourceBranch, options.targetBranch, ); if (commits.length === 0) { console.log( `No commits to merge from ${options.sourceBranch} to ${options.targetBranch}.`, ); return; } // Use a distinct branch prefix to support specific PR checks on backports. const branchPrefix = options.isPrimaryRelease ? "update" : "backport"; // The branch name is based on the target version and the SHA of the source // branch head. If the branch already exists we can assume this script has // already run for this combination. const newBranchName = `${branchPrefix}-v${version}-${sourceBranchShortSha}`; console.log(`Branch name is '${newBranchName}'.`); // Check if the branch already exists. If so we can abort as this script // has already run on this combination of branches. if (branchExistsOnRemote(newBranchName)) { console.log(`Branch '${newBranchName}' already exists. Nothing to do.`); return; } // Prepare the update/backport branch. const conflictedFiles = await prepareNewBranch( options, newBranchName, targetBranchMajorVersion, version, ); // Push the new branch to the remote. console.log(`Creating branch ${newBranchName}.`); runGit(["push", ORIGIN, newBranchName], { dryRun: options.dryRun }); // Open a PR to merge the new branch into the target branch. await openPr({ client, owner, repo, commits, sourceBranchShortSha, newBranchName, sourceBranch: options.sourceBranch, targetBranch: options.targetBranch, conductor: options.conductor, isPrimaryRelease: options.isPrimaryRelease, conflictedFiles, dryRun: options.dryRun, }); } // Only call `main` if this script was run directly. if (require.main === module) { void main(); }