From 8b990db7d75c415112156571a826803e85a20938 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Apr 2026 12:18:17 +0100 Subject: [PATCH 1/6] Do not run `bundle-metadata.ts` as part of `npm run build` --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b8e8553b8..db7a8e3151 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "CodeQL action", "scripts": { "_build_comment": "echo 'Run the full build so we typecheck the project and can reuse the transpiled files in npm test'", - "build": "./scripts/check-node-modules.sh && npm run transpile && node build.mjs && npx tsx ./pr-checks/bundle-metadata.ts", + "build": "./scripts/check-node-modules.sh && npm run transpile && node build.mjs", "lint": "eslint --report-unused-disable-directives --max-warnings=0 .", "lint-ci": "SARIF_ESLINT_IGNORE_SUPPRESSED=true eslint --report-unused-disable-directives --max-warnings=0 . --format @microsoft/eslint-formatter-sarif --output-file=eslint.sarif", "lint-fix": "eslint --report-unused-disable-directives --max-warnings=0 . --fix", From 4acf6eb1870e68921096937075d7695788dac32f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Apr 2026 12:22:50 +0100 Subject: [PATCH 2/6] Upload `meta.json` as workflow artifact --- .github/workflows/pr-checks.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index f240997030..4369fd9442 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -52,6 +52,12 @@ jobs: - name: Verify compiled JS up to date run: .github/workflows/script/check-js.sh + - name: Upload esbuild metadata + uses: actions/upload-artifact@v7 + with: + name: bundle-metadata-${{ matrix.os }}-${{ matrix.node-version }} + path: meta.json + - name: Run unit tests if: always() run: npm test From dbfd510456401890ff6e6340834d180ef93800fc Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Apr 2026 17:09:18 +0100 Subject: [PATCH 3/6] Make `token` parameter reusable --- pr-checks/api-client.ts | 15 +++++++++++++++ pr-checks/sync-checks.ts | 16 ++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pr-checks/api-client.ts b/pr-checks/api-client.ts index 93675dba77..2622515d00 100644 --- a/pr-checks/api-client.ts +++ b/pr-checks/api-client.ts @@ -1,3 +1,5 @@ +import { ParseArgsConfig } from "node:util"; + import * as githubUtils from "@actions/github/lib/utils"; import { type Octokit } from "@octokit/core"; import { type PaginateInterface } from "@octokit/plugin-paginate-rest"; @@ -11,3 +13,16 @@ export function getApiClient(token: string): ApiClient { const opts = githubUtils.getOctokitOptions(token); return new githubUtils.GitHub(opts); } + +export interface TokenOption { + /** The token to use to authenticate to the GitHub API. */ + token?: string; +} + +/** Command-line argument parser settings for the token parameter. */ +export const TOKEN_OPTION_CONFIG = { + // The token to use to authenticate to the API. + token: { + type: "string", + }, +} satisfies ParseArgsConfig["options"]; diff --git a/pr-checks/sync-checks.ts b/pr-checks/sync-checks.ts index ef07531107..ab9d237cad 100755 --- a/pr-checks/sync-checks.ts +++ b/pr-checks/sync-checks.ts @@ -7,16 +7,19 @@ import { parseArgs } from "node:util"; import * as yaml from "yaml"; -import { type ApiClient, getApiClient } from "./api-client"; +import { + type ApiClient, + getApiClient, + TOKEN_OPTION_CONFIG, + TokenOption, +} from "./api-client"; import { OLDEST_SUPPORTED_MAJOR_VERSION, PR_CHECK_EXCLUDED_FILE, } from "./config"; /** Represents the command-line options. */ -export interface Options { - /** The token to use to authenticate to the GitHub API. */ - token?: string; +export interface Options extends TokenOption { /** The git ref to use the checks for. */ ref?: string; /** Whether to actually apply the changes or not. */ @@ -205,10 +208,7 @@ async function updateBranch( async function main(): Promise { const { values: options } = parseArgs({ options: { - // The token to use to authenticate to the API. - token: { - type: "string", - }, + ...TOKEN_OPTION_CONFIG, // The git ref for which to retrieve the check runs. ref: { type: "string", From 989d6f4689b2f2c82d54b4a98e9023e1705d6408 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Apr 2026 17:29:36 +0100 Subject: [PATCH 4/6] Move `CODEQL_ACTION_REPO` out of `sync-checks.ts` --- pr-checks/api-client.ts | 6 ++++++ pr-checks/sync-checks.ts | 15 +++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pr-checks/api-client.ts b/pr-checks/api-client.ts index 2622515d00..95a7f8916e 100644 --- a/pr-checks/api-client.ts +++ b/pr-checks/api-client.ts @@ -5,6 +5,12 @@ import { type Octokit } from "@octokit/core"; import { type PaginateInterface } from "@octokit/plugin-paginate-rest"; import { type Api } from "@octokit/plugin-rest-endpoint-methods"; +/** Identifies the CodeQL Action repository. */ +export const CODEQL_ACTION_REPO = { + owner: "github", + repo: "codeql-action", +}; + /** The type of the Octokit client. */ export type ApiClient = Octokit & Api & { paginate: PaginateInterface }; diff --git a/pr-checks/sync-checks.ts b/pr-checks/sync-checks.ts index ab9d237cad..ae8a166a8a 100755 --- a/pr-checks/sync-checks.ts +++ b/pr-checks/sync-checks.ts @@ -9,6 +9,7 @@ import * as yaml from "yaml"; import { type ApiClient, + CODEQL_ACTION_REPO, getApiClient, TOKEN_OPTION_CONFIG, TokenOption, @@ -28,12 +29,6 @@ export interface Options extends TokenOption { verbose: boolean; } -/** Identifies the CodeQL Action repository. */ -const codeqlActionRepo = { - owner: "github", - repo: "codeql-action", -}; - /** Represents a configuration of which checks should not be set up as required checks. */ export interface Exclusions { /** A list of strings that, if contained in a check name, are excluded. */ @@ -103,7 +98,7 @@ async function getChecksFor( const response = await client.paginate( "GET /repos/{owner}/{repo}/commits/{ref}/check-runs", { - ...codeqlActionRepo, + ...CODEQL_ACTION_REPO, ref, }, ); @@ -136,7 +131,7 @@ async function getChecksFor( /** Gets the current list of release branches. */ async function getReleaseBranches(client: ApiClient): Promise { const refs = await client.rest.git.listMatchingRefs({ - ...codeqlActionRepo, + ...CODEQL_ACTION_REPO, ref: "heads/releases/v", }); return refs.data.map((ref) => ref.ref).sort(); @@ -149,7 +144,7 @@ async function patchBranchProtectionRule( checks: Set, ) { await client.rest.repos.setStatusCheckContexts({ - ...codeqlActionRepo, + ...CODEQL_ACTION_REPO, branch, contexts: Array.from(checks), }); @@ -166,7 +161,7 @@ async function updateBranch( // Query the current set of required checks for this branch. const currentContexts = await client.rest.repos.getAllStatusCheckContexts({ - ...codeqlActionRepo, + ...CODEQL_ACTION_REPO, branch, }); From af6b8994414fa10d976f4b57caceec9ca6114087 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Apr 2026 18:09:02 +0100 Subject: [PATCH 5/6] Fetch baseline bundle metadata --- pr-checks/bundle-metadata.ts | 110 ++++++++++++++++++++++++++++++++++- pr-checks/config.ts | 6 ++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/pr-checks/bundle-metadata.ts b/pr-checks/bundle-metadata.ts index 25c282e9ae..8a7df630fe 100755 --- a/pr-checks/bundle-metadata.ts +++ b/pr-checks/bundle-metadata.ts @@ -1,8 +1,43 @@ #!/usr/bin/env npx tsx import * as fs from "node:fs/promises"; +import { parseArgs, ParseArgsConfig } from "node:util"; -import { BUNDLE_METADATA_FILE } from "./config"; +import * as exec from "@actions/exec"; + +import { + ApiClient, + CODEQL_ACTION_REPO, + getApiClient, + TOKEN_OPTION_CONFIG, +} from "./api-client"; +import { BASELINE_BUNDLE_METADATA_FILE, BUNDLE_METADATA_FILE } from "./config"; + +const optionsConfig = { + ...TOKEN_OPTION_CONFIG, + branch: { + type: "string", + default: "main", + }, + runner: { + type: "string", + default: "macos-latest", + }, + "node-version": { + type: "string", + default: "24", + }, +} satisfies ParseArgsConfig["options"]; + +function parseOptions() { + const { values: options } = parseArgs({ + options: optionsConfig, + }); + + return options; +} + +type Options = ReturnType; interface InputInfo { bytesInOutput: number; @@ -23,7 +58,80 @@ function toMB(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; } +async function getBaselineFrom(client: ApiClient, options: Options) { + const workflowRun = await client.rest.actions.listWorkflowRuns({ + ...CODEQL_ACTION_REPO, + branch: options.branch, + workflow_id: "pr-checks.yml", + status: "success", + per_page: 1, + event: "push", + }); + + if (workflowRun.data.total_count === 0) { + throw new Error( + `Expected to find a 'pr-checks.yml' run for '${options.branch}', but found none.`, + ); + } + + const expectedArtifactName = `bundle-metadata-${options.runner}-${options["node-version"]}`; + const artifacts = await client.rest.actions.listWorkflowRunArtifacts({ + ...CODEQL_ACTION_REPO, + run_id: workflowRun.data.workflow_runs[0].id, + name: expectedArtifactName, + }); + + if (artifacts.data.total_count === 0) { + throw new Error( + `Expected to find an artifact named '${expectedArtifactName}', but found none.`, + ); + } + + const downloadInfo = await client.rest.actions.downloadArtifact({ + ...CODEQL_ACTION_REPO, + artifact_id: artifacts.data.artifacts[0].id, + archive_format: "zip", + }); + + // This works fine for us with our version of Octokit, so we don't need to + // worry about over-complicating this script and handle other possibilities. + if (downloadInfo.data instanceof ArrayBuffer) { + const archivePath = `${expectedArtifactName}.zip`; + await fs.writeFile(archivePath, Buffer.from(downloadInfo.data)); + + console.info(`Extracting zip file: ${archivePath}`); + await exec.exec("unzip", ["-o", archivePath, "-d", "."]); + + // We no longer need the archive after unzipping it. + await fs.rm(archivePath); + + // Check that we have the expected file. + try { + await fs.stat(BASELINE_BUNDLE_METADATA_FILE); + } catch (err) { + throw new Error( + `Expected '${BASELINE_BUNDLE_METADATA_FILE}' to have been extracted, but it does not exist: ${err}`, + ); + } + + const baselineData = await fs.readFile(BASELINE_BUNDLE_METADATA_FILE); + return JSON.parse(String(baselineData)) as Metadata; + } else { + throw new Error("Expected to receive artifact data, but didn't."); + } +} + async function main() { + const options = parseOptions(); + + if (options.token === undefined) { + throw new Error("Missing --token"); + } + + // Initialise the API client. + const client = getApiClient(options.token); + const baselineMetadata = await getBaselineFrom(client, options); + const fileContents = await fs.readFile(BUNDLE_METADATA_FILE); const metadata = JSON.parse(String(fileContents)) as Metadata; diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 253843f226..35acf6ddbf 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -11,3 +11,9 @@ export const PR_CHECK_EXCLUDED_FILE = path.join(PR_CHECKS_DIR, "excluded.yml"); /** The path to the esbuild metadata file. */ export const BUNDLE_METADATA_FILE = path.join(PR_CHECKS_DIR, "..", "meta.json"); + +/** The path of the baseline esbuild metadata file, once extracted from a workflow artifact. */ +export const BASELINE_BUNDLE_METADATA_FILE = path.join( + PR_CHECKS_DIR, + "meta.json", +); From 2ab95cc5abcf65b7a2f8980ef0ffae67e4d70b30 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 10 Apr 2026 18:18:41 +0100 Subject: [PATCH 6/6] Compare baseline to current metadata --- pr-checks/bundle-metadata.ts | 47 ++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/pr-checks/bundle-metadata.ts b/pr-checks/bundle-metadata.ts index 8a7df630fe..67a7d99409 100755 --- a/pr-checks/bundle-metadata.ts +++ b/pr-checks/bundle-metadata.ts @@ -135,17 +135,48 @@ async function main() { const fileContents = await fs.readFile(BUNDLE_METADATA_FILE); const metadata = JSON.parse(String(fileContents)) as Metadata; + console.info("Comparing bundle metadata to baseline..."); + + const filesInBaseline = new Set(Object.keys(baselineMetadata.outputs)); + const filesInCurrent = new Set(Object.keys(metadata.outputs)); + + const filesNotPresent = filesInBaseline.difference(filesInCurrent); + if (filesNotPresent.size > 0) { + console.info(`Found ${filesNotPresent.size} file(s) which were removed:`); + for (const removedFile of filesNotPresent) { + console.info(` - ${removedFile}`); + } + } + for (const [outputFile, outputData] of Object.entries( metadata.outputs, ).reverse()) { - console.info(`${outputFile}: ${toMB(outputData.bytes)}`); - - for (const [inputName, inputData] of Object.entries(outputData.inputs)) { - // Ignore any inputs that make up less than 5% of the output. - const percentage = (inputData.bytesInOutput / outputData.bytes) * 100.0; - if (percentage < 5.0) continue; - - console.info(` ${inputName}: ${toMB(inputData.bytesInOutput)}`); + const baselineOutputData = baselineMetadata.outputs[outputFile]; + + if (baselineOutputData === undefined) { + console.info(`${outputFile}: New file (${toMB(outputData.bytes)})`); + } else { + const percentageDifference = + ((outputData.bytes - baselineOutputData.bytes) / + baselineOutputData.bytes) * + 100.0; + + if (Math.abs(percentageDifference) >= 5) { + console.info( + `${outputFile}: ${toMB(outputData.bytes)} (${percentageDifference.toFixed(2)}%)`, + ); + + for (const [inputName, inputData] of Object.entries( + outputData.inputs, + )) { + // Ignore any inputs that make up less than 5% of the output. + const percentage = + (inputData.bytesInOutput / outputData.bytes) * 100.0; + if (percentage < 5.0) continue; + + console.info(` ${inputName}: ${toMB(inputData.bytesInOutput)}`); + } + } } } }