Compare commits

...

8 Commits

Author SHA1 Message Date
Michael B. Gale
2e5cfb2878 Update for ESM 2026-10-14 10:40:45 +01:00
Michael B. Gale
7fbadd1a21 Merge branch 'main' into mbg/ts/remaining-scripts 2026-10-14 10:37:14 +01:00
Michael B. Gale
7e36708483 Convert check-js.sh to TypeScript 2026-07-31 17:17:54 +01:00
Michael B. Gale
fccc166683 Move subprocess helpers from update-release-branch.ts to command.ts 2026-07-31 14:38:04 +01:00
Michael B. Gale
f8f91fb336 Move check-codescanning-config/index.ts to pr-checks and add tests 2026-07-31 14:38:03 +01:00
Michael B. Gale
58f39a7d42 Convert check-sarif/index.js and add tests 2026-07-31 14:01:48 +01:00
Michael B. Gale
40b3f0c1bc Move upgrade-bundle.ts to pr-checks and add tests 2026-07-31 14:01:48 +01:00
Michael B. Gale
6e32e882a9 Add DEFAULTS_FILE constant 2026-07-31 14:01:48 +01:00
19 changed files with 787 additions and 297 deletions

View File

@@ -54,16 +54,15 @@ runs:
env: env:
CODEQL_ACTION_TEST_MODE: 'true' CODEQL_ACTION_TEST_MODE: 'true'
- name: Install dependencies
shell: bash
run: npm install --location=global ts-node js-yaml
- name: Check config - name: Check config
working-directory: ${{ github.action_path }}
shell: bash shell: bash
env: env:
EXPECTED_CONFIG_FILE_CONTENTS: '${{ inputs.expected-config-file-contents }}' EXPECTED_CONFIG_FILE_CONTENTS: '${{ inputs.expected-config-file-contents }}'
run: ts-node ./index.ts "$RUNNER_TEMP/user-config.yaml" "$EXPECTED_CONFIG_FILE_CONTENTS" run: |
npx tsx ../action/pr-checks/check-cs-config.ts \
--file "$RUNNER_TEMP/user-config.yaml" \
--expected-contents "$EXPECTED_CONFIG_FILE_CONTENTS"
- name: Clean up - name: Clean up
shell: bash shell: bash
if: always() if: always()

View File

@@ -1,49 +0,0 @@
import * as core from '@actions/core'
import * as yaml from 'js-yaml'
import * as fs from 'fs'
import * as assert from 'assert'
const actualConfig = loadActualConfig()
function sortConfigArrays(config) {
for (const key of Object.keys(config)) {
const value = config[key];
if (key === 'queries' && Array.isArray(value)) {
config[key] = value.sort();
}
}
return config;
}
const rawExpectedConfig = process.argv[3].trim()
if (!rawExpectedConfig) {
core.setFailed('No expected configuration provided')
} else {
core.startGroup('Expected generated user config')
core.info(yaml.dump(JSON.parse(rawExpectedConfig)))
core.endGroup()
}
const expectedConfig = rawExpectedConfig ? JSON.parse(rawExpectedConfig) : undefined;
assert.deepStrictEqual(
sortConfigArrays(actualConfig),
sortConfigArrays(expectedConfig),
'Expected configuration does not match actual configuration'
);
function loadActualConfig() {
if (!fs.existsSync(process.argv[2])) {
core.info('No configuration file found')
return undefined
} else {
const rawActualConfig = fs.readFileSync(process.argv[2], 'utf8')
core.startGroup('Actual generated user config')
core.info(rawActualConfig)
core.endGroup()
return yaml.load(rawActualConfig)
}
}

View File

@@ -16,5 +16,23 @@ inputs:
Comma separated list of query ids that should NOT be included in this SARIF file. Comma separated list of query ids that should NOT be included in this SARIF file.
runs: runs:
using: node24 using: "composite"
main: index.js steps:
- name: Run `check-sarif.ts`
shell: bash
env:
SARIF_FILE: ${{ inputs.sarif-file }}
QUERIES_RUN: ${{ inputs.queries-run }}
QUERIES_NOT_RUN: ${{ inputs.queries-not-run }}
run: |
if [[ -d pr-checks ]]; then
npx tsx ./pr-checks/check-sarif.ts \
--sarif-file "$SARIF_FILE" \
--queries-run "$QUERIES_RUN" \
--queries-not-run "$QUERIES_NOT_RUN"
else
npx tsx ../action/pr-checks/check-sarif.ts \
--sarif-file "$SARIF_FILE" \
--queries-run "$QUERIES_RUN" \
--queries-not-run "$QUERIES_NOT_RUN"
fi

