import { readFileSync } from 'node:fs'; export type WorkflowStep = { name?: string; run?: string; env?: Record; }; export type ParsedWorkflow = { jobs?: Record; }; // Workflow tests only ever run under `bun test`, which parses YAML natively. function parseWorkflowYaml(source: string): ParsedWorkflow { const bunRuntime = globalThis as typeof globalThis & { Bun?: { YAML?: { parse?: (input: string) => unknown } }; }; const parse = bunRuntime.Bun?.YAML?.parse; if (!parse) { throw new Error('Bun.YAML.parse is unavailable; workflow tests must run under bun.'); } return parse(source) as ParsedWorkflow; } export function readWorkflow(workflowPath: string): ParsedWorkflow { return parseWorkflowYaml(readFileSync(workflowPath, 'utf8')); } // Steps of one job, in declaration order. Throws on an unknown job so a renamed // job fails loudly instead of silently emptying an ordering assertion. export function jobSteps(workflow: ParsedWorkflow, jobName: string): WorkflowStep[] { const job = workflow.jobs?.[jobName]; if (!job) { throw new Error(`Workflow has no job named ${jobName}.`); } return job.steps ?? []; } function allSteps(workflow: ParsedWorkflow): Array<{ job: string; step: WorkflowStep }> { return Object.entries(workflow.jobs ?? {}).flatMap(([job, definition]) => (definition?.steps ?? []).map((step) => ({ job, step })), ); } // Lines of a step's shell body that actually execute. Comments are dropped so a // commented-out command cannot satisfy a "this step runs X" assertion. export function executableRunLines(step: WorkflowStep): string[] { return (typeof step.run === 'string' ? step.run.split('\n') : []) .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')); } // Leading shell keywords and operators that can precede a real command. const COMMAND_PREFIX = /^(?:if|elif|while|until|then|else|do|!|&&|\|\||\(|\{)\s+/; // Splits one shell line on command separators, tracking quotes so a separator // inside a string is not treated as a command break, and stopping at an // unquoted inline comment. function splitCommandSeparators(line: string): string[] { const segments: string[] = []; let current = ''; let quote: "'" | '"' | null = null; for (let index = 0; index < line.length; index += 1) { const char = line[index]!; if (quote) { current += char; if (char === '\\' && quote === '"' && index + 1 < line.length) { current += line[index + 1]!; index += 1; } else if (char === quote) { quote = null; } continue; } // An unquoted backslash escapes the next character, so `\;` is literal text // rather than a separator. Checked before comments and separators. if (char === '\\' && index + 1 < line.length) { current += char + line[index + 1]!; index += 1; continue; } if (char === "'" || char === '"') { quote = char; current += char; continue; } // An unquoted # starts a comment when it opens a word; the rest is inert. if (char === '#' && (current === '' || /\s$/.test(current))) { break; } const next = line[index + 1]; if (char === ';') { segments.push(current); current = ''; continue; } if ((char === '&' || char === '|') && next === char) { segments.push(current); current = ''; index += 1; continue; } // A lone pipe separates commands; a redirect such as 2>&1 does not. if (char === '|' && !/[0-9<>&]$/.test(current)) { segments.push(current); current = ''; continue; } current += char; } segments.push(current); return segments; } // Command positions within a step's shell body: each line split on separators, // with control-flow prefixes stripped. A pattern anchored with ^ therefore // matches only where a command actually starts, so text quoted inside an // `echo`/`printf` argument is not mistaken for the command running. export function commandPositions(step: WorkflowStep): string[] { return executableRunLines(step).flatMap((line) => splitCommandSeparators(line) .map((segment) => { let candidate = segment.trim(); let stripped = candidate.replace(COMMAND_PREFIX, ''); while (stripped !== candidate) { candidate = stripped; stripped = candidate.replace(COMMAND_PREFIX, ''); } return candidate; }) .filter(Boolean), ); } // Whether a step actually executes a command matching the pattern. Anchor the // pattern with ^ so it has to match at a command position. export function stepRunsCommand(step: WorkflowStep, pattern: RegExp): boolean { return commandPositions(step).some((position) => pattern.test(position)); } // GitHub substitutes ${{ }} into a run script before the shell parses it, so any // value used that way is executed as script rather than read as data. Reporting // every expression (rather than allow-listing known-safe ones) also covers // alternate spellings such as ${{ steps.version.outputs['VERSION'] }}. export function templateExpressionsInRunBodies(workflow: ParsedWorkflow): string[] { return allSteps(workflow).flatMap(({ job, step }) => (typeof step.run === 'string' ? (step.run.match(/\$\{\{[\s\S]*?\}\}/g) ?? []) : []).map( (expression) => `${job}/${step.name ?? ''}: ${expression}`, ), ); } // Steps whose shell body reads $NAME without the step declaring it in env, which // would silently expand to an empty string at run time. export function stepsMissingEnvDeclaration(workflow: ParsedWorkflow, name: string): string[] { const reference = new RegExp(`\\$${name}\\b|\\$\\{${name}\\b`); return allSteps(workflow) .filter(({ step }) => typeof step.run === 'string' && reference.test(step.run)) .filter(({ step }) => !Object.prototype.hasOwnProperty.call(step.env ?? {}, name)) .map(({ job, step }) => `${job}/${step.name ?? ''}`); }