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 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", diff --git a/pr-checks/api-client.ts b/pr-checks/api-client.ts index 93675dba77..95a7f8916e 100644 --- a/pr-checks/api-client.ts +++ b/pr-checks/api-client.ts @@ -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"]; diff --git a/pr-checks/bundle-metadata.ts b/pr-checks/bundle-metadata.ts index 25c282e9ae..67a7d99409 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,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)}`); + } + } } } } 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", +); diff --git a/pr-checks/sync-checks.ts b/pr-checks/sync-checks.ts index ef07531107..ae8a166a8a 100755 --- a/pr-checks/sync-checks.ts +++ b/pr-checks/sync-checks.ts @@ -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 { 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, ) { 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 { 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",