View File

@@ -1,43 +0,0 @@
'use strict'
const core = require('@actions/core')
const fs = require('fs')
const sarif = JSON.parse(fs.readFileSync(core.getInput('sarif-file'), 'utf8'))
const rules = sarif.runs[0].tool.extensions.flatMap(ext => ext.rules || [])
const ruleIds = rules.map(rule => rule.id)
// Check that all the expected queries ran
const expectedQueriesRun = getQueryIdsInput('queries-run')
const queriesThatShouldHaveRunButDidNot = expectedQueriesRun.filter(queryId => !ruleIds.includes(queryId))
if (queriesThatShouldHaveRunButDidNot.length > 0) {
core.setFailed(`The following queries were expected to run but did not: ${queriesThatShouldHaveRunButDidNot.join(', ')}`)
}
// Check that all the unexpected queries did not run
const expectedQueriesNotRun = getQueryIdsInput('queries-not-run')
const queriesThatShouldNotHaveRunButDid = expectedQueriesNotRun.filter(queryId => ruleIds.includes(queryId))
if (queriesThatShouldNotHaveRunButDid.length > 0) {
core.setFailed(`The following queries were NOT expected to have run but did: ${queriesThatShouldNotHaveRunButDid.join(', ')}`)
}
core.startGroup('All queries run')
rules.forEach(rule => {
core.info(`${rule.id}: ${(rule.properties && rule.properties.name) || rule.name}`)
})
core.endGroup()
core.startGroup('Full SARIF')
core.info(JSON.stringify(sarif, null, 2))
core.endGroup()
function getQueryIdsInput(name) {
return core.getInput(name)
.split(',')
.map(q => q.trim())
.filter(q => q.length > 0)
}

View File

@@ -1,14 +0,0 @@
name: Update default CodeQL bundle
description: Updates 'src/defaults.json' to point to a new CodeQL bundle release.
runs:
using: composite
steps:
- name: Install ts-node
shell: bash
run: npm install -g ts-node
- name: Run update script
working-directory: ${{ github.action_path }}
shell: bash
run: ts-node ./index.ts

View File

@@ -1,67 +0,0 @@
import * as fs from 'fs';
import * as github from '@actions/github';
interface BundleInfo {
bundleVersion: string;
cliVersion: string;
}
interface Defaults {
bundleVersion: string;
cliVersion: string;
priorBundleVersion: string;
priorCliVersion: string;
}
function getCodeQLCliVersionForRelease(release): string {
// We do not currently tag CodeQL bundles based on the CLI version they contain.
// Instead, we use a marker file `cli-version-<version>.txt` to record the CLI version.
// This marker file is uploaded as a release asset for all new CodeQL bundles.
const cliVersionsFromMarkerFiles = release.assets
.map((asset) => asset.name.match(/cli-version-(.*)\.txt/)?.[1])
.filter((v) => v)
.map((v) => v as string);
if (cliVersionsFromMarkerFiles.length > 1) {
throw new Error(
`Release ${release.tag_name} has multiple CLI version marker files.`
);
} else if (cliVersionsFromMarkerFiles.length === 0) {
throw new Error(
`Failed to find the CodeQL CLI version for release ${release.tag_name}.`
);
}
return cliVersionsFromMarkerFiles[0];
}
async function getBundleInfoFromRelease(release): Promise<BundleInfo> {
return {
bundleVersion: release.tag_name,
cliVersion: getCodeQLCliVersionForRelease(release)
};
}
async function getNewDefaults(currentDefaults: Defaults): Promise<Defaults> {
const release = github.context.payload.release;
console.log('Updating default bundle as a result of the following release: ' +
`${JSON.stringify(release)}.`)
const bundleInfo = await getBundleInfoFromRelease(release);
return {
bundleVersion: bundleInfo.bundleVersion,
cliVersion: bundleInfo.cliVersion,
priorBundleVersion: currentDefaults.bundleVersion,
priorCliVersion: currentDefaults.cliVersion
};
}
async function main() {
const previousDefaults: Defaults = JSON.parse(fs.readFileSync('../../../src/defaults.json', 'utf8'));
const newDefaults = await getNewDefaults(previousDefaults);
// Update the source file in the repository. Calling workflows should subsequently rebuild
// the Action to update `lib/defaults.json`.
fs.writeFileSync('../../../src/defaults.json', JSON.stringify(newDefaults, null, 2) + "\n");
}
// Ideally, we'd await main() here, but that doesn't work well with `ts-node`.
// So instead we rely on the fact that Node won't exit until the event loop is empty.
main();

