import * as fs from "fs"; import * as path from "path"; import * as core from "@actions/core"; import * as yaml from "js-yaml"; import * as api from "./api-client"; import { getRequiredEnvParam } from "./util"; interface WorkflowJobStep { run: any; uses?: string; with?: { [key: string]: string }; } interface WorkflowJob { steps?: WorkflowJobStep[]; } interface WorkflowTrigger { branches?: string[] | string; paths?: string[]; } // on: {} then push/pull_request are undefined // on: // push: // pull_request: // then push/pull_request are null interface WorkflowTriggers { push?: WorkflowTrigger | null; pull_request?: WorkflowTrigger | null; } export interface Workflow { jobs?: { [key: string]: WorkflowJob }; on?: string | string[] | WorkflowTriggers; } function isObject(o: unknown): o is object { return o !== null && typeof o === "object"; } const GLOB_PATTERN = new RegExp("(\\*\\*?)"); function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string } function patternToRegExp(value) { return new RegExp( `^${value .toString() .split(GLOB_PATTERN) .reduce(function (arr, cur) { if (cur === "**") { arr.push(".*?"); } else if (cur === "*") { arr.push("[^/]*?"); } else if (cur) { arr.push(escapeRegExp(cur)); } return arr; }, []) .join("")}$` ); } // this function should return true if patternA is a superset of patternB // e.g: * is a superset of main-* but main-* is not a superset of *. export function patternIsSuperset(patternA: string, patternB: string): boolean { return patternToRegExp(patternA).test(patternB); } function branchesToArray(branches?: string | null | string[]): string[] | "**" { if (typeof branches === "string") { return [branches]; } if (Array.isArray(branches)) { if (branches.length === 0) { return "**"; } return branches; } return "**"; } export interface CodedError { message: string; code: string; } function toCodedErrors(errors: { [code: string]: string; }): Record { return Object.entries(errors).reduce((acc, [code, message]) => { acc[code] = { message, code }; return acc; }, {} as Record); } // code to send back via status report // message to add as a warning annotation to the run export const WorkflowErrors = toCodedErrors({ MismatchedBranches: `Please make sure that every branch in on.pull_request is also in on.push so that Code Scanning can compare pull requests against the state of the base branch.`, MissingPushHook: `Please specify an on.push hook so that Code Scanning can compare pull requests against the state of the base branch.`, PathsSpecified: `Using on.push.paths can prevent Code Scanning annotating new alerts in your pull requests.`, PathsIgnoreSpecified: `Using on.push.paths-ignore can prevent Code Scanning annotating new alerts in your pull requests.`, CheckoutWrongHead: `git checkout HEAD^2 is no longer necessary. Please remove this step as Code Scanning recommends analyzing the merge commit for best results.`, }); export function getWorkflowErrors(doc: Workflow): CodedError[] { const errors: CodedError[] = []; const jobName = process.env.GITHUB_JOB; if (jobName) { const job = doc?.jobs?.[jobName]; const steps = job?.steps; if (Array.isArray(steps)) { for (const step of steps) { // this was advice that we used to give in the README // we actually want to run the analysis on the merge commit // to produce results that are more inline with expectations // (i.e: this is what will happen if you merge this PR) // and avoid some race conditions if (step?.run === "git checkout HEAD^2") { errors.push(WorkflowErrors.CheckoutWrongHead); break; } } } } let missingPush = false; if (doc.on === undefined) { // this is not a valid config } else if (typeof doc.on === "string") { if (doc.on === "pull_request") { missingPush = true; } } else if (Array.isArray(doc.on)) { const hasPush = doc.on.includes("push"); const hasPullRequest = doc.on.includes("pull_request"); if (hasPullRequest && !hasPush) { missingPush = true; } } else if (isObject(doc.on)) { const hasPush = Object.prototype.hasOwnProperty.call(doc.on, "push"); const hasPullRequest = Object.prototype.hasOwnProperty.call( doc.on, "pull_request" ); if (!hasPush && hasPullRequest) { missingPush = true; } if (hasPush && hasPullRequest) { const paths = doc.on.push?.paths; // if you specify paths or paths-ignore you can end up with commits that have no baseline // if they didn't change any files // currently we cannot go back through the history and find the most recent baseline if (Array.isArray(paths) && paths.length > 0) { errors.push(WorkflowErrors.PathsSpecified); } const pathsIgnore = doc.on.push?.["paths-ignore"]; if (Array.isArray(pathsIgnore) && pathsIgnore.length > 0) { errors.push(WorkflowErrors.PathsIgnoreSpecified); } } // if doc.on.pull_request is null that means 'all branches' // if doc.on.pull_request is undefined that means 'off' // we only want to check for mismatched branches if pull_request is on. if (doc.on.pull_request !== undefined) { const push = branchesToArray(doc.on.push?.branches); if (push !== "**") { const pull_request = branchesToArray(doc.on.pull_request?.branches); if (pull_request !== "**") { const difference = pull_request.filter( (value) => !push.some((o) => patternIsSuperset(o, value)) ); if (difference.length > 0) { // there are branches in pull_request that may not have a baseline // because we are not building them on push errors.push(WorkflowErrors.MismatchedBranches); } } else if (push.length > 0) { // push is set up to run on a subset of branches // and you could open a PR against a branch with no baseline errors.push(WorkflowErrors.MismatchedBranches); } } } } if (missingPush) { errors.push(WorkflowErrors.MissingPushHook); } return errors; } export async function validateWorkflow(): Promise { let workflow: Workflow; try { workflow = await getWorkflow(); } catch (e) { return `error: getWorkflow() failed: ${String(e)}`; } let workflowErrors: CodedError[]; try { workflowErrors = getWorkflowErrors(workflow); } catch (e) { return `error: getWorkflowErrors() failed: ${String(e)}`; } if (workflowErrors.length > 0) { let message: string; try { message = formatWorkflowErrors(workflowErrors); } catch (e) { return `error: formatWorkflowErrors() failed: ${String(e)}`; } core.warning(message); } return formatWorkflowCause(workflowErrors); } export function formatWorkflowErrors(errors: CodedError[]): string { const issuesWere = errors.length === 1 ? "issue was" : "issues were"; const errorsList = errors.map((e) => e.message).join(" "); return `${errors.length} ${issuesWere} detected with this workflow: ${errorsList}`; } export function formatWorkflowCause(errors: CodedError[]): undefined | string { if (errors.length === 0) { return undefined; } return errors.map((e) => e.code).join(","); } export async function getWorkflow(): Promise { const relativePath = await getWorkflowPath(); const absolutePath = path.join( getRequiredEnvParam("GITHUB_WORKSPACE"), relativePath ); return yaml.load(fs.readFileSync(absolutePath, "utf-8")) as Workflow; } /** * Get the path of the currently executing workflow. */ export async function getWorkflowPath(): Promise { const repo_nwo = getRequiredEnvParam("GITHUB_REPOSITORY").split("/"); const owner = repo_nwo[0]; const repo = repo_nwo[1]; const run_id = Number(getRequiredEnvParam("GITHUB_RUN_ID")); const apiClient = api.getApiClient(); const runsResponse = await apiClient.request( "GET /repos/:owner/:repo/actions/runs/:run_id?exclude_pull_requests=true", { owner, repo, run_id, } ); const workflowUrl = runsResponse.data.workflow_url; const workflowResponse = await apiClient.request(`GET ${workflowUrl}`); return workflowResponse.data.path; } /** * Get the workflow run ID. */ export function getWorkflowRunID(): number { const workflowRunID = parseInt(getRequiredEnvParam("GITHUB_RUN_ID"), 10); if (Number.isNaN(workflowRunID)) { throw new Error("GITHUB_RUN_ID must define a non NaN workflow run ID"); } return workflowRunID; } function getStepsCallingAction( job: WorkflowJob, actionName: string ): WorkflowJobStep[] { const steps = job.steps; if (!Array.isArray(steps)) { throw new Error( `Could not get steps calling ${actionName} since job.steps was not an array.` ); } return steps.filter((step) => step.uses?.includes(actionName)); } /** * Makes a best effort attempt to retrieve the value of a particular input with which * an Action in the workflow would be invoked. * * Typically you'll want to wrap this function in a try/catch block and handle the error. * * @returns the value of the input, or undefined if no such input is passed to the Action * @throws an error if the value of the input could not be determined, or we could not * determine that no such input is passed to the Action. */ function getInputOrThrow( workflow: Workflow, jobName: string, actionName: string, inputName: string, matrixVars: { [key: string]: string } ) { if (!workflow.jobs) { throw new Error( `Could not get ${inputName} input to ${actionName} since the workflow has no jobs.` ); } if (!workflow.jobs[jobName]) { throw new Error( `Could not get ${inputName} input to ${actionName} since the workflow has no job named ${jobName}.` ); } const inputs = getStepsCallingAction(workflow.jobs[jobName], actionName) .map((step) => step.with?.[inputName]) .filter((input) => input !== undefined) .map((input) => input!); if (inputs.length === 0) { return undefined; } if (!inputs.every((input) => input === inputs[0])) { throw new Error( `Could not get ${inputName} input to ${actionName} since there were multiple steps calling ` + `${actionName} with different values for ${inputName}.` ); } // Make a basic attempt to substitute matrix variables // First normalize by removing whitespace let input = inputs[0].replace(/\${{\s+/, "${{").replace(/\s+}}/, "}}"); for (const [key, value] of Object.entries(matrixVars)) { input = input.replace(`\${{matrix.${key}}}`, value); } if (input.includes("${{")) { throw new Error( `Could not get ${inputName} input to ${actionName} since it contained an unrecognized dynamic value.` ); } return input; } /** * Makes a best effort attempt to retrieve the category input for the particular job, * given a set of matrix variables. * * Typically you'll want to wrap this function in a try/catch block and handle the error. * * @returns the category input, or undefined if the category input is not defined * @throws an error if the category input could not be determined */ export function getCategoryInputOrThrow( workflow: Workflow, jobName: string, matrixVars: { [key: string]: string } ): string | undefined { return getInputOrThrow( workflow, jobName, "github/codeql-action/analyze", "category", matrixVars ); }