#!/usr/bin/env npx tsx import * as fs from "fs"; import * as path from "path"; import * as yaml from "yaml"; import { KnownLanguage } from "../src/languages"; /** Known workflow input names. */ enum KnownInputName { GoVersion = "go-version", JavaVersion = "java-version", PythonVersion = "python-version", DotnetVersion = "dotnet-version", } /** * Represents workflow input definitions. */ interface WorkflowInput { type: string; description: string; required: boolean; default: string; } /** A partial mapping from known input names to input definitions. */ type WorkflowInputs = Partial>; /** * Represents PR check specifications. */ interface Specification extends JobSpecification { /** Workflow-level input definitions forwarded to `workflow_dispatch`/`workflow_call`. */ inputs?: Record; /** CodeQL bundle versions to test against. Defaults to `DEFAULT_TEST_VERSIONS`. */ versions?: string[]; /** Operating system prefixes used to select runner images (e.g. `["ubuntu", "macos"]`). */ operatingSystems?: string[]; /** Per-OS version overrides. If specified for an OS, only those versions are tested on that OS. */ osCodeQlVersions?: Record; /** Whether to use the all-platform CodeQL bundle. */ useAllPlatformBundle?: string; /** Values for the `analysis-kinds` matrix dimension. */ analysisKinds?: string[]; /** Container image configuration for the job. */ container?: any; /** Service containers for the job. */ services?: any; /** Additional jobs to run after the main PR check job. */ validationJobs?: Record; /** If set, this check is part of a named collection that gets its own caller workflow. */ collection?: string; } /** Minimal type to represent steps in Actions workflows. */ interface Step { name?: string; [other: string]: any; } /** Represents job specifications. */ interface JobSpecification { /** The display name for the check. */ name: string; /** Custom permissions override for the job. */ permissions?: Record; /** Extra environment variables for the job. */ env?: Record; /** The workflow steps specific to this check. */ steps: Step[]; installNode?: boolean; installGo?: boolean; installJava?: boolean; installPython?: boolean; installDotNet?: boolean; installYq?: boolean; } /** Describes language/framework-specific steps and inputs. */ interface LanguageSetup { specProperty: keyof JobSpecification; /** The names of the known inputs which are required for this setup step. */ inputs?: KnownInputName[]; steps: Step[]; } /** Describes partial mappings from known languages to their specific setup information. */ type LanguageSetups = Partial>; // The default set of CodeQL Bundle versions to use for the PR checks. const defaultTestVersions = [ // The oldest supported CodeQL version. If bumping, update `CODEQL_MINIMUM_VERSION` in `codeql.ts` "stable-v2.17.6", // The last CodeQL release in the 2.18 series. "stable-v2.18.4", // The last CodeQL release in the 2.19 series. "stable-v2.19.4", // The last CodeQL release in the 2.20 series. "stable-v2.20.7", // The last CodeQL release in the 2.21 series. "stable-v2.21.4", // The last CodeQL release in the 2.22 series. "stable-v2.22.4", // The default version of CodeQL for Dotcom, as determined by feature flags. "default", // The version of CodeQL shipped with the Action in `defaults.json`. During the release process // for a new CodeQL release, there will be a period of time during which this will be newer than // the default version on Dotcom. "linked", // A nightly build directly from the our private repo, built in the last 24 hours. "nightly-latest", ]; /** The default versions we use for languages / frameworks, if not specified as a workflow input. */ const defaultLanguageVersions = { javascript: "20.x", go: ">=1.21.0", java: "17", python: "3.13", csharp: "9.x", } as const satisfies Partial>; /** A mapping from known input names to their specifications. */ const inputSpecs: WorkflowInputs = { [KnownInputName.GoVersion]: { type: "string", description: "The version of Go to install", required: false, default: defaultLanguageVersions.go, }, [KnownInputName.JavaVersion]: { type: "string", description: "The version of Java to install", required: false, default: defaultLanguageVersions.java, }, [KnownInputName.PythonVersion]: { type: "string", description: "The version of Python to install", required: false, default: defaultLanguageVersions.python, }, [KnownInputName.DotnetVersion]: { type: "string", description: "The version of .NET to install", required: false, default: defaultLanguageVersions.csharp, }, }; /** Obtains a `WorkflowInputs` object for all the inputs given by `requiredInputs`. */ function getSetupInputs(requiredInputs: Set): WorkflowInputs { const inputs: WorkflowInputs = {}; // Copy the input specifications for the requested inputs into the output. for (const requiredInput of requiredInputs) { inputs[requiredInput] = inputSpecs[requiredInput]; } return inputs; } /** A partial mapping from known languages to their specific setup information. */ const languageSetups: LanguageSetups = { javascript: { specProperty: "installNode", steps: [ { name: "Install Node.js", uses: "actions/setup-node@v6", with: { "node-version": defaultLanguageVersions.javascript, cache: "npm", }, }, { name: "Install dependencies", run: "npm ci", }, ], }, go: { specProperty: "installGo", inputs: [KnownInputName.GoVersion], steps: [ { name: "Install Go", uses: "actions/setup-go@v6", with: { "go-version": `\${{ inputs.go-version || '${defaultLanguageVersions.go}' }}`, // to avoid potentially misleading autobuilder results where we expect it to download // dependencies successfully, but they actually come from a warm cache cache: false, }, }, ], }, java: { specProperty: "installJava", inputs: [KnownInputName.JavaVersion], steps: [ { name: "Install Java", uses: "actions/setup-java@v5", with: { "java-version": `\${{ inputs.java-version || '${defaultLanguageVersions.java}' }}`, distribution: "temurin", }, }, ], }, python: { specProperty: "installPython", inputs: [KnownInputName.PythonVersion], steps: [ { name: "Install Python", uses: "actions/setup-python@v6", with: { "python-version": `\${{ inputs.python-version || '${defaultLanguageVersions.python}' }}`, }, }, ], }, csharp: { specProperty: "installDotNet", inputs: [KnownInputName.DotnetVersion], steps: [ { name: "Install .NET", uses: "actions/setup-dotnet@v5", with: { "dotnet-version": `\${{ inputs.dotnet-version || '${defaultLanguageVersions.csharp}' }}`, }, }, ], }, }; // This is essentially an arbitrary version of `yq`, which happened to be the one that // `choco` fetched when we moved away from using that here. // See https://github.com/github/codeql-action/pull/3423 const YQ_VERSION = "v4.50.1"; const THIS_DIR = __dirname; const CHECKS_DIR = path.join(THIS_DIR, "checks"); const OUTPUT_DIR = path.join(THIS_DIR, "..", ".github", "workflows"); /** * Loads and parses a YAML file. */ function loadYaml(filePath: string): yaml.Document { const content = fs.readFileSync(filePath, "utf8"); return yaml.parseDocument(content); } /** Computes the union of all given `sets`. */ function unionAll(sets: Array>): Set { return sets.reduce((prev, cur) => prev.union(cur), new Set()); } /** * Serialize a value to YAML and write it to a file, prepended with the * standard header comment. */ function writeYaml(filePath: string, workflow: any): void { const header = `# Warning: This file is generated automatically, and should not be modified. # Instead, please modify the template in the pr-checks directory and run: # pr-checks/sync.sh # to regenerate this file. `; const workflowDoc = new yaml.Document(workflow, { aliasDuplicateObjects: false, }); const yamlStr = yaml.stringify(workflowDoc, { aliasDuplicateObjects: false, singleQuote: true, lineWidth: 0, }); fs.writeFileSync(filePath, stripTrailingWhitespace(header + yamlStr), "utf8"); } /** * Strip trailing whitespace from each line. */ function stripTrailingWhitespace(content: string): string { return content .split("\n") .map((line) => line.trimEnd()) .join("\n"); } /** Generates the matrix for a job. */ function generateJobMatrix( checkSpecification: Specification, ): Array> { let matrix: Array> = []; for (const version of checkSpecification.versions ?? defaultTestVersions) { if (version === "latest") { throw new Error( `Did not recognise "version: ${version}". Did you mean "version: linked"?`, ); } const runnerImages = ["ubuntu-latest", "macos-latest", "windows-latest"]; const operatingSystems = checkSpecification.operatingSystems ?? ["ubuntu"]; for (const operatingSystem of operatingSystems) { // If osCodeQlVersions is set for this OS, only include the specified CodeQL versions. const allowedVersions = checkSpecification.osCodeQlVersions?.[operatingSystem]; if (allowedVersions && !allowedVersions.includes(version)) { continue; } const runnerImagesForOs = runnerImages.filter((image) => image.startsWith(operatingSystem), ); for (const runnerImage of runnerImagesForOs) { matrix.push({ os: runnerImage, version, }); } } } if (checkSpecification.analysisKinds) { const newMatrix: Array> = []; for (const matrixInclude of matrix) { for (const analysisKind of checkSpecification.analysisKinds) { newMatrix.push({ ...matrixInclude, "analysis-kinds": analysisKind, }); } } matrix = newMatrix; } return matrix; } /** * Retrieves setup steps and additional input definitions based on specific languages or frameworks * that are requested by the `checkSpecification`. * * @returns An object containing setup steps and required input names. */ function getSetupSteps(checkSpecification: JobSpecification): { inputs: Set; steps: Step[]; } { const inputs: Array> = []; const steps: Step[] = []; for (const language of Object.values(KnownLanguage).sort()) { const setupSpec = languageSetups[language]; if ( setupSpec === undefined || checkSpecification[setupSpec.specProperty] !== true ) { continue; } steps.push(...setupSpec.steps); inputs.push(new Set(setupSpec.inputs)); } const installYq = checkSpecification.installYq; if (installYq) { steps.push({ name: "Install yq", if: "runner.os == 'Windows'", env: { YQ_PATH: "${{ runner.temp }}/yq", YQ_VERSION, }, run: 'gh release download --repo mikefarah/yq --pattern "yq_windows_amd64.exe" "$YQ_VERSION" -O "$YQ_PATH/yq.exe"\n' + 'echo "$YQ_PATH" >> "$GITHUB_PATH"', }); } return { inputs: unionAll(inputs), steps }; } /** * Generates an Actions job from the `checkSpecification`. * * @param specDocument * The raw YAML document of the PR check specification. * Used to extract `jobs` without losing the original formatting. * @param checkSpecification The PR check specification. * @returns The job and additional workflow inputs. */ function generateJob( specDocument: yaml.Document, checkSpecification: Specification, ) { const matrix: Array> = generateJobMatrix(checkSpecification); const useAllPlatformBundle = checkSpecification.useAllPlatformBundle ? checkSpecification.useAllPlatformBundle : "false"; // Determine which languages or frameworks have to be installed. const setupInfo = getSetupSteps(checkSpecification); const workflowInputs = setupInfo.inputs; // Construct the workflow steps needed for this check. const steps: Step[] = [ { name: "Check out repository", uses: "actions/checkout@v6", }, ...setupInfo.steps, { name: "Prepare test", id: "prepare-test", uses: "./.github/actions/prepare-test", with: { version: "${{ matrix.version }}", "use-all-platform-bundle": useAllPlatformBundle, // If the action is being run from a container, then do not setup kotlin. // This is because the kotlin binaries cannot be downloaded from the container. "setup-kotlin": "container" in checkSpecification ? "false" : "true", }, }, ]; // Extract the sequence of steps from the YAML document to persist as much formatting as possible. const specSteps = specDocument.get("steps") as yaml.YAMLSeq; // A handful of workflow specifications use double quotes for values, while we generally use single quotes. // This replaces double quotes with single quotes for consistency. yaml.visit(specSteps, { Scalar(_key, node) { if (node.type === "QUOTE_DOUBLE") { node.type = "QUOTE_SINGLE"; } }, }); // Add the generated steps in front of the ones from the specification. specSteps.items.unshift(...steps); const checkJob: Record = { strategy: { "fail-fast": false, matrix: { include: matrix, }, }, name: checkSpecification.name, if: "github.triggering_actor != 'dependabot[bot]'", permissions: { contents: "read", "security-events": "read", }, "timeout-minutes": 45, "runs-on": "${{ matrix.os }}", steps: specSteps, }; if (checkSpecification.permissions) { checkJob.permissions = checkSpecification.permissions; } for (const key of ["env", "container", "services"] as const) { if (checkSpecification[key] !== undefined) { checkJob[key] = checkSpecification[key]; } } checkJob.env = checkJob.env ?? {}; if (!("CODEQL_ACTION_TEST_MODE" in checkJob.env)) { checkJob.env.CODEQL_ACTION_TEST_MODE = true; } return { checkJob, workflowInputs }; } /** Generates a validation job. */ function generateValidationJob( specDocument: yaml.Document, jobSpecification: JobSpecification, checkName: string, name: string, ) { // Determine which languages or frameworks have to be installed. const { inputs, steps } = getSetupSteps(jobSpecification); // Extract the sequence of steps from the YAML document to persist as much formatting as possible. const specSteps = specDocument.getIn([ "validationJobs", name, "steps", ]) as yaml.YAMLSeq; // Add the generated steps in front of the ones from the specification. specSteps.items.unshift(...steps); const validationJob: Record = { name: jobSpecification.name, if: "github.triggering_actor != 'dependabot[bot]'", needs: [checkName], permissions: { contents: "read", "security-events": "read", }, "timeout-minutes": 5, "runs-on": "ubuntu-slim", steps: specSteps, }; if (jobSpecification.permissions) { validationJob.permissions = jobSpecification.permissions; } for (const key of ["env"] as const) { if (jobSpecification[key] !== undefined) { validationJob[key] = jobSpecification[key]; } } validationJob.env = validationJob.env ?? {}; if (!("CODEQL_ACTION_TEST_MODE" in validationJob.env)) { validationJob.env.CODEQL_ACTION_TEST_MODE = true; } return { validationJob, inputs }; } /** Generates additional jobs that run after the main check job, based on the `validationJobs` property. */ function generateValidationJobs( specDocument: yaml.Document, checkSpecification: Specification, checkName: string, ): { validationJobs: Record; workflowInputs: Set; } { if (checkSpecification.validationJobs === undefined) { return { validationJobs: {}, workflowInputs: new Set() }; } const validationJobs: Record = {}; const workflowInputs: Array> = []; for (const [jobName, jobSpec] of Object.entries( checkSpecification.validationJobs, )) { if (checkName === jobName) { throw new Error( `Validation job '${jobName}' cannot have the same name as the main job.`, ); } const { validationJob, inputs } = generateValidationJob( specDocument, jobSpec, checkName, jobName, ); validationJobs[jobName] = validationJob; workflowInputs.push(inputs); } return { validationJobs, workflowInputs: unionAll(workflowInputs), }; } /** * Main entry point for the sync script. */ function main(): void { // Ensure the output directory exists. fs.mkdirSync(OUTPUT_DIR, { recursive: true }); // Discover and sort all check specification files. const checkFiles = fs .readdirSync(CHECKS_DIR) .filter((f) => f.endsWith(".yml")) .sort() .map((f) => path.join(CHECKS_DIR, f)); console.log(`Found ${checkFiles.length} check specification(s).`); const collections: Record< string, Array<{ specification: Specification; checkName: string; inputs: Record; }> > = {}; for (const file of checkFiles) { const checkName = path.basename(file, ".yml"); const specDocument = loadYaml(file); const checkSpecification = specDocument.toJS() as Specification; console.log(`Processing: ${checkName} — "${checkSpecification.name}"`); const { checkJob, workflowInputs } = generateJob( specDocument, checkSpecification, ); const { validationJobs, workflowInputs: validationJobInputs } = generateValidationJobs(specDocument, checkSpecification, checkName); const combinedInputs = getSetupInputs( workflowInputs.union(validationJobInputs), ); // If this check belongs to a named collection, record it. if (checkSpecification.collection) { const collectionName = checkSpecification.collection; if (!collections[collectionName]) { collections[collectionName] = []; } collections[collectionName].push({ specification: checkSpecification, checkName, inputs: combinedInputs, }); } let extraGroupName = ""; for (const inputName of Object.keys(combinedInputs)) { extraGroupName += `-\${{inputs.${inputName}}}`; } const cron = new yaml.Scalar("0 5 * * *"); cron.type = yaml.Scalar.QUOTE_SINGLE; const workflow = { name: `PR Check - ${checkSpecification.name}`, env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}", GO111MODULE: "auto", }, on: { push: { branches: ["main", "releases/v*"], }, pull_request: { types: ["opened", "synchronize", "reopened", "ready_for_review"], }, merge_group: { types: ["checks_requested"], }, schedule: [{ cron }], workflow_dispatch: { inputs: combinedInputs, }, workflow_call: { inputs: combinedInputs, }, }, defaults: { run: { shell: "bash", }, }, concurrency: { "cancel-in-progress": "${{ github.event_name == 'pull_request' || false }}", group: `${checkName}-\${{github.ref}}${extraGroupName}`, }, jobs: { [checkName]: checkJob, ...validationJobs, }, }; const outputPath = path.join(OUTPUT_DIR, `__${checkName}.yml`); writeYaml(outputPath, workflow); } // Write workflow files for collections. for (const collectionName of Object.keys(collections)) { const jobs: Record = {}; let combinedInputs: Record = {}; for (const check of collections[collectionName]) { const { checkName, specification, inputs: checkInputs } = check; const checkWith: Record = {}; combinedInputs = { ...combinedInputs, ...checkInputs }; for (const inputName of Object.keys(checkInputs)) { checkWith[inputName] = `\${{ inputs.${inputName} }}`; } jobs[checkName] = { name: specification.name, permissions: { contents: "read", "security-events": "read", }, uses: `./.github/workflows/__${checkName}.yml`, with: checkWith, }; } const collectionWorkflow = { name: `Manual Check - ${collectionName}`, env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}", GO111MODULE: "auto", }, on: { workflow_dispatch: { inputs: combinedInputs, }, }, jobs, }; const outputPath = path.join(OUTPUT_DIR, `__${collectionName}.yml`); writeYaml(outputPath, collectionWorkflow); } console.log( `\nDone. Wrote ${checkFiles.length} workflow file(s) to ${OUTPUT_DIR}`, ); } main();