View File

@@ -61,7 +61,7 @@ jobs:
npm ci npm ci
- name: Verify compiled JS up to date - name: Verify compiled JS up to date
run: .github/workflows/script/check-js.sh run: npx tsx pr-checks/check-js.ts
- name: Run unit tests - name: Run unit tests
if: always() if: always()

View File

@@ -1,33 +0,0 @@
#!/bin/bash
set -eu
# Sanity check that repo is clean to start with
if [ ! -z "$(git status --porcelain)" ]; then
# If we get a fail here then this workflow needs attention...
>&2 echo "Failed: Repo should be clean before testing!"
exit 1
fi
# Wipe the lib directory in case there are extra unnecessary files in there
rm -rf lib
# Generate the JavaScript files
npm run-script build
# Check that repo is still clean
if [ ! -z "$(git status --porcelain)" ]; then
# If we get a fail here then the PR needs attention
>&2 echo "Failed: JavaScript files are not up to date. Run 'rm -rf lib && npm run-script build' to update"
git status
echo "### Transpiled JS diff" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```diff' >> $GITHUB_STEP_SUMMARY
git diff --output="$RUNNER_TEMP/js.diff"
cat "$RUNNER_TEMP/js.diff" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Reset bundled files to allow other checks to test for changes
git checkout lib
# Fail this check
exit 1
fi
echo "Success: JavaScript files are up to date"

View File

@@ -50,7 +50,7 @@ jobs:
run: npm ci run: npm ci
- name: Update bundle - name: Update bundle
uses: ./.github/actions/update-bundle run: npx tsx pr-checks/update-bundle.ts
- name: Set up CodeQL CLI from new bundle - name: Set up CodeQL CLI from new bundle
id: setup-codeql id: setup-codeql

View File

@@ -0,0 +1,68 @@
/**
* Tests for `check-cs-config.ts`.
*/
import * as assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { UserConfig } from "../src/config/db-config";
import { checkConfiguration } from "./check-cs-config";
describe("checkConfiguration", async () => {
await it("passes when actual and expected configs match", () => {
const actual: UserConfig = { name: "test-config", paths: ["src"] };
const expected = JSON.stringify(actual);
assert.doesNotThrow(() => checkConfiguration(actual, expected));
});
await it("passes when queries arrays match after sorting", () => {
const actual: UserConfig = { paths: ["b", "a", "c"] };
const expected = JSON.stringify(actual);
assert.doesNotThrow(() => checkConfiguration(actual, expected));
});
await it("throws when actual config does not match expected", () => {
const actual: UserConfig = { name: "actual-name" };
const expected = JSON.stringify({
name: "expected-name",
} satisfies UserConfig);
assert.throws(() => checkConfiguration(actual, expected), {
message: /Expected configuration does not match actual configuration/,
});
});
await it("throws when expected contents are empty", () => {
assert.throws(() => checkConfiguration({}, ""), {
message: /No expected configuration provided/,
});
});
await it("throws when expected contents are only whitespace", () => {
assert.throws(() => checkConfiguration({}, " "), {
message: /No expected configuration provided/,
});
});
await it("passes with complex config", () => {
const actual: UserConfig = {
name: "complex",
"disable-default-queries": true,
paths: ["src", "lib"],
"paths-ignore": ["test"],
"threat-models": ["remote"],
};
const expected = JSON.stringify(actual);
assert.doesNotThrow(() => checkConfiguration(actual, expected));
});
await it("trims whitespace from expected contents before parsing", () => {
const actual: UserConfig = { name: "trimmed" };
const expected = ` ${JSON.stringify(actual)} `;
assert.doesNotThrow(() => checkConfiguration(actual, expected));
});
await it("passes when both configs are empty objects", () => {
assert.doesNotThrow(() => checkConfiguration({}, "{}"));
});
});

100
pr-checks/check-cs-config.ts Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env npx tsx
/**
* Checks the code scanning configuration file generated by the
* action to ensure it contains the expected contents
*/
import * as assert from "node:assert";
import * as fs from "node:fs";
import { parseArgs } from "node:util";
import * as core from "@actions/core";
import * as yaml from "yaml";
import type { UserConfig } from "../src/config/db-config";
import { getErrorMessage } from "./util";
function sortConfigArrays(config: UserConfig) {
for (const key of Object.keys(config)) {
const value = config[key];
if (key === "queries" && Array.isArray(value)) {
config[key] = value.sort();
}
}
return config;
}
function loadActualConfig(configPath: string) {
if (!fs.existsSync(configPath)) {
throw new Error("No configuration file found");
} else {
const rawActualConfig = fs.readFileSync(configPath, "utf8");
core.startGroup("Actual generated user config");
core.info(rawActualConfig);
core.endGroup();
return yaml.parse(rawActualConfig) as UserConfig;
}
}
export function checkConfiguration(
actualConfig: UserConfig,
expectedContents: string,
) {
const rawExpectedConfig = expectedContents.trim();
if (!rawExpectedConfig) {
throw new Error("No expected configuration provided");
}
const expectedConfig = JSON.parse(rawExpectedConfig) as UserConfig;
core.startGroup("Expected generated user config");
core.info(yaml.stringify(expectedConfig));
core.endGroup();
assert.deepStrictEqual(
sortConfigArrays(actualConfig),
sortConfigArrays(expectedConfig),
"Expected configuration does not match actual configuration",
);
}
function main() {
const { values } = parseArgs({
options: {
// The path of the configuration file to check.
file: {
type: "string",
},
// The expected contents of the file.
"expected-contents": {
type: "string",
},
},
strict: true,
});
if (values.file === undefined) {
throw new Error("The '--file' input is required.");
}
if (values["expected-contents"] === undefined) {
throw new Error("The '--expected-contents' input is required.");
}
const actualConfig = loadActualConfig(values.file);
try {
checkConfiguration(actualConfig, values["expected-contents"]);
} catch (err) {
core.error(getErrorMessage(err));
return -1;
}
return 0;
}
if (import.meta.main) {
process.exit(main());
}

67
pr-checks/check-js.ts Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env npx tsx
import * as fs from "node:fs";
import * as path from "node:path";
import * as core from "@actions/core";
import { runCommand, runGit } from "./command";
import { LIB_ROOT, PR_CHECKS_DIR, REPO_ROOT } from "./config";
import { getErrorMessage } from "./util";
function main() {
// Sanity check that repo is clean to start with
try {
runGit(["diff", "--exit-code"], { allowNonZeroExitCode: false });
console.info("Repository is clean.");
} catch (err) {
// If we get a fail here then this workflow needs attention...
console.error(getErrorMessage(err));
console.error("Failed: Repo should be clean before testing!");
return -1;
}
// Wipe the lib directory in case there are extra unnecessary files in there
console.info(`Removing ${LIB_ROOT}...`);
fs.rmSync(LIB_ROOT, { recursive: true, force: true });
// Generate the JavaScript files
runCommand("npm", ["run", "build"], { execOptions: { shell: true } });
// Check that repo is still clean
try {
runGit(["diff", "--exit-code"], { allowNonZeroExitCode: false });
console.info("Repository is clean.");
} catch (err) {
// If we get a fail here then the PR needs attention
console.error(getErrorMessage(err));
console.error("Failed: JavaScript files are not up to date.");
console.error("Run 'rm -rf lib && npm run build' to update.");
const diffFile = path.join(
process.env["RUNNER_TEMP"] ?? PR_CHECKS_DIR,
"js.diff",
);
runCommand("git", ["status"]);
runCommand("git", ["diff", `--output=${diffFile}`], {
execOptions: { cwd: REPO_ROOT },
});
core.summary.addHeading("Transpiled JS diff", 3);
core.summary.addCodeBlock(fs.readFileSync(diffFile, "utf-8"), "diff");
fs.rmSync(diffFile);
// Reset bundled files to allow other checks to test for changes
runCommand("git", ["checkout", "lib"]);
return 1;
}
console.info("Success: JavaScript files are up to date");
return 0;
}
if (import.meta.main) {
process.exit(main());
}

View File

@@ -0,0 +1,159 @@
/**
* Tests for `check-sarif.ts`.
*/
import * as assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Log } from "sarif";
import { checkSarif } from "./check-sarif";
/** Builds a minimal SARIF Log with the given rule IDs spread across extensions. */
function buildSarifLog(ruleIds: string[]): Log {
return {
version: "2.1.0",
$schema:
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
runs: [
{
tool: {
driver: { name: "CodeQL" },
extensions: [
{
name: "test-pack",
rules: ruleIds.map((id) => ({ id })),
},
],
},
results: [],
},
],
};
}
describe("checkSarif", async () => {
await it("returns 0 when all expected queries ran and no unexpected queries ran", () => {
const sarif = buildSarifLog(["js/sql-injection", "js/xss"]);
const exitCode = checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "js/sql-injection, js/xss",
queriesNotRun: "js/hardcoded-credentials",
});
assert.equal(exitCode, 0);
});
await it("returns -2 when an expected query did not run", () => {
const sarif = buildSarifLog(["js/sql-injection"]);
const exitCode = checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "js/sql-injection, js/xss",
queriesNotRun: "",
});
assert.equal(exitCode, -2);
});
await it("returns -2 when an unexpected query ran", () => {
const sarif = buildSarifLog(["js/sql-injection", "js/xss"]);
const exitCode = checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "js/sql-injection",
queriesNotRun: "js/xss",
});
assert.equal(exitCode, -2);
});
await it("handles empty queries-run and queries-not-run inputs", () => {
const sarif = buildSarifLog(["js/sql-injection"]);
const exitCode = checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "",
queriesNotRun: "",
});
assert.equal(exitCode, 0);
});
await it("handles multiple extensions with rules", () => {
const sarif: Log = {
version: "2.1.0",
$schema:
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
runs: [
{
tool: {
driver: { name: "CodeQL" },
extensions: [
{
name: "pack-a",
rules: [{ id: "js/sql-injection" }],
},
{
name: "pack-b",
rules: [{ id: "js/xss" }],
},
],
},
results: [],
},
],
};
const exitCode = checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "js/sql-injection, js/xss",
queriesNotRun: "",
});
assert.equal(exitCode, 0);
});
await it("handles extensions with no rules", () => {
const sarif: Log = {
version: "2.1.0",
$schema:
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
runs: [
{
tool: {
driver: { name: "CodeQL" },
extensions: [
{ name: "empty-pack" },
{
name: "pack-with-rules",
rules: [{ id: "js/xss" }],
},
],
},
results: [],
},
],
};
const exitCode = checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "js/xss",
queriesNotRun: "js/sql-injection",
});
assert.equal(exitCode, 0);
});
await it("throws when tool extensions are undefined", () => {
const sarif: Log = {
version: "2.1.0",
$schema:
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
runs: [
{
tool: { driver: { name: "CodeQL" } },
results: [],
},
],
};
assert.throws(
() =>
checkSarif(sarif, {
sarifFile: "test.sarif",
queriesRun: "js/xss",
queriesNotRun: "",
}),
{ message: /Couldn't find tool extensions/ },
);
});
});

125
pr-checks/check-sarif.ts Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env npx tsx
/** Checks a SARIF file to see if certain queries were run and others were not run. */
import * as fs from "node:fs";
import { parseArgs } from "node:util";
import * as core from "@actions/core";
import type { ReportingDescriptor, Log } from "sarif";
import { getErrorMessage } from "./util";
type Options = { sarifFile: string; queriesRun: string; queriesNotRun: string };
function getOptions(): Options {
const { values } = parseArgs({
options: {
// The path of the SARIF file to check.
"sarif-file": {
type: "string",
},
// The query ids to check are present.
"queries-run": {
type: "string",
},
// The query ids to check are absent.
"queries-not-run": {
type: "string",
},
},
strict: true,
});
if (values["sarif-file"] === undefined) {
throw new Error("The '--sarif-file' input is required.");
}
if (values["queries-run"] === undefined) {
throw new Error("The '--queries-run' input is required.");
}
if (values["queries-not-run"] === undefined) {
throw new Error("The '--queries-not-run' input is required.");
}
return {
sarifFile: values["sarif-file"],
queriesRun: values["queries-run"],
queriesNotRun: values["queries-not-run"],
};
}
function parseQueryIdsInput(queriesRun: string): string[] {
return queriesRun
.split(",")
.map((q) => q.trim())
.filter((q) => q.length > 0);
}
export function checkSarif(sarif: Log, options: Options) {
if (sarif.runs[0].tool.extensions === undefined) {
throw new Error(`Couldn't find tool extensions in the SARIF file.`);
}
let exitCode = 0;
// Extract the rule ids from the SARIF file.
const rules: ReportingDescriptor[] = sarif.runs[0].tool.extensions.flatMap(
(ext) => ext.rules || [],
);
const ruleIds: string[] = rules.map((rule) => rule.id);
// Check that all the expected queries ran
const expectedQueriesRun = parseQueryIdsInput(options.queriesRun);
const queriesThatShouldHaveRunButDidNot = expectedQueriesRun.filter(
(queryId) => !ruleIds.includes(queryId),
);
if (queriesThatShouldHaveRunButDidNot.length > 0) {
core.error(
`The following queries were expected to run but did not: ${queriesThatShouldHaveRunButDidNot.join(", ")}`,
);
exitCode = -2;
}
// Check that all the unexpected queries did not run
const expectedQueriesNotRun = parseQueryIdsInput(options.queriesNotRun);
const queriesThatShouldNotHaveRunButDid = expectedQueriesNotRun.filter(
(queryId) => ruleIds.includes(queryId),
);
if (queriesThatShouldNotHaveRunButDid.length > 0) {
core.error(
`The following queries were NOT expected to have run but did: ${queriesThatShouldNotHaveRunButDid.join(", ")}`,
);
exitCode = -2;
}
core.startGroup("All queries that ran");
for (const rule of rules) {
core.info(`${rule.id}: ${rule.properties?.name || rule.name}`);
}
core.endGroup();
core.startGroup("Full SARIF");
core.info(JSON.stringify(sarif, null, 2));
core.endGroup();
return exitCode;
}
function main() {
try {
const options = getOptions();
const sarif: Log = JSON.parse(fs.readFileSync(options.sarifFile, "utf8"));
return checkSarif(sarif, options);
} catch (err) {
core.error(`Failed to check SARIF file: ${getErrorMessage(err)}`);
return -1;
}
}
if (import.meta.main) {
process.exit(main());
}

