Compare commits

...

9 Commits

Author SHA1 Message Date
Michael B. Gale a6fad803a6 Rename meta.json to build-metadata.json 2026-04-30 14:30:25 +01:00
Michael B. Gale e09b8dbe8a Merge remote-tracking branch 'origin/main' into mbg/bundle-metadata-improvements 2026-04-30 14:28:42 +01:00
Michael B. Gale 6e368b7e89 Set retention and if-no-files-found 2026-04-13 13:18:56 +01:00
Michael B. Gale 2ab95cc5ab Compare baseline to current metadata 2026-04-10 18:47:56 +01:00
Michael B. Gale af6b899441 Fetch baseline bundle metadata 2026-04-10 18:47:56 +01:00
Michael B. Gale 989d6f4689 Move CODEQL_ACTION_REPO out of sync-checks.ts 2026-04-10 17:29:36 +01:00
Michael B. Gale dbfd510456 Make token parameter reusable 2026-04-10 17:09:18 +01:00
Michael B. Gale 4acf6eb187 Upload meta.json as workflow artifact 2026-04-10 12:22:50 +01:00
Michael B. Gale 8b990db7d7 Do not run bundle-metadata.ts as part of npm run build 2026-04-10 12:18:17 +01:00
8 changed files with 202 additions and 29 deletions
+8
View File
@@ -52,6 +52,14 @@ 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: build-metadata.json
retention-days: ${{ (github.ref_name == github.event.repository.default_branch && 90) || 7 }}
if-no-files-found: error
- name: Run unit tests
if: always()
run: npm test
+1 -1
View File
@@ -12,4 +12,4 @@ eslint.sarif
# for local incremental compilation
tsconfig.tsbuildinfo
# esbuild metadata file
meta.json
build-metadata.json
+1 -1
View File
@@ -82,6 +82,6 @@ const context = await esbuild.context({
});
const result = await context.rebuild();
await writeFile(join(__dirname, "meta.json"), JSON.stringify(result.metafile));
await writeFile(join(__dirname, "build-metadata.json"), JSON.stringify(result.metafile));
await context.dispose();
+1 -1
View File
@@ -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",
+21
View File
@@ -1,8 +1,16 @@
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";
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 };
@@ -11,3 +19,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"];
+146 -7
View File
@@ -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<typeof parseOptions>;
interface InputInfo {
bytesInOutput: number;
@@ -23,21 +58,125 @@ 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;
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)}`);
const baselineOutputData = baselineMetadata.outputs[outputFile];
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;
if (baselineOutputData === undefined) {
console.info(`${outputFile}: New file (${toMB(outputData.bytes)})`);
} else {
const percentageDifference =
((outputData.bytes - baselineOutputData.bytes) /
baselineOutputData.bytes) *
100.0;
console.info(` ${inputName}: ${toMB(inputData.bytesInOutput)}`);
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)}`);
}
}
}
}
}
+11 -1
View File
@@ -10,7 +10,17 @@ export const PR_CHECKS_DIR = __dirname;
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");
export const BUNDLE_METADATA_FILE = path.join(
PR_CHECKS_DIR,
"..",
"build-metadata.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,
"build-metadata.json",
);
/** The `src` directory. */
const SOURCE_ROOT = path.join(PR_CHECKS_DIR, "..", "src");
+13 -18
View File
@@ -7,16 +7,20 @@ import { parseArgs } from "node:util";
import * as yaml from "yaml";
import { type ApiClient, getApiClient } from "./api-client";
import {
type ApiClient,
CODEQL_ACTION_REPO,
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. */
@@ -25,12 +29,6 @@ export interface Options {
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. */
@@ -100,7 +98,7 @@ async function getChecksFor(
const response = await client.paginate(
"GET /repos/{owner}/{repo}/commits/{ref}/check-runs",
{
...codeqlActionRepo,
...CODEQL_ACTION_REPO,
ref,
},
);
@@ -133,7 +131,7 @@ async function getChecksFor(
/** Gets the current list of release branches. */
async function getReleaseBranches(client: ApiClient): Promise<string[]> {
const refs = await client.rest.git.listMatchingRefs({
...codeqlActionRepo,
...CODEQL_ACTION_REPO,
ref: "heads/releases/v",
});
return refs.data.map((ref) => ref.ref).sort();
@@ -146,7 +144,7 @@ async function patchBranchProtectionRule(
checks: Set<string>,
) {
await client.rest.repos.setStatusCheckContexts({
...codeqlActionRepo,
...CODEQL_ACTION_REPO,
branch,
contexts: Array.from(checks),
});
@@ -163,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,
});
@@ -205,10 +203,7 @@ async function updateBranch(
async function main(): Promise<void> {
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",