Files
codeql-action/pr-checks/sync.ts
2026-03-12 22:39:44 +00:00

746 lines
21 KiB
TypeScript
Executable File

#!/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<Record<KnownInputName, WorkflowInput>>;
/**
* Represents PR check specifications.
*/
interface Specification extends JobSpecification {
/** Workflow-level input definitions forwarded to `workflow_dispatch`/`workflow_call`. */
inputs?: Record<string, WorkflowInput>;
/** 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<string, string[]>;
/** 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<string, JobSpecification>;
/** 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<string, string>;
/** Extra environment variables for the job. */
env?: Record<string, any>;
/** 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<Record<KnownLanguage, LanguageSetup>>;
// 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<Record<KnownLanguage, string>>;
/** 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<KnownInputName>): 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<T>(sets: Array<Set<T>>): Set<T> {
return sets.reduce((prev, cur) => prev.union(cur), new Set<T>());
}
/**
* 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<Record<string, any>> {
let matrix: Array<Record<string, any>> = [];
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<Record<string, any>> = [];
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<KnownInputName>;
steps: Step[];
} {
const inputs: Array<Set<KnownInputName>> = [];
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<Record<string, any>> =
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<string, any> = {
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<string, any> = {
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<string, any>;
workflowInputs: Set<KnownInputName>;
} {
if (checkSpecification.validationJobs === undefined) {
return { validationJobs: {}, workflowInputs: new Set() };
}
const validationJobs: Record<string, any> = {};
const workflowInputs: Array<Set<KnownInputName>> = [];
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<string, WorkflowInput>;
}>
> = {};
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<string, any> = {};
let combinedInputs: Record<string, WorkflowInput> = {};
for (const check of collections[collectionName]) {
const { checkName, specification, inputs: checkInputs } = check;
const checkWith: Record<string, string> = {};
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();