Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uploads 6 artifacts per workflow run (3 OS × 2 Node versions) without setting retention-days or if-no-files-found. To limit storage growth and make failures obvious when meta.json isn’t produced, set a short retention (e.g. 7 days, consistent with other PR-check artifacts) and consider if-no-files-found: error.

Suggested change
path: meta.json
path: meta.json
retention-days: 7
if-no-files-found: error

Copilot uses AI. Check for mistakes.

- name: Run unit tests
if: always()
run: npm test
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions pr-checks/api-client.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand All @@ -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"];
153 changes: 146 additions & 7 deletions pr-checks/bundle-metadata.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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];

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;
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)}`);
console.info(` ${inputName}: ${toMB(inputData.bytesInOutput)}`);
}
}
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions pr-checks/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
31 changes: 13 additions & 18 deletions pr-checks/sync-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
},
);
Expand Down Expand Up @@ -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();
Expand All @@ -146,7 +144,7 @@ async function patchBranchProtectionRule(
checks: Set<string>,
) {
await client.rest.repos.setStatusCheckContexts({
...codeqlActionRepo,
...CODEQL_ACTION_REPO,
branch,
contexts: Array.from(checks),
});
Expand All @@ -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,
});

Expand Down Expand Up @@ -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",
Expand Down
Loading