diff --git a/.github/actions/release-branches/action.yml b/.github/actions/release-branches/action.yml index 26be726205..7734411c73 100644 --- a/.github/actions/release-branches/action.yml +++ b/.github/actions/release-branches/action.yml @@ -22,7 +22,6 @@ runs: MAJOR_VERSION: ${{ inputs.major_version }} LATEST_TAG: ${{ inputs.latest_tag }} run: | - npm ci npx tsx ./pr-checks/release-branches.ts \ --major-version "$MAJOR_VERSION" \ --latest-tag "$LATEST_TAG" diff --git a/.github/actions/release-initialise/action.yml b/.github/actions/release-initialise/action.yml index 057d5a5b6d..be16f03950 100644 --- a/.github/actions/release-initialise/action.yml +++ b/.github/actions/release-initialise/action.yml @@ -21,6 +21,10 @@ runs: node-version: 24 cache: 'npm' + - name: Install JavaScript dependencies + shell: bash + run: npm ci + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 11e97eeca0..f690c3847e 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -69,7 +69,7 @@ jobs: run: | echo SOURCE_BRANCH=${REF_NAME} echo TARGET_BRANCH=releases/${MAJOR_VERSION} - python .github/update-release-branch.py \ + npx tsx ./pr-checks/update-release-branch.ts \ --repository-nwo ${{ github.repository }} \ --source-branch '${{ env.REF_NAME }}' \ --target-branch 'releases/${{ env.MAJOR_VERSION }}' \ @@ -113,7 +113,7 @@ jobs: run: | echo SOURCE_BRANCH=${SOURCE_BRANCH} echo TARGET_BRANCH=${TARGET_BRANCH} - python .github/update-release-branch.py \ + npx tsx ./pr-checks/update-release-branch.ts \ --repository-nwo ${{ github.repository }} \ --source-branch ${SOURCE_BRANCH} \ --target-branch ${TARGET_BRANCH} \ diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts new file mode 100755 index 0000000000..3eddc14591 --- /dev/null +++ b/pr-checks/changelog.test.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env npx tsx + +/** + * Tests for `changelog.ts`. + */ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + EMPTY_CHANGELOG, + getReleaseDateString, + processChangelogForBackports, + setVersionAndDate, +} from "./changelog"; + +const testDate = new Date(2026, 7, 14); + +describe("getReleaseDateString", async () => { + await it("formats dates as expected", async () => { + assert.equal(getReleaseDateString(testDate), "14 Aug 2026"); + }); +}); + +const emptyChangelogExpected = `# CodeQL Action Changelog + +## 9.99.9 - 14 Aug 2026 + +No user facing changes. + +`; + +describe("setVersionAndDate", async () => { + await it("replaces the placeholder", async () => { + const result = setVersionAndDate("9.99.9", EMPTY_CHANGELOG, testDate); + assert.equal(result, emptyChangelogExpected); + }); +}); + +const testChangelog = `# CodeQL Action Changelog + +## 4.12.3 - 14 Aug 2026 + +No user facing changes. +`; + +const testChangelogResult: string = `# CodeQL Action Changelog + +## 3.12.3 - 14 Aug 2026 + +No user facing changes. +`; + +describe("processChangelogForBackports", async () => { + await it("replaces major versions", async () => { + const result = processChangelogForBackports("4", "3", testChangelog); + + assert.deepEqual(result.split("\n"), testChangelogResult.split("\n")); + }); +}); diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts new file mode 100644 index 0000000000..49011b0361 --- /dev/null +++ b/pr-checks/changelog.ts @@ -0,0 +1,135 @@ +import * as fs from "node:fs"; + +import { CHANGELOG_FILE, DryRunOption } from "./config"; + +/** Placeholder changelog content for a new release. */ +export const EMPTY_CHANGELOG = `# CodeQL Action Changelog + +## [UNRELEASED] + +No user facing changes. + +`; + +/** Returns `date` formatted as `DD Mon YYYY`. */ +export function getReleaseDateString(today: Date = new Date()): string { + return today.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export interface OpenChangelogOptions { + initChangelog?: boolean; +} + +export function withChangelog( + transformer: (contents: string) => string, + options: DryRunOption & OpenChangelogOptions, +): void { + let content: string; + + if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { + content = EMPTY_CHANGELOG; + } else { + content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + } + + if (!options.dryRun) { + fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); + } else { + console.info(`[DRY RUN] Would have written updated changelog.`); + } +} + +/** + * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version + * and today's date. + */ +export function setVersionAndDate( + version: string, + content: string, + date: Date = new Date(), +): string { + const versionAndDate = `${version} - ${getReleaseDateString(date)}`; + return content.replace("[UNRELEASED]", versionAndDate); +} + +/** + * Processes changelog entries for a backport, converting version references + * from the source major version to the target major version and filtering + * entries that only apply to newer versions. + */ +export function processChangelogForBackports( + sourceBranchMajorVersion: string, + targetBranchMajorVersion: string, + content: string, +): string { + const lines = content.split("\n"); + + // Changelog entries can use the following format to indicate + // that they only apply to newer versions + const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; + + let output = ""; + let i = 0; + + // Copy lines until we find the first section heading. + let foundFirstSection = false; + while (!foundFirstSection && i < lines.length) { + let line = lines[i]; + if (line.startsWith("## ")) { + line = line.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + foundFirstSection = true; + } + output += `${line}\n`; + i++; + } + + if (!foundFirstSection) { + throw new Error("Could not find any change sections in CHANGELOG.md"); + } + + // Process remaining lines. + // `foundContent` tracks whether we hit two headings in a row + let foundContent = false; + output += "\n"; + + while (i < lines.length) { + let line = lines[i]; + i++; + + // Filter out changelog entries that only apply to newer versions. + const match = someVersionsOnlyRegex.exec(line); + if (match) { + if ( + Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) + ) { + continue; + } + } + + if (line.startsWith("## ")) { + line = line.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + if (!foundContent) { + output += "No user facing changes.\n"; + } + foundContent = false; + output += `\n${line}\n\n`; + } else { + if (line.trim() !== "") { + foundContent = true; + output += `${line}\n`; + } + } + } + + return output; +} diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 75cd0a1515..94d34a931d 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -12,6 +12,12 @@ export const REPO_ROOT = path.join(PR_CHECKS_DIR, ".."); /** The path of the file configuring which checks shouldn't be required. */ export const PR_CHECK_EXCLUDED_FILE = path.join(PR_CHECKS_DIR, "excluded.yml"); +/** The path of the main `package.json`. */ +export const PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); + +/** The path of the changelog. */ +export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md"); + /** The path to the esbuild metadata file. */ export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); @@ -30,3 +36,9 @@ export const API_COMPATIBILITY_FILE = path.join( SOURCE_ROOT, "api-compatibility.json", ); + +/** A common interface for operations that support dry runs. */ +export interface DryRunOption { + /** A value indicating whether to perform operations with side effects. */ + dryRun?: boolean; +} diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts new file mode 100755 index 0000000000..56f3aef31c --- /dev/null +++ b/pr-checks/update-release-branch.ts @@ -0,0 +1,838 @@ +#!/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 { data: prs } = + await 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(); +} diff --git a/pr-checks/versions.test.ts b/pr-checks/versions.test.ts new file mode 100755 index 0000000000..6697710f83 --- /dev/null +++ b/pr-checks/versions.test.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env npx tsx + +/** + * Tests for `versions.ts`. + */ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { getCurrentVersion, replaceVersionInPackageJson } from "./versions"; + +describe("getCurrentVersion", async () => { + await it("reads versions", async () => { + const result = getCurrentVersion(`{ "version": "1.23.4" }`); + assert.deepEqual(result, "1.23.4"); + }); +}); + +const packageJsonContents = `{ + "name": "codeql", + "version": "1.23.4" +} +`; + +const packageJsonContentsExpected = `{ + "name": "codeql", + "version": "2.23.4" +} +`; + +describe("replaceVersionInPackageJson", async () => { + await it("replaces versions", async () => { + const result = replaceVersionInPackageJson( + "1.23.4", + "2.23.4", + packageJsonContents, + ); + assert.deepEqual( + result.split("\n"), + packageJsonContentsExpected.split("\n"), + ); + assert.deepEqual(JSON.parse(result), { name: "codeql", version: "2.23.4" }); + }); +}); diff --git a/pr-checks/versions.ts b/pr-checks/versions.ts new file mode 100644 index 0000000000..4abc7faa17 --- /dev/null +++ b/pr-checks/versions.ts @@ -0,0 +1,54 @@ +import * as fs from "node:fs"; + +import { DryRunOption, PACKAGE_JSON } from "./config"; + +export function withPackageJson( + transformer: (content: string) => { value: T; content?: string }, + options: DryRunOption, +): T { + const content = fs.readFileSync(PACKAGE_JSON, "utf8"); + const result = transformer(content); + + if (result.content !== undefined) { + if (!options.dryRun) { + fs.writeFileSync(PACKAGE_JSON, result.content, "utf8"); + } else { + console.info(`[DRY RUN] Would have written an updated package.json`); + } + } + + return result.value; +} + +/** Reads the current version from `package.json`. */ +export function getCurrentVersion(content: string): string | undefined { + const pkg: { version: string } = JSON.parse(content); + return pkg.version; +} + +/** + * Replaces the version in `package.json` textually. Only updates the version + * field that immediately follows the `"name": "codeql"` line. + * `npm version` doesn't always work because of merge conflicts, so we + * replace the version in package.json textually. + */ +export function replaceVersionInPackageJson( + prevVersion: string, + newVersion: string, + content: string, +): string { + const lines = content.split("\n"); + let prevLineIsCodeql = false; + const output: string[] = []; + + for (const line of lines) { + if (prevLineIsCodeql && line.includes(`"version": "${prevVersion}"`)) { + output.push(line.replace(prevVersion, newVersion)); + } else { + output.push(line); + } + prevLineIsCodeql = line.includes('"name": "codeql",'); + } + + return output.join("\n"); +}