diff --git a/.github/actions/release-initialise/action.yml b/.github/actions/release-initialise/action.yml index be16f03950..239dfa9428 100644 --- a/.github/actions/release-initialise/action.yml +++ b/.github/actions/release-initialise/action.yml @@ -25,17 +25,6 @@ runs: shell: bash run: npm ci - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install PyGithub==2.3.0 requests - shell: bash - - name: Update git config run: | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f1b9e770b..f27de17fd8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -113,7 +113,6 @@ jobs: matrix: include: - language: actions - - language: python permissions: contents: read diff --git a/.github/workflows/post-release-mergeback.yml b/.github/workflows/post-release-mergeback.yml index 170b309de1..c493c2a382 100644 --- a/.github/workflows/post-release-mergeback.yml +++ b/.github/workflows/post-release-mergeback.yml @@ -51,9 +51,9 @@ jobs: with: node-version: 24 cache: 'npm' - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' + + - name: Install JavaScript dependencies + run: npm ci - name: Update git config run: | @@ -127,7 +127,7 @@ jobs: env: PARTIAL_CHANGELOG: "${{ runner.temp }}/partial_changelog.md" run: | - python .github/workflows/script/prepare_changelog.py CHANGELOG.md > $PARTIAL_CHANGELOG + npx tsx pr-checks/prepare-changelog.ts --output="$PARTIAL_CHANGELOG" echo "::group::Partial CHANGELOG" cat $PARTIAL_CHANGELOG diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index 6e6b127905..c37f8a79ae 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -93,7 +93,7 @@ jobs: LATEST_TAG: ${{ needs.prepare.outputs.latest_tag }} VERSION: "${{ needs.prepare.outputs.version }}" run: | - python .github/workflows/script/rollback_changelog.py \ + npx tsx pr-checks/rollback-changelog.ts \ --target-version "${ROLLBACK_TAG:1}" \ --rollback-version "${LATEST_TAG:1}" \ --new-version "$VERSION" > $NEW_CHANGELOG @@ -128,7 +128,9 @@ jobs: NEW_CHANGELOG: "${{ runner.temp }}/new_changelog.md" PARTIAL_CHANGELOG: "${{ runner.temp }}/partial_changelog.md" run: | - python .github/workflows/script/prepare_changelog.py $NEW_CHANGELOG > $PARTIAL_CHANGELOG + npx tsx pr-checks/prepare-changelog.ts \ + --changelog="$NEW_CHANGELOG" \ + --output="$PARTIAL_CHANGELOG" echo "::group::Partial CHANGELOG" cat $PARTIAL_CHANGELOG diff --git a/.github/workflows/script/bundle_changelog.py b/.github/workflows/script/bundle_changelog.py deleted file mode 100755 index d8ced87d8d..0000000000 --- a/.github/workflows/script/bundle_changelog.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import os -import re - -cli_version = os.environ['CLI_VERSION'] - -# The GitHub Release for the new bundle version. -bundle_release_url = f"https://github.com/github/codeql-action/releases/tag/codeql-bundle-v{cli_version}" -# Get the PR number from the PR URL. -pr_number = os.environ['PR_URL'].split('/')[-1] -changelog_note = f"- Update default CodeQL bundle version to [{cli_version}]({bundle_release_url}). [#{pr_number}]({os.environ['PR_URL']})" - -# If the "[UNRELEASED]" section starts with "no user facing changes", remove that line. -with open('CHANGELOG.md', 'r') as f: - changelog = f.read() - -changelog = changelog.replace('## [UNRELEASED]\n\nNo user facing changes.', '## [UNRELEASED]\n') - -# Add the changelog note to the bottom of the "[UNRELEASED]" section. -changelog = re.sub(r'\n## (\d+\.\d+\.\d+)', f'{changelog_note}\n\n## \\1', changelog, count=1) - -with open('CHANGELOG.md', 'w') as f: - f.write(changelog) diff --git a/.github/workflows/script/prepare_changelog.py b/.github/workflows/script/prepare_changelog.py deleted file mode 100755 index dafb84b39c..0000000000 --- a/.github/workflows/script/prepare_changelog.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys - -EMPTY_CHANGELOG = 'No changes.\n\n' - -# Prepare the changelog for the new release -# This function will extract the part of the changelog that -# we want to include in the new release. -def extract_changelog_snippet(changelog_file): - output = '' - if (not os.path.exists(changelog_file)): - output = EMPTY_CHANGELOG - - else: - with open(changelog_file, 'r') as f: - lines = f.readlines() - - # Include only the contents of the first section - found_first_section = False - for line in lines: - if line.startswith('## '): - if found_first_section: - break - found_first_section = True - elif found_first_section: - output += line - - return output.strip() - - -if len(sys.argv) < 2: - raise Exception('Expecting argument: changelog_file') -changelog_file = sys.argv[1] -print(extract_changelog_snippet(changelog_file)) diff --git a/.github/workflows/script/rollback_changelog.py b/.github/workflows/script/rollback_changelog.py deleted file mode 100644 index 5e06f83455..0000000000 --- a/.github/workflows/script/rollback_changelog.py +++ /dev/null @@ -1,62 +0,0 @@ -import datetime -import os -import argparse - -EMPTY_CHANGELOG = """# CodeQL Action Changelog - -""" - -def get_today_string(): - today = datetime.datetime.today() - return '{:%d %b %Y}'.format(today) - -# Include everything up to and after the first heading, -# but not the first heading and body. -def drop_unreleased_section(lines: list[str]): - before_first_section = '' - after_first_section = '' - found_first_section = False - skipped_first_section = False - - for i, line in enumerate(lines): - if line.startswith('## ') and not found_first_section: - found_first_section = True - elif line.startswith('## ') and found_first_section: - skipped_first_section = True - - if not found_first_section: - before_first_section += line - if skipped_first_section: - after_first_section += line - - return (before_first_section, after_first_section) - -def update_changelog(target_version, rollback_version, new_version): - before_first_section = EMPTY_CHANGELOG - after_first_section = '' - - if (os.path.exists('CHANGELOG.md')): - with open('CHANGELOG.md', 'r') as f: - (before_first_section, after_first_section) = drop_unreleased_section(f.readlines()) - - newHeader = f'## {new_version} - {get_today_string()}\n' - - print(before_first_section, end="") - print(newHeader) - print(f"This release rolls back {rollback_version} due to issues with that release. It is identical to {target_version}.\n") - print(after_first_section) - -# We expect three version strings as input: -# -# - target_version: the version that we are re-releasing as `new_version` -# - rollback_version: the version that we are rolling back, typically the one that followed `target_version` -# - new_version: the new version that we are releasing `target_version` as, typically the one that follows `rollback_version` -# -# Example: python3 .github/workflows/script/rollback_changelog.py --target-version "1.2.3" --rollback-version "1.2.4" --new-version "1.2.5" -parser = argparse.ArgumentParser(description="Update CHANGELOG.md for a rollback release.") -parser.add_argument("--target-version", "-t", required=True, help="Version to re-release as new_version.") -parser.add_argument("--rollback-version", "-r", required=True, help="Version being rolled back.") -parser.add_argument("--new-version", "-n", required=True, help="New version to publish for target_version.") -args = parser.parse_args() - -update_changelog(args.target_version, args.rollback_version, args.new_version) diff --git a/.github/workflows/update-bundle.yml b/.github/workflows/update-bundle.yml index 8dcf058591..d3ee924e59 100644 --- a/.github/workflows/update-bundle.yml +++ b/.github/workflows/update-bundle.yml @@ -40,11 +40,6 @@ jobs: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' - - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -120,7 +115,7 @@ jobs: - name: Create changelog note run: | - python .github/workflows/script/bundle_changelog.py + npx tsx pr-checks/bundle-changelog.ts - name: Push changelog note run: | diff --git a/.github/workflows/update-supported-enterprise-server-versions.yml b/.github/workflows/update-supported-enterprise-server-versions.yml index fc7873711d..01cd6ab8fb 100644 --- a/.github/workflows/update-supported-enterprise-server-versions.yml +++ b/.github/workflows/update-supported-enterprise-server-versions.yml @@ -22,11 +22,6 @@ jobs: pull-requests: write # needed to create pull request steps: - - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.13" - - name: Checkout CodeQL Action uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/pr-checks/bundle-changelog.test.ts b/pr-checks/bundle-changelog.test.ts new file mode 100644 index 0000000000..6cc4d096ba --- /dev/null +++ b/pr-checks/bundle-changelog.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for `bundle-changelog.ts`. + */ + +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { + CLI_VERSION_ENV_VAR, + getCLIVersion, + getPRNumber, + getPRUrl, + PR_URL_ENV_VAR, + updateChangelog, +} from "./bundle-changelog"; +import { + EMPTY_CHANGELOG, + NO_CHANGES_STR, + UNRELEASED_PLACEHOLDER, +} from "./changelog"; + +let testDir: string; + +beforeEach(() => { + // Set up a temporary directory for testing + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-changelog-test-")); +}); + +afterEach(() => { + /** Clean up temporary directories. */ + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe("getCLIVersion", async () => { + await it("throws if the environment variable is not set", async () => { + delete process.env[CLI_VERSION_ENV_VAR]; + assert.throws(() => getCLIVersion()); + }); + + await it("throws if the environment variable is empty", async () => { + process.env[CLI_VERSION_ENV_VAR] = " "; + assert.throws(() => getCLIVersion()); + }); + + await it("returns value of the environment variable if set", async () => { + const testValue = "1.2.3"; + process.env[CLI_VERSION_ENV_VAR] = testValue; + assert.deepEqual(getCLIVersion(), testValue); + }); +}); + +const testPrUrl = "https://github.com/github/codeql-action/pulls/42"; + +describe("getPRUrl", async () => { + await it("throws if the environment variable is not set", async () => { + delete process.env[PR_URL_ENV_VAR]; + assert.throws(() => getPRUrl()); + }); + + await it("throws if the environment variable is empty", async () => { + process.env[PR_URL_ENV_VAR] = " "; + assert.throws(() => getPRUrl()); + }); + + await it("returns value of the environment variable if set", async () => { + process.env[PR_URL_ENV_VAR] = testPrUrl; + assert.deepEqual(getPRUrl(), testPrUrl); + }); +}); + +describe("getPRNumber", async () => { + await it("throws if the last part of the input is not a number", async () => { + assert.throws(() => getPRNumber(`${testPrUrl}/foo`)); + }); + + await it("throws if the last part of the input is not a positive number", async () => { + assert.throws(() => getPRNumber(`${testPrUrl}/-100`)); + }); + + await it("returns the PR number from an URL", async () => { + assert.equal(getPRNumber(testPrUrl), 42); + }); +}); + +const testChangelog = `${EMPTY_CHANGELOG.trimEnd()} + +## 4.23.7 + +- Other change + +## 4.23.6 + +${NO_CHANGES_STR}`; + +const expectedChangelog = `# CodeQL Action Changelog + +## ${UNRELEASED_PLACEHOLDER} + +- Update default CodeQL bundle version to + +## 4.23.7 + +- Other change + +## 4.23.6 + +${NO_CHANGES_STR}`; + +describe("updateChangelog", async () => { + await it("removes `NO_CHANGES_STR` if present in [UNRELEASED] section", async () => { + const result = updateChangelog(EMPTY_CHANGELOG, ""); + assert.ok(!result.includes(NO_CHANGES_STR.trim())); + }); + + await it("doesn't remove `NO_CHANGES_STR` if present in versioned section", async () => { + const result = updateChangelog( + EMPTY_CHANGELOG.replace(UNRELEASED_PLACEHOLDER, "1.2.3"), + "", + ); + assert.ok(result.includes(NO_CHANGES_STR.trim())); + }); + + await it("throws if there are no sections", async () => { + assert.throws(() => { + updateChangelog( + "# CodeQL Action Changelog", + "- Update default CodeQL bundle version to", + ); + }); + }); + + await it("adds note at the end of the first section", async () => { + const result = updateChangelog( + testChangelog, + "- Update default CodeQL bundle version to", + ); + assert.deepEqual(result, expectedChangelog); + }); +}); diff --git a/pr-checks/bundle-changelog.ts b/pr-checks/bundle-changelog.ts new file mode 100755 index 0000000000..557a8556c1 --- /dev/null +++ b/pr-checks/bundle-changelog.ts @@ -0,0 +1,127 @@ +#!/usr/bin/env npx tsx + +/** + * Updates the changelog with a change note for an updated CodeQL CLI bundle. + */ + +import * as fs from "node:fs"; + +import { + parseChangelog, + renderChangelog, + UNRELEASED_PLACEHOLDER, +} from "./changelog"; +import { CHANGELOG_FILE, CLI_BUNDLE_RELEASE_URL_PREFIX } from "./config"; +import { getErrorMessage } from "./util"; + +export const CLI_VERSION_ENV_VAR = "CLI_VERSION"; +export const PR_URL_ENV_VAR = "PR_URL"; + +/** Gets the CLI version from the environment. */ +export function getCLIVersion() { + const cliVersion = process.env[CLI_VERSION_ENV_VAR]; + + if (cliVersion === undefined || cliVersion.trim() === "") { + throw new Error(`No CLI version was set in '${CLI_VERSION_ENV_VAR}'.`); + } + + return cliVersion; +} + +/** Gets the PR URL from the environment. */ +export function getPRUrl() { + const prUrl = process.env[PR_URL_ENV_VAR]; + + if (prUrl === undefined || prUrl.trim() === "") { + throw new Error(`No PR URL was set in '${PR_URL_ENV_VAR}'.`); + } + + return prUrl; +} + +/** + * Gets the PR number from something like a PR URL. + */ +export function getPRNumber(prUrl: string) { + const prUrlParts = prUrl.split("/"); + const prNumberStr = prUrlParts[prUrlParts.length - 1]; + + const prNumber = Number.parseInt(prNumberStr, 10); + + if (!Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error( + `Invalid PR URL '${prUrl}': last part is not a positive number`, + ); + } + + return prNumber; +} + +/** + * Updates `changelog` by adding `changelogNote` to the first section. + * + * @param contents The existing changelog contents. + * @param changelogNote The note to add to the first section. + */ +export function updateChangelog(contents: string, changelogNote: string) { + // If the "[UNRELEASED]" section starts with "no user facing changes", remove that line. + contents = contents.replace( + `## ${UNRELEASED_PLACEHOLDER}\n\nNo user facing changes.`, + `## ${UNRELEASED_PLACEHOLDER}\n`, + ); + + const changelog = parseChangelog(contents); + + if (changelog.sections.length === 0) { + throw new Error("The changelog contains no existing sections."); + } + + // Add the changelog note to the bottom of the first section. + const firstSection = changelog.sections[0]; + const lastLine = firstSection.bodyLines.pop(); + + if (lastLine !== undefined && lastLine.trim() !== "") { + // We expect the last line to be empty. If it isn't for some reason, + // add it back. + firstSection.bodyLines.push(lastLine); + } + + firstSection.bodyLines.push(changelogNote); + + // If the last line is empty as expected, then add it back in after the new note. + if (lastLine?.trim() === "") { + firstSection.bodyLines.push(lastLine); + } + + return renderChangelog(changelog); +} + +function main() { + try { + const cliVersion = getCLIVersion(); + const prUrl = getPRUrl(); + + // The GitHub Release for the new bundle version. + const bundleReleaseUrl = `${CLI_BUNDLE_RELEASE_URL_PREFIX}${cliVersion}`; + + // Get the PR number from the PR URL. + const prNumber = getPRNumber(prUrl); + const changelogNote = `- Update default CodeQL bundle version to [${cliVersion}](${bundleReleaseUrl}). [#${prNumber}](${prUrl})`; + + let changelog = fs.readFileSync(CHANGELOG_FILE, "utf-8"); + + changelog = updateChangelog(changelog, changelogNote); + + fs.writeFileSync(CHANGELOG_FILE, changelog); + + return 0; + } catch (err) { + console.error(`Failed to bundle changelog: ${getErrorMessage(err)}`); + return -1; + } +} + +// Only call `main` if this script was run directly. +if (require.main === module) { + process.exit(main()); +} diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts index 3eddc14591..817852e3e1 100755 --- a/pr-checks/changelog.test.ts +++ b/pr-checks/changelog.test.ts @@ -5,14 +5,18 @@ */ import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; import { describe, it } from "node:test"; import { EMPTY_CHANGELOG, getReleaseDateString, + parseChangelog, processChangelogForBackports, + renderChangelog, setVersionAndDate, } from "./changelog"; +import { CHANGELOG_FILE } from "./config"; const testDate = new Date(2026, 7, 14); @@ -37,6 +41,14 @@ describe("setVersionAndDate", async () => { }); }); +describe("parseChangelog + renderChangelog", async () => { + await it("renderChangelog(parseChangelog(c)) == c", async () => { + const actualChangelog = fs.readFileSync(CHANGELOG_FILE, "utf-8"); + const roundtrip = renderChangelog(parseChangelog(actualChangelog)); + assert.deepEqual(roundtrip.split("\n"), actualChangelog.split("\n")); + }); +}); + const testChangelog = `# CodeQL Action Changelog ## 4.12.3 - 14 Aug 2026 diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 49011b0361..4cf1e75494 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -2,14 +2,34 @@ import * as fs from "node:fs"; import { CHANGELOG_FILE, DryRunOption } from "./config"; +/** The placeholder in the header for unreleased changes. */ +export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]"; + +/** The default contents for a section in the changelog. */ +export const NO_CHANGES_STR = "No user facing changes.\n\n"; + /** Placeholder changelog content for a new release. */ export const EMPTY_CHANGELOG = `# CodeQL Action Changelog -## [UNRELEASED] +## ${UNRELEASED_PLACEHOLDER} -No user facing changes. +${NO_CHANGES_STR}`; + +/** + * Represents sections in a changelog. + */ +export interface ChangelogSection { + headerLine: string; + bodyLines: string[]; +} -`; +/** + * Represents a changelog. + */ +export interface Changelog { + preamble: string[]; + sections: ChangelogSection[]; +} /** Returns `date` formatted as `DD Mon YYYY`. */ export function getReleaseDateString(today: Date = new Date()): string { @@ -53,7 +73,76 @@ export function setVersionAndDate( date: Date = new Date(), ): string { const versionAndDate = `${version} - ${getReleaseDateString(date)}`; - return content.replace("[UNRELEASED]", versionAndDate); + return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); +} + +/** + * Parses `content` into a structured representation of a changelog. + * + * @param content The contents of the changelog file. + */ +export function parseChangelog(content: string): Changelog { + const lines = content.split("\n"); + let i = 0; + + const preamble: string[] = []; + const sections: ChangelogSection[] = []; + let currentSection: ChangelogSection | undefined = undefined; + + // Process all lines of the input file. + while (i < lines.length) { + const line = lines[i]; + + // Sections of the changelog start with `## `. + if (line.startsWith("## ")) { + // We have discovered a new section. If `currentSection` is already defined, + // then this marks the end of that section. Push it to the array of sections + // in the changelog. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + // Initialise the new section. + currentSection = { headerLine: line, bodyLines: [] }; + } else if (currentSection !== undefined) { + // Add lines between the section header and the next to the current section. + currentSection.bodyLines.push(line); + } else { + // This is neither a section header nor are we in a section already, + // so this line is part of the preamble. + preamble.push(line); + } + + i++; + } + + // Push the current section to the array of completed sections, if there is + // still one unfinished. + if (currentSection !== undefined) { + sections.push(currentSection); + } + + return { preamble, sections }; +} + +/** + * Combines an array of lines into a single string by adding line breaks. + */ +export function unlines(lines: string[]): string { + return `${lines.join("\n")}`; +} + +/** + * Renders a given changelog to a string. + */ +export function renderChangelog(changelog: Changelog): string { + let result = unlines(changelog.preamble); + + for (const section of changelog.sections) { + result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`; + } + + return result; } /** @@ -66,70 +155,58 @@ export function processChangelogForBackports( 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++; - } + // Parse the changelog. + const changelog = parseChangelog(content); - if (!foundFirstSection) { + if (changelog.sections.length === 0) { 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) { + // Filter out changelog entries that only apply to newer versions and + // update the section headings with the backport major version for + // sections we keep. + for (const section of changelog.sections) { + // Update the section headings with the backport major version. + section.headerLine = section.headerLine.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + + const filteredEntries: string[] = []; + let foundContent = false; + + for (const line of section.bodyLines) { + // Skip the entry if `someVersionsOnlyRegex` matches and the major version + // of the target branch is smaller than the required version. + const match = someVersionsOnlyRegex.exec(line); if ( + match && 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 { + // Keep the line. + filteredEntries.push(line); + + // Set `foundContent` to `true` if the line is not empty. if (line.trim() !== "") { foundContent = true; - output += `${line}\n`; } } + + // Update the section with the retained entries. + section.bodyLines = filteredEntries; + + // Add an entry if we didn't keep any. + if (!foundContent) { + section.bodyLines.push(NO_CHANGES_STR.trim()); + } } - return output; + return renderChangelog(changelog); } diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 94d34a931d..356fe665f9 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -37,6 +37,10 @@ export const API_COMPATIBILITY_FILE = path.join( "api-compatibility.json", ); +/** The prefix of CodeQL CLI bundle release URLs. */ +export const CLI_BUNDLE_RELEASE_URL_PREFIX = + "https://github.com/github/codeql-action/releases/tag/codeql-bundle-v"; + /** A common interface for operations that support dry runs. */ export interface DryRunOption { /** A value indicating whether to perform operations with side effects. */ diff --git a/pr-checks/prepare-changelog.test.ts b/pr-checks/prepare-changelog.test.ts new file mode 100644 index 0000000000..13a302c8f6 --- /dev/null +++ b/pr-checks/prepare-changelog.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for `prepare-changelog.ts`. + */ + +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { EMPTY_CHANGELOG, NO_CHANGES_STR } from "./changelog"; +import { extractChangelogSnippet } from "./prepare-changelog"; + +let testDir: string; + +beforeEach(() => { + // Set up a temporary directory for testing + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "prepare-changelog-test-")); +}); + +afterEach(() => { + /** Clean up temporary directories. */ + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +const testBody = `- Test change`; +const testChangelog = `${EMPTY_CHANGELOG.replace(NO_CHANGES_STR, testBody)} + +## Another section + +- Other change`; + +describe("extractChangelogSnippet", async () => { + await it("returns the default body if the input doesn't exist", async () => { + const result = extractChangelogSnippet(path.join(testDir, "not-here.md")); + assert.deepEqual(result, NO_CHANGES_STR); + }); + + await it("returns the first section if the input exists", async () => { + const changelogPath = path.join(testDir, "test-readme.md"); + fs.writeFileSync(changelogPath, testChangelog); + + const result = extractChangelogSnippet(changelogPath); + assert.deepEqual(result, testBody); + }); + + await it("returns an empty string if there is no first section", async () => { + const changelogPath = path.join(testDir, "test-readme.md"); + fs.writeFileSync(changelogPath, "# CodeQL Action Changelog\n"); + + const result = extractChangelogSnippet(changelogPath); + assert.deepEqual(result, ""); + }); +}); diff --git a/pr-checks/prepare-changelog.ts b/pr-checks/prepare-changelog.ts new file mode 100755 index 0000000000..0c89699fc8 --- /dev/null +++ b/pr-checks/prepare-changelog.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env npx tsx + +/** + * Extracts the body of the first changelog section and outputs it to either + * stdout or a file. + */ + +import * as fs from "node:fs"; +import { parseArgs } from "node:util"; + +import { NO_CHANGES_STR, parseChangelog } from "./changelog"; +import { CHANGELOG_FILE } from "./config"; +import { getErrorMessage } from "./util"; + +/** + * Prepare the changelog for the new release + * This function will extract the part of the changelog that + * we want to include in the new release. + * + * @param changelogPath The path to the changelog file. + */ +export function extractChangelogSnippet(changelogPath: string) { + try { + const content = fs.readFileSync(changelogPath, "utf-8"); + const changelog = parseChangelog(content); + + // Return an empty string if we couldn't find the first section. + if (changelog.sections.length === 0) { + return ""; + } + + return changelog.sections[0].bodyLines.join("\n").trim(); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + console.error(`Changelog file at '${changelogPath}' does not exist.`); + return NO_CHANGES_STR; + } else { + throw Error( + `Failed to open changelog file at '${changelogPath}': ${getErrorMessage(err)}`, + ); + } + } +} + +function main() { + try { + const { values } = parseArgs({ + options: { + changelog: { + type: "string", + short: "f", + default: CHANGELOG_FILE, + }, + output: { + type: "string", + short: "o", + }, + }, + strict: true, + }); + + const body = extractChangelogSnippet(values.changelog); + + // If no `output` argument was provided, output to stdout. Otherwise, + // write a file to the specified path. + if (values.output === undefined) { + console.info(body); + } else { + fs.writeFileSync(values.output, body); + } + + return 0; + } catch (err) { + console.error(`Failed to prepare changelog: ${getErrorMessage(err)}`); + return -1; + } +} + +// Only call `main` if this script was run directly. +if (require.main === module) { + process.exit(main()); +} diff --git a/pr-checks/rollback-changelog.test.ts b/pr-checks/rollback-changelog.test.ts new file mode 100644 index 0000000000..5264755a69 --- /dev/null +++ b/pr-checks/rollback-changelog.test.ts @@ -0,0 +1,45 @@ +/** + * Tests for `rollback-changelog.ts`. + */ + +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { describe, it } from "node:test"; + +import { getReleaseDateString, parseChangelog } from "./changelog"; +import { CHANGELOG_FILE } from "./config"; +import { updateChangelog } from "./rollback-changelog"; + +describe("updateChangelog", async () => { + await it("replaces the first section with one for the rollback release", async () => { + const actualChangelog = parseChangelog( + fs.readFileSync(CHANGELOG_FILE, "utf-8"), + ); + const existingFirstSection = actualChangelog.sections[0]; + + const today = new Date(); + updateChangelog(actualChangelog, { + "new-version": "Test.1.3", + "rollback-version": "Test.1.2", + "target-version": "Test.1.1", + today, + }); + + // Check that the old, first section is gone. + for (const section of actualChangelog.sections) { + assert.notDeepEqual(section, existingFirstSection); + } + + // Check that the new, first section matches our expectations. + const newFirstSection = actualChangelog.sections[0]; + assert.deepEqual( + newFirstSection.headerLine, + `## Test.1.3 - ${getReleaseDateString(today)}`, + ); + assert.equal(newFirstSection.bodyLines.length, 3); + assert.deepEqual( + newFirstSection.bodyLines[1], + `This release rolls back Test.1.2 due to issues with that release. It is identical to Test.1.1.`, + ); + }); +}); diff --git a/pr-checks/rollback-changelog.ts b/pr-checks/rollback-changelog.ts new file mode 100755 index 0000000000..6f2edb4b94 --- /dev/null +++ b/pr-checks/rollback-changelog.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env npx tsx + +/** + * Replaces the current, first section of the changelog with a new one for the rollback release. + */ + +import * as fs from "node:fs"; +import { parseArgs } from "node:util"; + +import { + Changelog, + ChangelogSection, + getReleaseDateString, + parseChangelog, + renderChangelog, +} from "./changelog"; +import { CHANGELOG_FILE } from "./config"; +import { getErrorMessage } from "./util"; + +export interface RollbackChangelogInputs { + "target-version": string; + "rollback-version": string; + "new-version": string; + today?: Date; +} + +/** + * Replaces the current, first section of the changelog with a new one for the rollback release. + */ +export function updateChangelog( + changelog: Changelog, + versions: RollbackChangelogInputs, +) { + // Drop the existing first section. + changelog.sections.shift(); + + // Construct the section for the rollback version. + const newSection: ChangelogSection = { + headerLine: `## ${versions["new-version"]} - ${getReleaseDateString(versions.today)}`, + bodyLines: [ + "", + `This release rolls back ${versions["rollback-version"]} due to issues with that release. It is identical to ${versions["target-version"]}.`, + "", + ], + }; + + // Add the new section at the top of the changelog. + changelog.sections.unshift(newSection); +} + +function main() { + try { + const { values } = parseArgs({ + options: { + "target-version": { + type: "string", + short: "t", + }, + "rollback-version": { + type: "string", + short: "r", + }, + "new-version": { + type: "string", + short: "n", + }, + }, + strict: true, + }); + + for (const [key, val] of Object.entries(values)) { + if (val === undefined || val.trim() === "") { + throw new Error(`Argument '--${key}' is required.`); + } + } + + const changelog = parseChangelog(fs.readFileSync(CHANGELOG_FILE, "utf-8")); + updateChangelog(changelog, values as RollbackChangelogInputs); + console.info(renderChangelog(changelog)); + + return 0; + } catch (err) { + console.error( + `Failed to prepare rollback changelog: ${getErrorMessage(err)}`, + ); + return -1; + } +} + +// Only call `main` if this script was run directly. +if (require.main === module) { + process.exit(main()); +} diff --git a/pr-checks/util.ts b/pr-checks/util.ts new file mode 100644 index 0000000000..353b2a9654 --- /dev/null +++ b/pr-checks/util.ts @@ -0,0 +1,9 @@ +/** + * Returns an appropriate message for the error. + * + * If the error is an `Error` instance, this returns the error message without + * an `Error: ` prefix. + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +}