Add bundle-changelog.ts with tests

This commit is contained in:
Michael B. Gale
2026-07-24 15:23:11 +01:00
parent 9b8dc487b6
commit aeef68fc36
3 changed files with 270 additions and 0 deletions

View File

@@ -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);
});
});

124
pr-checks/bundle-changelog.ts Executable file
View File

@@ -0,0 +1,124 @@
#!/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 { CHANGELOG_FILE, CLI_BUNDLE_RELEASE_URL_PREFIX } from "./config";
import { NO_CHANGES_STR, parseChangelog, renderChangelog, UNRELEASED_PLACEHOLDER } from "./changelog";
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 !== undefined && 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());
}

View File

@@ -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. */