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/update-release-branch.py b/.github/update-release-branch.py deleted file mode 100644 index 2635126827..0000000000 --- a/.github/update-release-branch.py +++ /dev/null @@ -1,474 +0,0 @@ -import argparse -import datetime -import fileinput -import re -from github import Github -import json -import os -import subprocess - -EMPTY_CHANGELOG = """# CodeQL Action Changelog - -## [UNRELEASED] - -No user facing changes. - -""" - -# 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. -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`). -REBUILD_COMMIT_MESSAGE = 'Rebuild' - -# Name of the remote -ORIGIN = 'origin' - -# Environment variables to check for a GitHub API token. -TOKEN_ENVIRONMENT_VARIABLES = ('GH_TOKEN', 'GITHUB_TOKEN') - -# Gets a GitHub API token from one of the supported environment variables. -def get_github_token(): - for variable_name in TOKEN_ENVIRONMENT_VARIABLES: - token = os.environ.get(variable_name, '').strip() - if token: - return token - raise Exception('Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN.') - -# Runs git with the given args and returns the stdout. -# Raises an error if git does not exit successfully (unless passed -# allow_non_zero_exit_code=True). -def run_git(*args, allow_non_zero_exit_code=False): - cmd = ['git', *args] - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if not allow_non_zero_exit_code and p.returncode != 0: - raise Exception(f'Call to {" ".join(cmd)} exited with code {p.returncode} stderr: {p.stderr.decode("ascii")}.') - return p.stdout.decode('ascii') - -# Runs the given command, streaming output to the console. -# Raises an error if the command does not exit successfully. -def run_command(*args): - cmd = list(args) - print(f'Running `{" ".join(cmd)}`.') - subprocess.run(cmd, check=True) - -# Rebuilds the action and commits any changes. -def rebuild_action(): - # 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/`. - run_command('npm', 'ci') - run_command('npm', 'run', 'build') - - run_git('add', '--all') - # `git diff --cached --quiet` exits 0 if there are no staged changes, 1 if there are. - if subprocess.run(['git', 'diff', '--cached', '--quiet']).returncode == 0: - print('Rebuild produced no changes; skipping Rebuild commit.') - else: - run_git('commit', '-m', REBUILD_COMMIT_MESSAGE) - print('Created Rebuild commit.') - -# Returns true if the given branch exists on the origin remote -def branch_exists_on_remote(branch_name): - return run_git('ls-remote', '--heads', ORIGIN, branch_name).strip() != '' - -# Opens a PR from the given branch to the target branch -def open_pr( - repo, all_commits, source_branch_short_sha, new_branch_name, source_branch, target_branch, - conductor, is_primary_release, conflicted_files): - # Sort the commits into the pull requests that introduced them, - # and any commits that don't have a pull request - pull_requests = [] - commits_without_pull_requests = [] - for commit in all_commits: - pr = get_pr_for_commit(commit) - - if pr is None: - commits_without_pull_requests.append(commit) - elif not any(p for p in pull_requests if p.number == pr.number): - pull_requests.append(pr) - - print(f'Found {len(pull_requests)} pull requests.') - print(f'Found {len(commits_without_pull_requests)} commits not in a pull request.') - - # Sort PRs and commits by age - pull_requests = sorted(pull_requests, key=lambda pr: pr.number) - commits_without_pull_requests = sorted(commits_without_pull_requests, key=lambda c: c.commit.author.date) - - # Start constructing the body text - body = [] - body.append(f'Merging {source_branch_short_sha} into `{target_branch}`.') - - body.append('') - body.append(f'Conductor for this PR is @{conductor}.') - - # List all PRs merged - if len(pull_requests) > 0: - body.append('') - body.append('Contains the following pull requests:') - for pr in pull_requests: - # Use PR author if they are GitHub staff, otherwise use the merger - display_user = get_pr_author_if_staff(pr) or get_merger_of_pr(repo, pr) - body.append(f'- #{pr.number} (@{display_user})') - - # List all commits not part of a PR - if len(commits_without_pull_requests) > 0: - body.append('') - body.append('Contains the following commits not from a pull request:') - for commit in commits_without_pull_requests: - author_description = f' (@{commit.author.login})' if commit.author is not None else '' - body.append(f'- {commit.sha} - {get_truncated_commit_message(commit)}{author_description}') - - body.append('') - body.append('Please do the following:') - if len(conflicted_files) > 0: - body.append(' - [ ] Ensure `package.json` file contains the correct version.') - body.append(' - [ ] Add a commit to this branch to resolve the merge conflicts ' + - 'in the following files:') - body.extend([f' - `{file}`' for file in conflicted_files]) - body.append(' - [ ] Rebuild the Action locally (`npm run build`) and push any changes to the ' + - f'built output in `lib` as a separate commit named exactly `{REBUILD_COMMIT_MESSAGE}`.') - body.append(' - [ ] Ensure another maintainer has reviewed the additional commits you added to this ' + - 'branch to resolve the merge conflicts.') - body.append(' - [ ] Ensure the CHANGELOG displays the correct version and date.') - body.append(' - [ ] Ensure the CHANGELOG includes all relevant, user-facing changes since the last release.') - body.append(f' - [ ] Check that there are not any unexpected commits being merged into the `{target_branch}` branch.') - body.append(' - [ ] Ensure the docs team is aware of any documentation changes that need to be released.') - - body.append(' - [ ] Approve running the full set of PR checks if you have not pushed any changes.') - body.append(' - [ ] Approve and merge this PR. Make sure `Create a merge commit` is selected rather than `Squash and merge` or `Rebase and merge`.') - - if is_primary_release: - body.append(' - [ ] Merge the mergeback PR that will automatically be created once this PR is merged.') - body.append(' - [ ] Merge all backport PRs to older release branches, that will automatically be created once this PR is merged.') - - title = f'Merge {source_branch} into {target_branch}' - - # Create the pull request - pr = repo.create_pull(title=title, body='\n'.join(body), head=new_branch_name, base=target_branch) - print(f'Created PR #{str(pr.number)}') - - # Assign the conductor - pr.add_to_assignees(conductor) - print(f'Assigned PR to {conductor}') - -# Gets a list of the SHAs of all commits that have happened on the source branch -# since the last release to the target branch. -# This will not include any commits that exist on the target branch -# that aren't on the source branch. -def get_commit_difference(repo, source_branch, target_branch): - # Passing split nothing means that the empty string splits to nothing: compare `''.split() == []` - # to `''.split('\n') == ['']`. - commits = run_git('log', '--pretty=format:%H', f'{ORIGIN}/{target_branch}..{ORIGIN}/{source_branch}').strip().split() - - # Convert to full-fledged commit objects - commits = [repo.get_commit(c) for c in commits] - - # Filter out merge commits for PRs - return list(filter(lambda c: not is_pr_merge_commit(c), commits)) - -# Is the given commit the automatic merge commit from when merging a PR -def is_pr_merge_commit(commit): - return commit.committer is not None and commit.committer.login == 'web-flow' and len(commit.parents) > 1 - -# Gets a copy of the commit message that should display nicely -def get_truncated_commit_message(commit): - message = commit.commit.message.split('\n')[0] - if len(message) > 60: - return f'{message[:57]}...' - else: - return message - -# Converts a commit into the PR that introduced it to the source branch. -# Returns the PR object, or None if no PR could be found. -def get_pr_for_commit(commit): - prs = commit.get_pulls() - - if prs.totalCount > 0: - # In the case that there are multiple PRs, return the earliest one - prs = list(prs) - sorted_prs = sorted(prs, key=lambda pr: int(pr.number)) - return sorted_prs[0] - else: - return None - -# Get the person who merged the pull request. -# 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. -def get_merger_of_pr(repo, pr): - return repo.get_commit(pr.merge_commit_sha).author.login - -# Get the PR author if they are GitHub staff, otherwise None. -def get_pr_author_if_staff(pr): - if pr.user is None: - return None - if getattr(pr.user, 'site_admin', False): - return pr.user.login - return None - -def get_current_version(): - with open('package.json', 'r') as f: - return json.load(f)['version'] - -# `npm version` doesn't always work because of merge conflicts, so we -# replace the version in package.json textually. -def replace_version_package_json(prev_version, new_version): - prev_line_is_codeql = False - for line in fileinput.input('package.json', inplace = True, encoding='utf-8'): - if prev_line_is_codeql and f'\"version\": \"{prev_version}\"' in line: - print(line.replace(prev_version, new_version), end='') - else: - prev_line_is_codeql = False - print(line, end='') - if '\"name\": \"codeql\",' in line: - prev_line_is_codeql = True - -def get_today_string(): - today = datetime.datetime.today() - return '{:%d %b %Y}'.format(today) - -def process_changelog_for_backports(source_branch_major_version, target_branch_major_version): - - # changelog entries can use the following format to indicate - # that they only apply to newer versions - some_versions_only_regex = re.compile(r'\[v(\d+)\+ only\]') - - output = '' - - with open('CHANGELOG.md', 'r') as f: - - # until we find the first section, just duplicate all lines - found_first_section = False - while not found_first_section: - line = f.readline() - if not line: - raise Exception('Could not find any change sections in CHANGELOG.md') # EOF - - if line.startswith('## '): - line = line.replace(f'## {source_branch_major_version}', f'## {target_branch_major_version}') - found_first_section = True - - output += line - - # found_content tracks whether we hit two headings in a row - found_content = False - output += '\n' - while True: - line = f.readline() - if not line: - break # EOF - line = line.rstrip('\n') - - # filter out changenote entries that apply only to newer versions - match = some_versions_only_regex.search(line) - if match: - if int(target_branch_major_version) < int(match.group(1)): - continue - - if line.startswith('## '): - line = line.replace(f'## {source_branch_major_version}', f'## {target_branch_major_version}') - if found_content == False: - # we have found two headings in a row, so we need to add the placeholder message. - output += 'No user facing changes.\n' - found_content = False - output += f'\n{line}\n\n' - else: - if line.strip() != '': - found_content = True - # we use the original line here, rather than the stripped version - # so that we preserve indentation - output += line + '\n' - - with open('CHANGELOG.md', 'w') as f: - f.write(output) - -def update_changelog(version): - if (os.path.exists('CHANGELOG.md')): - content = '' - with open('CHANGELOG.md', 'r') as f: - content = f.read() - else: - content = EMPTY_CHANGELOG - - newContent = content.replace('[UNRELEASED]', f'{version} - {get_today_string()}', 1) - - with open('CHANGELOG.md', 'w') as f: - f.write(newContent) - - -def main(): - parser = argparse.ArgumentParser('update-release-branch.py') - - parser.add_argument( - '--repository-nwo', - type=str, - required=True, - help='The nwo of the repository, for example github/codeql-action.' - ) - parser.add_argument( - '--source-branch', - type=str, - required=True, - help='Source branch for release branch update.' - ) - parser.add_argument( - '--target-branch', - type=str, - required=True, - help='Target branch for release branch update.' - ) - parser.add_argument( - '--is-primary-release', - action='store_true', - default=False, - help='Whether this update is the primary release for the current major version.' - ) - parser.add_argument( - '--conductor', - type=str, - required=True, - help='The GitHub handle of the person who is conducting the release process.' - ) - - args = parser.parse_args() - - source_branch = args.source_branch - target_branch = args.target_branch - is_primary_release = args.is_primary_release - - repo = Github(get_github_token()).get_repo(args.repository_nwo) - - # the target branch will be of the form releases/vN, where N is the major version number - target_branch_major_version = target_branch.strip('releases/v') - - # split version into major, minor, patch - _, v_minor, v_patch = get_current_version().split('.') - - version = f"{target_branch_major_version}.{v_minor}.{v_patch}" - - # Print what we intend to go - print(f'Considering difference between {source_branch} and {target_branch}...') - source_branch_short_sha = run_git('rev-parse', '--short', f'{ORIGIN}/{source_branch}').strip() - print(f'Current head of {source_branch} is {source_branch_short_sha}.') - - # See if there are any commits to merge in - commits = get_commit_difference(repo=repo, source_branch=source_branch, target_branch=target_branch) - if len(commits) == 0: - print(f'No commits to merge from {source_branch} to {target_branch}.') - return - - # define distinct prefix in order to support specific pr checks on backports - branch_prefix = 'update' if is_primary_release else 'backport' - - # The branch name is based off of the name of branch being merged into - # and the SHA of the branch being merged from. Thus if the branch already - # exists we can assume we don't need to recreate it. - new_branch_name = f'{branch_prefix}-v{version}-{source_branch_short_sha}' - print(f'Branch name is {new_branch_name}.') - - # Check if the branch already exists. If so we can abort as this script - # has already run on this combination of branches. - if branch_exists_on_remote(new_branch_name): - print(f'Branch {new_branch_name} already exists. Nothing to do.') - return - - # Create the new branch and push it to the remote - print(f'Creating branch {new_branch_name}.') - - # 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) - conflicted_files = [] - - if not is_primary_release: - - # the source branch will be of the form releases/vN, where N is the major version number - source_branch_major_version = source_branch.strip('releases/v') - - # If we're performing a backport, start from the target branch - print(f'Creating {new_branch_name} from the {ORIGIN}/{target_branch} branch') - run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{target_branch}') - - # 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. - print('Reverting the version number and changelog updates from the last release to avoid conflicts') - vOlder_update_commits = run_git('log', '--grep', f'^{BACKPORT_COMMIT_MESSAGE}', '--format=%H').split() - - if len(vOlder_update_commits) > 0: - print(f' Reverting {vOlder_update_commits[0]}') - # Only revert the newest commit as older ones will already have been reverted in previous - # releases. - run_git('revert', vOlder_update_commits[0], '--no-edit') - - # Also revert the "Rebuild" commit, whether created by this script or by the - # `Rebuild Action` workflow. - rebuild_commit = run_git('log', '--grep', f'^{REBUILD_COMMIT_MESSAGE}$', '--format=%H').split()[0] - print(f' Reverting {rebuild_commit}') - run_git('revert', rebuild_commit, '--no-edit') - - else: - print(' Nothing to revert.') - - print(f'Merging {ORIGIN}/{source_branch} into the release prep branch') - # Commit any conflicts (see the comment for `conflicted_files`) - run_git('merge', f'{ORIGIN}/{source_branch}', allow_non_zero_exit_code=True) - conflicted_files = run_git('diff', '--name-only', '--diff-filter', 'U').splitlines() - if len(conflicted_files) > 0: - run_git('add', '.') - run_git('commit', '--no-edit') - - # Migrate the package version number from a vLatest version number to a vOlder version number. - # `package-lock.json` is updated as part of the subsequent rebuild step (see `rebuild_action`). - print(f'Setting version number to {version} in package.json') - replace_version_package_json(get_current_version(), version) - run_git('add', 'package.json') - - # Migrate the changelog notes from vLatest version numbers to vOlder version numbers - print(f'Migrating changelog notes from v{source_branch_major_version} to v{target_branch_major_version}') - process_changelog_for_backports(source_branch_major_version, target_branch_major_version) - - # Amend the commit generated by `npm version` to update the CHANGELOG - run_git('add', 'CHANGELOG.md') - run_git('commit', '-m', f'{BACKPORT_COMMIT_MESSAGE}{version}') - else: - # If we're performing a standard release, there won't be any new commits on the target branch, - # as these will have already been merged back into the source branch. Therefore we can just - # start from the source branch. - run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{source_branch}') - - print('Updating changelog') - update_changelog(version) - - # Create a commit that updates the CHANGELOG - run_git('add', 'CHANGELOG.md') - run_git('commit', '-m', f'Update changelog for v{version}') - - if not is_primary_release: - if len(conflicted_files) == 0: - print('Rebuilding the Action.') - rebuild_action() - else: - print(f'Skipping automatic rebuild because the merge produced conflicts in {conflicted_files}.') - - run_git('push', ORIGIN, new_branch_name) - - # Open a PR to update the branch - open_pr( - repo, - commits, - source_branch_short_sha, - new_branch_name, - source_branch=source_branch, - target_branch=target_branch, - conductor=args.conductor, - is_primary_release=is_primary_release, - conflicted_files=conflicted_files - ) - -if __name__ == '__main__': - main() 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..088da59281 --- /dev/null +++ b/pr-checks/update-release-branch.ts @@ -0,0 +1,840 @@ +#!/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(); +} 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"); +}