/** * The in-tree workflow definitions, read at the commit under test. A step is * a shell script, which a YAML block scalar carries as written and a JSON * string carries as one escaped line. YAML 1.2 is a superset of JSON, so one * parser reads both and a repository holding the older `.pdsjs/ci.json` still * runs. `parseWorkflow` is the only place that assumes the format. */ import { parse as parseYaml } from 'yaml'; /** Where a repository's workflows live, one file per workflow. */ export const WORKFLOWS_DIR = '.pdsjs/workflows'; /** Extensions `WORKFLOWS_DIR` reads. Anything else in there is ignored. */ export const WORKFLOW_EXTENSIONS = ['.yml', '.yaml', '.json']; /** The single-workflow path, read when `WORKFLOWS_DIR` is absent. */ export const WORKFLOW_PATH = '.pdsjs/ci.json'; /** Refs a workflow runs on when it names none. */ const DEFAULT_REF_PATTERNS = ['refs/heads/*']; /** * @typedef {Object} WorkflowStep * @property {string} name * @property {string} run - shell command */ /** * @typedef {Object} Workflow * @property {string} name * @property {WorkflowStep[]} steps * @property {string[]} refs - ref patterns this workflow runs on * @property {Record} env * @property {string[]} secrets - names this workflow asks the runner for */ /** * A pattern matches a ref exactly, or by a single trailing `*` standing for * one or more path segments. * @param {string} pattern * @param {string} ref * @returns {boolean} */ export function refMatches(pattern, ref) { if (!pattern.endsWith('*')) return pattern === ref; return ref.startsWith(pattern.slice(0, -1)); } /** * @param {Workflow} workflow * @param {string} ref * @returns {boolean} */ export function workflowRunsOn(workflow, ref) { return workflow.refs.some((pattern) => refMatches(pattern, ref)); } /** * @param {string} label - which file an error names * @returns {(value: unknown, field: string) => string} */ function stringChecker(label) { return (value, field) => { if (typeof value !== 'string' || value.length === 0) { throw new Error(`${label}: ${field} must be a non-empty string`); } return value; }; } /** * @param {string} source - file contents * @param {{label?: string, defaultName?: string}} [options] - which file an * error names, and the name an unnamed workflow gets * @returns {Workflow} */ export function parseWorkflow(source, options = {}) { const label = options.label ?? WORKFLOW_PATH; const requireString = stringChecker(label); /** @type {unknown} */ let parsed; try { parsed = parseYaml(source); } catch (err) { throw new Error( `${label}: cannot parse (${err instanceof Error ? err.message : err})`, ); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${label}: must be a mapping`); } const value = /** @type {Record} */ (parsed); const rawSteps = value.steps; if (!Array.isArray(rawSteps) || rawSteps.length === 0) { throw new Error(`${label}: steps must be a non-empty array`); } const steps = rawSteps.map((entry, index) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { throw new Error(`${label}: step ${index} must be an object`); } const step = /** @type {Record} */ (entry); const run = requireString(step.run, `step ${index} run`); const name = step.name === undefined ? `step ${index + 1}` : requireString(step.name, `step ${index} name`); return { name, run }; }); /** @type {string[]} */ let refs = DEFAULT_REF_PATTERNS; if (value.refs !== undefined) { if (!Array.isArray(value.refs) || value.refs.length === 0) { throw new Error(`${label}: refs must be a non-empty array`); } refs = value.refs.map((ref, index) => requireString(ref, `refs entry ${index}`), ); } /** @type {Record} */ const env = {}; if (value.env !== undefined) { if (!value.env || typeof value.env !== 'object' || Array.isArray(value.env)) throw new Error(`${label}: env must be an object`); for (const [key, entry] of Object.entries(value.env)) { env[key] = requireString(entry, `env.${key}`); } } /** @type {string[]} */ let secrets = []; if (value.secrets !== undefined) { if (!Array.isArray(value.secrets)) { throw new Error(`${label}: secrets must be an array`); } secrets = value.secrets.map((name, index) => requireString(name, `secrets entry ${index}`), ); } const name = value.name === undefined ? (options.defaultName ?? 'ci') : requireString(value.name, 'name'); return { name, steps, refs, env, secrets }; }