From 6e32e882a9d2a28f203b88031f5370ae97e09f4e Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 30 Jul 2026 21:24:10 +0100 Subject: [PATCH 1/6] Add `DEFAULTS_FILE` constant --- pr-checks/config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 356fe665f9..9cd23e48d2 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -22,7 +22,10 @@ export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md"); export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); /** The `src` directory. */ -const SOURCE_ROOT = path.join(REPO_ROOT, "src"); +export const SOURCE_ROOT = path.join(REPO_ROOT, "src"); + +/** The path to `defaults.json`. */ +export const DEFAULTS_FILE = path.join(SOURCE_ROOT, "defaults.json"); /** The path to the built-in languages file. */ export const BUILTIN_LANGUAGES_FILE = path.join( From 40b3f0c1bcfbff47c07f74944d89035ac1dfbdad Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 31 Jul 2026 11:44:09 +0100 Subject: [PATCH 2/6] Move `upgrade-bundle.ts` to `pr-checks` and add tests --- .github/actions/update-bundle/action.yml | 14 ---- .github/actions/update-bundle/index.ts | 67 ----------------- .github/workflows/update-bundle.yml | 2 +- pr-checks/update-bundle.test.ts | 58 +++++++++++++++ pr-checks/update-bundle.ts | 93 ++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 82 deletions(-) delete mode 100644 .github/actions/update-bundle/action.yml delete mode 100644 .github/actions/update-bundle/index.ts create mode 100644 pr-checks/update-bundle.test.ts create mode 100755 pr-checks/update-bundle.ts diff --git a/.github/actions/update-bundle/action.yml b/.github/actions/update-bundle/action.yml deleted file mode 100644 index 0216d2465b..0000000000 --- a/.github/actions/update-bundle/action.yml +++ /dev/null @@ -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 diff --git a/.github/actions/update-bundle/index.ts b/.github/actions/update-bundle/index.ts deleted file mode 100644 index a0f32312cd..0000000000 --- a/.github/actions/update-bundle/index.ts +++ /dev/null @@ -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-.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 { - return { - bundleVersion: release.tag_name, - cliVersion: getCodeQLCliVersionForRelease(release) - }; -} - -async function getNewDefaults(currentDefaults: Defaults): Promise { - 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(); diff --git a/.github/workflows/update-bundle.yml b/.github/workflows/update-bundle.yml index d3ee924e59..ba1ebb722d 100644 --- a/.github/workflows/update-bundle.yml +++ b/.github/workflows/update-bundle.yml @@ -50,7 +50,7 @@ jobs: run: npm ci - name: Update bundle - uses: ./.github/actions/update-bundle + run: npx tsx pr-checks/update-bundle.ts - name: Set up CodeQL CLI from new bundle id: setup-codeql diff --git a/pr-checks/update-bundle.test.ts b/pr-checks/update-bundle.test.ts new file mode 100644 index 0000000000..75014ccb4a --- /dev/null +++ b/pr-checks/update-bundle.test.ts @@ -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); + }); +}); diff --git a/pr-checks/update-bundle.ts b/pr-checks/update-bundle.ts new file mode 100755 index 0000000000..8e4b333dcb --- /dev/null +++ b/pr-checks/update-bundle.ts @@ -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-.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 (require.main === module) { + process.exit(main()); +} From 58f39a7d42573bc829dedf07fca13bdd4c21e495 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 31 Jul 2026 13:24:27 +0100 Subject: [PATCH 3/6] Convert `check-sarif/index.js` and add tests --- .github/actions/check-sarif/action.yml | 22 +++- .github/actions/check-sarif/index.js | 43 ------- pr-checks/check-sarif.test.ts | 159 +++++++++++++++++++++++++ pr-checks/check-sarif.ts | 125 +++++++++++++++++++ 4 files changed, 304 insertions(+), 45 deletions(-) delete mode 100644 .github/actions/check-sarif/index.js create mode 100644 pr-checks/check-sarif.test.ts create mode 100644 pr-checks/check-sarif.ts diff --git a/.github/actions/check-sarif/action.yml b/.github/actions/check-sarif/action.yml index bfa1c3b9d1..936747cb69 100644 --- a/.github/actions/check-sarif/action.yml +++ b/.github/actions/check-sarif/action.yml @@ -16,5 +16,23 @@ inputs: Comma separated list of query ids that should NOT be included in this SARIF file. runs: - using: node24 - main: index.js + using: "composite" + 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 diff --git a/.github/actions/check-sarif/index.js b/.github/actions/check-sarif/index.js deleted file mode 100644 index c002b07697..0000000000 --- a/.github/actions/check-sarif/index.js +++ /dev/null @@ -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) -} diff --git a/pr-checks/check-sarif.test.ts b/pr-checks/check-sarif.test.ts new file mode 100644 index 0000000000..81290659dd --- /dev/null +++ b/pr-checks/check-sarif.test.ts @@ -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/ }, + ); + }); +}); diff --git a/pr-checks/check-sarif.ts b/pr-checks/check-sarif.ts new file mode 100644 index 0000000000..7c744c34c2 --- /dev/null +++ b/pr-checks/check-sarif.ts @@ -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 (require.main === module) { + process.exit(main()); +} From f8f91fb336a11d155d378e7574f6070403fb265c Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 31 Jul 2026 13:50:04 +0100 Subject: [PATCH 4/6] Move `check-codescanning-config/index.ts` to `pr-checks` and add tests --- .../check-codescanning-config/action.yml | 11 +- .../check-codescanning-config/index.ts | 49 --------- pr-checks/check-cs-config.test.ts | 68 ++++++++++++ pr-checks/check-cs-config.ts | 100 ++++++++++++++++++ 4 files changed, 173 insertions(+), 55 deletions(-) delete mode 100644 .github/actions/check-codescanning-config/index.ts create mode 100644 pr-checks/check-cs-config.test.ts create mode 100755 pr-checks/check-cs-config.ts diff --git a/.github/actions/check-codescanning-config/action.yml b/.github/actions/check-codescanning-config/action.yml index 0c65c3a41d..cac79312e7 100644 --- a/.github/actions/check-codescanning-config/action.yml +++ b/.github/actions/check-codescanning-config/action.yml @@ -54,16 +54,15 @@ runs: env: CODEQL_ACTION_TEST_MODE: 'true' - - name: Install dependencies - shell: bash - run: npm install --location=global ts-node js-yaml - - name: Check config - working-directory: ${{ github.action_path }} shell: bash env: 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 shell: bash if: always() diff --git a/.github/actions/check-codescanning-config/index.ts b/.github/actions/check-codescanning-config/index.ts deleted file mode 100644 index ea99ca3653..0000000000 --- a/.github/actions/check-codescanning-config/index.ts +++ /dev/null @@ -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) - } -} diff --git a/pr-checks/check-cs-config.test.ts b/pr-checks/check-cs-config.test.ts new file mode 100644 index 0000000000..c2b470a73e --- /dev/null +++ b/pr-checks/check-cs-config.test.ts @@ -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({}, "{}")); + }); +}); diff --git a/pr-checks/check-cs-config.ts b/pr-checks/check-cs-config.ts new file mode 100755 index 0000000000..f520fc4034 --- /dev/null +++ b/pr-checks/check-cs-config.ts @@ -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 (require.main === module) { + process.exit(main()); +} From fccc1666838922cd6ec51b79f1b57525bdac1d03 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 31 Jul 2026 13:53:05 +0100 Subject: [PATCH 5/6] Move subprocess helpers from `update-release-branch.ts` to `command.ts` --- pr-checks/command.ts | 81 +++++++++++++++++++++++++++++ pr-checks/update-release-branch.ts | 82 +----------------------------- 2 files changed, 83 insertions(+), 80 deletions(-) create mode 100644 pr-checks/command.ts diff --git a/pr-checks/command.ts b/pr-checks/command.ts new file mode 100644 index 0000000000..459265c0d6 --- /dev/null +++ b/pr-checks/command.ts @@ -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; + } +} diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 088da59281..39c5c0b3de 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -18,12 +18,12 @@ * [--dry-run] */ -import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; import * as changelog from "./changelog"; -import { DryRunOption, REPO_ROOT } from "./config"; +import { runCommand, runGit } from "./command"; import { getCurrentVersion, replaceVersionInPackageJson, @@ -65,84 +65,6 @@ export function getGitHubToken(): string { throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); } -/** Options for {@link runCommand}. */ -export interface RunCommandOptions extends DryRunOption { - /** Options for `execFileSync`. */ - execOptions?: ExecFileSyncOptions; -} - -/** - * Runs a command, streaming output to the console by default. - * - * @param command The name of the command to run. - * @param args The arguments for the command. - * @throws When the process exits with a non-zero exit code. - * @param options How to run the command. - */ -export function runCommand( - command: string, - args: string[], - options?: RunCommandOptions, -) { - if (!options?.dryRun) { - console.log(`Running \`${command} ${args.join(" ")}\`.`); - return execFileSync(command, args, { - stdio: "inherit", - cwd: REPO_ROOT, - ...options?.execOptions, - }); - } else { - console.info( - `[DRY RUN] Would have executed '${command} ${args.join(" ")}'`, - ); - return ""; - } -} - -/** Options for {@link runGit}. */ -export interface RunGitOptions extends DryRunOption { - /** When true, non-zero exit codes will not throw. */ - allowNonZeroExitCode?: boolean; -} - -/** - * Runs `git` with the given `args` and returns the stdout. - * - * @param args - Arguments to pass to `git`. - * @param options - Optional settings. - * @throws If `git` does not exit successfully, unless - * `options.allowNonZeroExitCode` is `true`. - * @returns The trimmed stdout output. - */ -export function runGit(args: string[], options?: RunGitOptions): string { - const execOptions: ExecFileSyncOptions = { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }; - - try { - const result = runCommand("git", args, { - dryRun: options?.dryRun, - execOptions, - }) as string; - return result.trimEnd(); - } catch (error: unknown) { - if (options?.allowNonZeroExitCode) { - // execFileSync throws an object with `stdout` when the process exits - // with a non-zero code. - const execError = error as { stdout?: Buffer | string }; - if (typeof execError.stdout === "string") { - return execError.stdout.trimEnd(); - } - if (Buffer.isBuffer(execError.stdout)) { - return execError.stdout.toString("utf8").trimEnd(); - } - return ""; - } - throw error; - } -} - /** Returns true if the given branch exists on the origin remote. */ export function branchExistsOnRemote(branchName: string): boolean { const result = runGit(["ls-remote", "--heads", ORIGIN, branchName]); From 7e36708483fb8afff70018c44c3b5c8e49f04d1b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 31 Jul 2026 14:32:53 +0100 Subject: [PATCH 6/6] Convert `check-js.sh` to TypeScript --- .github/workflows/pr-checks.yml | 2 +- .github/workflows/script/check-js.sh | 33 -------------- pr-checks/check-js.ts | 67 ++++++++++++++++++++++++++++ pr-checks/config.ts | 3 ++ 4 files changed, 71 insertions(+), 34 deletions(-) delete mode 100755 .github/workflows/script/check-js.sh create mode 100755 pr-checks/check-js.ts diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ac61475d62..d835d15edb 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -55,7 +55,7 @@ jobs: npm ci - 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 if: always() diff --git a/.github/workflows/script/check-js.sh b/.github/workflows/script/check-js.sh deleted file mode 100755 index 57638dcf25..0000000000 --- a/.github/workflows/script/check-js.sh +++ /dev/null @@ -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" diff --git a/pr-checks/check-js.ts b/pr-checks/check-js.ts new file mode 100755 index 0000000000..835bb00564 --- /dev/null +++ b/pr-checks/check-js.ts @@ -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 (require.main === module) { + process.exit(main()); +} diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 9cd23e48d2..40ca7037a2 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -24,6 +24,9 @@ export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); /** The `src` directory. */ 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");