81
pr-checks/command.ts Normal file
View File

@@ -0,0 +1,81 @@
import { execFileSync, ExecFileSyncOptions } from "node:child_process";
import { DryRunOption, REPO_ROOT } from "./config";
/** 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;
}
}

View File

@@ -30,7 +30,13 @@ export const CHANGENOTES_DIR = path.join(REPO_ROOT, "unreleased-change-notes");
export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json");
/** The `src` directory. */ /** The `src` directory. */
const SOURCE_ROOT = path.join(REPO_ROOT, "src"); export const SOURCE_ROOT = path.join(REPO_ROOT, "src");
/** The `src` directory. */
export const LIB_ROOT = path.join(REPO_ROOT, "lib");
/** The path to `defaults.json`. */
export const DEFAULTS_FILE = path.join(SOURCE_ROOT, "defaults.json");
/** The path to the built-in languages file. */ /** The path to the built-in languages file. */
export const BUILTIN_LANGUAGES_FILE = path.join( export const BUILTIN_LANGUAGES_FILE = path.join(

View File

@@ -0,0 +1,58 @@
/*
* Tests for the update-bundle.ts script.
*/
import * as assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Defaults, getNewDefaults } from "./update-bundle";
const testDefaults: Defaults = {
bundleVersion: "codeql-bundle-v2.26.2",
cliVersion: "2.26.2",
priorBundleVersion: "codeql-bundle-v2.26.1",
priorCliVersion: "2.26.1",
};
describe("getNewDefaults", async () => {
await it("throws if there is no cli-version-*.txt asset", async () => {
assert.throws(
() => getNewDefaults({ tag_name: "foo", assets: [] }, testDefaults),
{ message: "Failed to find the CodeQL CLI version for release foo." },
);
});
await it("throws if there are multiple cli-version-*.txt assets", async () => {
assert.throws(
() =>
getNewDefaults(
{
tag_name: "foo",
assets: [
{ name: "cli-version-foo.txt" },
{ name: "cli-version-bar.txt" },
],
},
testDefaults,
),
{ message: "Release foo has multiple CLI version marker files." },
);
});
await it("finds the new bundle info", async () => {
const newDefaults = getNewDefaults(
{
tag_name: "foo",
assets: [{ name: "cli-version-1.2.3.txt" }],
},
testDefaults,
);
assert.deepEqual(newDefaults, {
bundleVersion: "foo",
cliVersion: "1.2.3",
priorBundleVersion: testDefaults.bundleVersion,
priorCliVersion: testDefaults.cliVersion,
} satisfies Defaults);
});
});

93
pr-checks/update-bundle.ts Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env npx tsx
/** Updates 'src/defaults.json' to point to a new CodeQL bundle release. */
import * as fs from "fs";
import * as github from "@actions/github";
import * as defaults from "../src/defaults.json";
import { DEFAULTS_FILE } from "./config";
interface BundleInfo {
bundleVersion: string;
cliVersion: string;
}
export type Defaults = typeof defaults;
interface Release {
tag_name: string;
assets: Array<{
name: string;
}>;
}
function getCodeQLCliVersionForRelease(release: Release): string {
// We do not currently tag CodeQL bundles based on the CLI version they contain.
// Instead, we use a marker file `cli-version-<version>.txt` to record the CLI version.
// This marker file is uploaded as a release asset for all new CodeQL bundles.
const cliVersionsFromMarkerFiles = release.assets
.map((asset) => asset.name.match(/cli-version-(.*)\.txt/)?.[1])
.filter((v) => v)
.map((v) => v as string);
if (cliVersionsFromMarkerFiles.length > 1) {
throw new Error(
`Release ${release.tag_name} has multiple CLI version marker files.`,
);
} else if (cliVersionsFromMarkerFiles.length === 0) {
throw new Error(
`Failed to find the CodeQL CLI version for release ${release.tag_name}.`,
);
}
return cliVersionsFromMarkerFiles[0];
}
function getBundleInfoFromRelease(release: Release): BundleInfo {
return {
bundleVersion: release.tag_name,
cliVersion: getCodeQLCliVersionForRelease(release),
};
}
export function getNewDefaults(
release: Release,
currentDefaults: Defaults,
): Defaults {
console.log(
"Updating default bundle as a result of the following release: " +
`${JSON.stringify(release)}.`,
);
const bundleInfo = getBundleInfoFromRelease(release);
return {
bundleVersion: bundleInfo.bundleVersion,
cliVersion: bundleInfo.cliVersion,
priorBundleVersion: currentDefaults.bundleVersion,
priorCliVersion: currentDefaults.cliVersion,
};
}
function main() {
const release: Release = github.context.payload.release;
if (release === undefined) {
console.error(`Release payload is undefined.`);
return -1;
}
const previousDefaults = defaults;
const newDefaults = getNewDefaults(release, previousDefaults);
// Update the source file in the repository. Calling workflows should subsequently rebuild
// the Action to update `lib/defaults.json`.
fs.writeFileSync(DEFAULTS_FILE, `${JSON.stringify(newDefaults, null, 2)}\n`);
return 0;
}
if (import.meta.main) {
process.exit(main());
}

View File

@@ -18,12 +18,12 @@
* [--dry-run] * [--dry-run]
*/ */
import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { type ApiClient, getApiClient } from "./api-client"; import { type ApiClient, getApiClient } from "./api-client";
import * as changelog from "./changelog"; import * as changelog from "./changelog";
import { DryRunOption, REPO_ROOT } from "./config"; import { runCommand, runGit } from "./command";
import { import {
getCurrentVersion, getCurrentVersion,
replaceVersionInPackageJson, replaceVersionInPackageJson,
@@ -65,84 +65,6 @@ export function getGitHubToken(): string {
throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_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. */ /** Returns true if the given branch exists on the origin remote. */
export function branchExistsOnRemote(branchName: string): boolean { export function branchExistsOnRemote(branchName: string): boolean {
const result = runGit(["ls-remote", "--heads", ORIGIN, branchName]); const result = runGit(["ls-remote", "--heads", ORIGIN, branchName]);