From 2d14f71964fc76b4d4951317784ee6b4c9440830 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 12:35:05 +0100 Subject: [PATCH 01/18] Add `NO_CHANGES_STR` constant --- pr-checks/changelog.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 49011b0361..1351cd8c2f 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -2,14 +2,15 @@ import * as fs from "node:fs"; import { CHANGELOG_FILE, DryRunOption } from "./config"; +/** 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] -No user facing changes. - -`; +${NO_CHANGES_STR}`; /** Returns `date` formatted as `DD Mon YYYY`. */ export function getReleaseDateString(today: Date = new Date()): string { From 85d157095f7ec93f7aa17909634728937fe6921d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 12:48:01 +0100 Subject: [PATCH 02/18] Add `prepare-changelog.ts` with tests --- pr-checks/prepare-changelog.test.ts | 46 ++++++++++++++ pr-checks/prepare-changelog.ts | 96 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 pr-checks/prepare-changelog.test.ts create mode 100755 pr-checks/prepare-changelog.ts diff --git a/pr-checks/prepare-changelog.test.ts b/pr-checks/prepare-changelog.test.ts new file mode 100644 index 0000000000..53804d7fd9 --- /dev/null +++ b/pr-checks/prepare-changelog.test.ts @@ -0,0 +1,46 @@ +/** + * 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); + }); +}); diff --git a/pr-checks/prepare-changelog.ts b/pr-checks/prepare-changelog.ts new file mode 100755 index 0000000000..8add7dcf9f --- /dev/null +++ b/pr-checks/prepare-changelog.ts @@ -0,0 +1,96 @@ +#!/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 { getErrorMessage } from "../src/util"; + +import { NO_CHANGES_STR } from "./changelog"; +import { CHANGELOG_FILE } from "./config"; + +/** + * 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 lines = fs.readFileSync(changelogPath, "utf-8").split("\n"); + const output: string[] = []; + let foundFirstSection = false; + + // Extract the body of the first section in the changelog file. + for (const line of lines) { + if (line.startsWith("## ")) { + if (foundFirstSection) { + // This is the second section header we have found, which means that we have + // captured all lines in the first section in `output`. We can stop here. + break; + } + + // We have discovered the first section header. + foundFirstSection = true; + } else if (foundFirstSection) { + // Add lines between the first section header (if any) and the next to the output. + output.push(line); + } + } + + return output.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()); +} From b69467ce8bd149c52676a584a728ab412d2645ea Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 12:53:04 +0100 Subject: [PATCH 03/18] Update workflows to use `prepare-changelog.ts` --- .github/workflows/post-release-mergeback.yml | 2 +- .github/workflows/rollback-release.yml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/post-release-mergeback.yml b/.github/workflows/post-release-mergeback.yml index 170b309de1..22215eea1a 100644 --- a/.github/workflows/post-release-mergeback.yml +++ b/.github/workflows/post-release-mergeback.yml @@ -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..16680565d2 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -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 From 5901394530153b6f8919d08615334908398cc2b2 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 12:54:11 +0100 Subject: [PATCH 04/18] Remove `prepare_changelog.py` --- .github/workflows/script/prepare_changelog.py | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100755 .github/workflows/script/prepare_changelog.py 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)) From 57eb44123f42baf689ff4e4eb31eba3627666bc1 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 13:32:04 +0100 Subject: [PATCH 05/18] Add constant for unreleased placeholder --- pr-checks/changelog.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index 1351cd8c2f..3fe03a399f 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -2,13 +2,16 @@ 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_CHANGES_STR}`; @@ -54,7 +57,7 @@ export function setVersionAndDate( date: Date = new Date(), ): string { const versionAndDate = `${version} - ${getReleaseDateString(date)}`; - return content.replace("[UNRELEASED]", versionAndDate); + return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate); } /** From 093dce6cc2d6fcbbe0e87a60af7aba4687f57f72 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 13:41:35 +0100 Subject: [PATCH 06/18] Add `extractChangelogSnippet` test for the case where there is no first section --- pr-checks/prepare-changelog.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pr-checks/prepare-changelog.test.ts b/pr-checks/prepare-changelog.test.ts index 53804d7fd9..13a302c8f6 100644 --- a/pr-checks/prepare-changelog.test.ts +++ b/pr-checks/prepare-changelog.test.ts @@ -43,4 +43,12 @@ describe("extractChangelogSnippet", async () => { 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, ""); + }); }); From 916098aa8d13708f92e6c439083f7832ba3b89c7 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 14:59:57 +0100 Subject: [PATCH 07/18] Add `parseChangelog` and `renderChangelog` --- pr-checks/changelog.test.ts | 12 ++++++ pr-checks/changelog.ts | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) 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 3fe03a399f..db3a99b153 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -15,6 +15,22 @@ export const EMPTY_CHANGELOG = `# CodeQL Action Changelog ${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 { return today.toLocaleDateString("en-GB", { @@ -60,6 +76,75 @@ export function setVersionAndDate( 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; +} + /** * Processes changelog entries for a backport, converting version references * from the source major version to the target major version and filtering From adba0868a4038cf2cc306da86d5c53024f0109b4 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:00:44 +0100 Subject: [PATCH 08/18] Update `processChangelogForBackports` to use `parseChangelog` --- pr-checks/changelog.ts | 80 ++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 46 deletions(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index db3a99b153..4cf1e75494 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -155,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; + // Parse the changelog. + const changelog = parseChangelog(content); - // 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) { + 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); } From c5d621238d047125431ab165a2c991d3097c735d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:01:07 +0100 Subject: [PATCH 09/18] Update `extractChangelogSnippet` to use `parseChangelog` --- pr-checks/prepare-changelog.ts | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/pr-checks/prepare-changelog.ts b/pr-checks/prepare-changelog.ts index 8add7dcf9f..9ee89767bb 100755 --- a/pr-checks/prepare-changelog.ts +++ b/pr-checks/prepare-changelog.ts @@ -10,7 +10,7 @@ import { parseArgs } from "node:util"; import { getErrorMessage } from "../src/util"; -import { NO_CHANGES_STR } from "./changelog"; +import { NO_CHANGES_STR, parseChangelog } from "./changelog"; import { CHANGELOG_FILE } from "./config"; /** @@ -22,28 +22,15 @@ import { CHANGELOG_FILE } from "./config"; */ export function extractChangelogSnippet(changelogPath: string) { try { - const lines = fs.readFileSync(changelogPath, "utf-8").split("\n"); - const output: string[] = []; - let foundFirstSection = false; + const content = fs.readFileSync(changelogPath, "utf-8"); + const changelog = parseChangelog(content); - // Extract the body of the first section in the changelog file. - for (const line of lines) { - if (line.startsWith("## ")) { - if (foundFirstSection) { - // This is the second section header we have found, which means that we have - // captured all lines in the first section in `output`. We can stop here. - break; - } - - // We have discovered the first section header. - foundFirstSection = true; - } else if (foundFirstSection) { - // Add lines between the first section header (if any) and the next to the output. - output.push(line); - } + // Return an empty string if we couldn't find the first section. + if (changelog.sections.length === 0) { + return ""; } - return output.join("\n").trim(); + 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.`); From 66a6f42f0a3913dd88868e788503602498b26925 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:23:11 +0100 Subject: [PATCH 10/18] Add `bundle-changelog.ts` with tests --- pr-checks/bundle-changelog.test.ts | 142 +++++++++++++++++++++++++++++ pr-checks/bundle-changelog.ts | 128 ++++++++++++++++++++++++++ pr-checks/config.ts | 4 + 3 files changed, 274 insertions(+) create mode 100644 pr-checks/bundle-changelog.test.ts create mode 100755 pr-checks/bundle-changelog.ts 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..243cbd86b6 --- /dev/null +++ b/pr-checks/bundle-changelog.ts @@ -0,0 +1,128 @@ +#!/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 { getErrorMessage } from "../src/util"; + +import { + parseChangelog, + renderChangelog, + UNRELEASED_PLACEHOLDER, +} from "./changelog"; +import { CHANGELOG_FILE, CLI_BUNDLE_RELEASE_URL_PREFIX } from "./config"; + +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/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. */ From 027ac05d3b9f135622bc03943be850da84e8ad3b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:24:33 +0100 Subject: [PATCH 11/18] Use `bundle-changelog.ts` and remove Python version --- .github/workflows/script/bundle_changelog.py | 23 -------------------- .github/workflows/update-bundle.yml | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) delete mode 100755 .github/workflows/script/bundle_changelog.py 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/update-bundle.yml b/.github/workflows/update-bundle.yml index 8dcf058591..72eacf1397 100644 --- a/.github/workflows/update-bundle.yml +++ b/.github/workflows/update-bundle.yml @@ -120,7 +120,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: | From d71461774b5cf2296505e568335a3415b840aa5a Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:48:35 +0100 Subject: [PATCH 12/18] Add `rollback-changelog.ts` with tests --- pr-checks/rollback-changelog.test.ts | 45 +++++++++++++ pr-checks/rollback-changelog.ts | 94 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 pr-checks/rollback-changelog.test.ts create mode 100755 pr-checks/rollback-changelog.ts 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..23b10c9357 --- /dev/null +++ b/pr-checks/rollback-changelog.ts @@ -0,0 +1,94 @@ +#!/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 { getErrorMessage } from "../src/util"; + +import { + Changelog, + ChangelogSection, + getReleaseDateString, + parseChangelog, + renderChangelog, +} from "./changelog"; +import { CHANGELOG_FILE } from "./config"; + +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 of Object.keys(values)) { + if (key === undefined || key.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()); +} From 961b583f9ac26ed437c0f5abb5609fe16d8e29fa Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:52:50 +0100 Subject: [PATCH 13/18] Use `rollback-changelog.ts` and remove Python version --- .github/workflows/rollback-release.yml | 2 +- .../workflows/script/rollback_changelog.py | 62 ------------------- 2 files changed, 1 insertion(+), 63 deletions(-) delete mode 100644 .github/workflows/script/rollback_changelog.py diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index 16680565d2..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 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) From ab44eb939db84ba2eebaa9ead83e1e833bde6df6 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 15:58:27 +0100 Subject: [PATCH 14/18] Remove `python` from CodeQL workflow There is no more (non-test) Python code left to analyse, so CodeQL analysis would fail now --- .github/workflows/codeql.yml | 1 - 1 file changed, 1 deletion(-) 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 From cbad145443761aa6d61cbac486cf37b1bf610a15 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 16:01:33 +0100 Subject: [PATCH 15/18] Remove Python-specific steps from workflows that no longer need them --- .github/actions/release-initialise/action.yml | 11 ----------- .github/workflows/post-release-mergeback.yml | 3 --- .github/workflows/update-bundle.yml | 5 ----- .../update-supported-enterprise-server-versions.yml | 5 ----- 4 files changed, 24 deletions(-) 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/post-release-mergeback.yml b/.github/workflows/post-release-mergeback.yml index 22215eea1a..ceb220b3a0 100644 --- a/.github/workflows/post-release-mergeback.yml +++ b/.github/workflows/post-release-mergeback.yml @@ -51,9 +51,6 @@ jobs: with: node-version: 24 cache: 'npm' - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' - name: Update git config run: | diff --git a/.github/workflows/update-bundle.yml b/.github/workflows/update-bundle.yml index 72eacf1397..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: 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 From 0953dc00da12a0b089198632e7283ce52dc21c35 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 16:08:18 +0100 Subject: [PATCH 16/18] Add `getErrorMessage` to `pr-checks`-local `util.ts` to avoid pulling in `src/util.ts` dependencies --- pr-checks/bundle-changelog.ts | 3 +-- pr-checks/prepare-changelog.ts | 3 +-- pr-checks/rollback-changelog.ts | 3 +-- pr-checks/util.ts | 9 +++++++++ 4 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 pr-checks/util.ts diff --git a/pr-checks/bundle-changelog.ts b/pr-checks/bundle-changelog.ts index 243cbd86b6..557a8556c1 100755 --- a/pr-checks/bundle-changelog.ts +++ b/pr-checks/bundle-changelog.ts @@ -6,14 +6,13 @@ import * as fs from "node:fs"; -import { getErrorMessage } from "../src/util"; - 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"; diff --git a/pr-checks/prepare-changelog.ts b/pr-checks/prepare-changelog.ts index 9ee89767bb..0c89699fc8 100755 --- a/pr-checks/prepare-changelog.ts +++ b/pr-checks/prepare-changelog.ts @@ -8,10 +8,9 @@ import * as fs from "node:fs"; import { parseArgs } from "node:util"; -import { getErrorMessage } from "../src/util"; - import { NO_CHANGES_STR, parseChangelog } from "./changelog"; import { CHANGELOG_FILE } from "./config"; +import { getErrorMessage } from "./util"; /** * Prepare the changelog for the new release diff --git a/pr-checks/rollback-changelog.ts b/pr-checks/rollback-changelog.ts index 23b10c9357..e0b3634daa 100755 --- a/pr-checks/rollback-changelog.ts +++ b/pr-checks/rollback-changelog.ts @@ -7,8 +7,6 @@ import * as fs from "node:fs"; import { parseArgs } from "node:util"; -import { getErrorMessage } from "../src/util"; - import { Changelog, ChangelogSection, @@ -17,6 +15,7 @@ import { renderChangelog, } from "./changelog"; import { CHANGELOG_FILE } from "./config"; +import { getErrorMessage } from "./util"; export interface RollbackChangelogInputs { "target-version": string; 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); +} From f00f809405a0571f02079a483378ba1102bd3d1e Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 16:17:04 +0100 Subject: [PATCH 17/18] Fix checking keys rather than values --- pr-checks/rollback-changelog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pr-checks/rollback-changelog.ts b/pr-checks/rollback-changelog.ts index e0b3634daa..6f2edb4b94 100755 --- a/pr-checks/rollback-changelog.ts +++ b/pr-checks/rollback-changelog.ts @@ -68,8 +68,8 @@ function main() { strict: true, }); - for (const key of Object.keys(values)) { - if (key === undefined || key.trim() === "") { + for (const [key, val] of Object.entries(values)) { + if (val === undefined || val.trim() === "") { throw new Error(`Argument '--${key}' is required.`); } } From 74b15aa2c6c649153cb2e5f7a9d3bd2f0c8f82d1 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 24 Jul 2026 16:19:45 +0100 Subject: [PATCH 18/18] Install JS deps if needed in `post-release-mergeback` workflow --- .github/workflows/post-release-mergeback.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/post-release-mergeback.yml b/.github/workflows/post-release-mergeback.yml index ceb220b3a0..c493c2a382 100644 --- a/.github/workflows/post-release-mergeback.yml +++ b/.github/workflows/post-release-mergeback.yml @@ -52,6 +52,9 @@ jobs: node-version: 24 cache: 'npm' + - name: Install JavaScript dependencies + run: npm ci + - name: Update git config run: | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"