/** * One check run, from a ref that moved to a published record. * * A repository with no workflow file, or whose workflow does not name this * ref, produces no record at all. Watching a repository is therefore safe * before it opts in. */ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { checkout as defaultCheckout, discardCheckout as defaultDiscard, } from './checkout.js'; import { checkStatus } from './publish.js'; import { parseWorkflow, WORKFLOW_EXTENSIONS, WORKFLOW_PATH, WORKFLOWS_DIR, workflowRunsOn, } from './workflow.js'; /** * @typedef {Object} RunnerContext * @property {import('./executor.js').Executor} executor * @property {import('./publish.js').Publisher} publisher * @property {import('./secrets.js').SecretsResolver} [secrets] * @property {typeof defaultCheckout} [checkout] - how a working tree appears * @property {typeof defaultDiscard} [discardCheckout] * @property {(message: string) => void} [onNotice] */ /** * @typedef {Object} CheckRequest * @property {string} service - PDS hosting the repository * @property {string} did - repository owner * @property {string} repoName - what the repository is called * @property {string} repoRkey - the repository's record key * @property {import('./publish.js').CheckSubject} subject * @property {string} ref * @property {string} sha */ /** * @param {unknown} err * @returns {boolean} */ function isEnoent(err) { return /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT'; } /** * The workflows at the commit under test. `.pdsjs/workflows/*.yml`, `.yaml` * or `.json` when the directory exists, otherwise `.pdsjs/ci.json`; a commit * from before either convention still runs. Two workflows may not share a * name, because the name distinguishes their entries in the latest-check * record, and a workflow's name defaults to its filename without the * extension, so `ci.yml` beside `ci.json` is that collision. * @param {string} dir * @returns {Promise} */ async function readWorkflows(dir) { /** @type {string[]} */ let files; try { files = await readdir(join(dir, WORKFLOWS_DIR)); } catch (err) { if (!isEnoent(err)) throw err; try { return [parseWorkflow(await readFile(join(dir, WORKFLOW_PATH), 'utf8'))]; } catch (err) { if (isEnoent(err)) return []; throw err; } } /** @type {import('./workflow.js').Workflow[]} */ const workflows = []; const seen = new Set(); for (const file of files.sort()) { const extension = WORKFLOW_EXTENSIONS.find((ext) => file.endsWith(ext)); if (!extension) continue; const label = `${WORKFLOWS_DIR}/${file}`; const workflow = parseWorkflow( await readFile(join(dir, WORKFLOWS_DIR, file), 'utf8'), { label, defaultName: file.slice(0, -extension.length) }, ); if (seen.has(workflow.name)) { throw new Error(`${label}: a workflow named "${workflow.name}" exists`); } seen.add(workflow.name); workflows.push(workflow); } return workflows; } /** Orders outcomes so several workflows report as their worst one. */ const STATUS_RANK = /** @type {const} */ ({ skipped: 0, success: 1, failure: 2, error: 3, }); /** * @param {'skipped'|'success'|'failure'|'error'} a * @param {'skipped'|'success'|'failure'|'error'} b * @returns {'skipped'|'success'|'failure'|'error'} */ function worseStatus(a, b) { return STATUS_RANK[a] >= STATUS_RANK[b] ? a : b; } /** @typedef {ReturnType} RunCheck */ /** * @param {RunnerContext} ctx */ export function createRunner(ctx) { const { executor, publisher } = ctx; const secrets = ctx.secrets ?? (() => ({})); const checkout = ctx.checkout ?? defaultCheckout; const discardCheckout = ctx.discardCheckout ?? defaultDiscard; const onNotice = ctx.onNotice ?? (() => {}); /** * One workflow, one check record. * @param {CheckRequest} request * @param {import('./workflow.js').Workflow} workflow * @param {string} dir * @param {string[]} checkoutLog - clone output, opening every log * @returns {Promise<'success'|'failure'|'error'>} */ async function runWorkflow(request, workflow, dir, checkoutLog) { const { did, repoName, repoRkey, subject, ref, sha } = request; const label = `${repoName}@${sha.slice(0, 8)}`; const log = [...checkoutLog]; const onLog = (/** @type {string} */ chunk) => { log.push(chunk); }; const startedAt = new Date().toISOString(); const { rkey, cid } = await publisher.start({ repoDid: did, repoName, repoRkey, subject, ref, sha, workflow: workflow.name, startedAt, }); onNotice(`${label}: ${workflow.name} started`); /** @type {import('./executor.js').StepResult[]} */ let steps = []; /** @type {'success'|'failure'|'error'} */ let status; try { steps = await executor({ dir, steps: workflow.steps, env: { ...workflow.env, ...secrets(did, repoRkey, workflow.secrets), PDSJS_CI: 'true', PDSJS_CI_REPO: `${did}/${repoName}`, PDSJS_CI_REF: ref, PDSJS_CI_SHA: sha, }, onLog, }); status = checkStatus(steps); } catch (err) { const message = err instanceof Error ? err.message : String(err); onLog(`\n[run error: ${message}]\n`); status = 'error'; } const uri = await publisher.finish({ rkey, cid, repoDid: did, repoName, repoRkey, subject, ref, sha, workflow: workflow.name, status, steps, startedAt, logs: log.join(''), }); onNotice(`${label}: ${workflow.name} ${status} (${uri})`); return status; } /** * @param {CheckRequest} request * @returns {Promise<'skipped'|'success'|'failure'|'error'>} */ return async function runCheck(request) { const { service, did, repoName, repoRkey, ref, sha } = request; const label = `${repoName}@${sha.slice(0, 8)}`; /** @type {string[]} */ const checkoutLog = []; /** @type {string} */ let dir; try { dir = await checkout({ service, did, repoName, sha, onLog: (chunk) => checkoutLog.push(chunk), }); } catch (err) { onNotice( `${label}: checkout failed (${err instanceof Error ? err.message : err})`, ); return 'skipped'; } try { const workflows = (await readWorkflows(dir)).filter((workflow) => workflowRunsOn(workflow, ref), ); if (workflows.length === 0) { onNotice(`${label}: no workflow runs on ${ref}`); return 'skipped'; } /** @type {'skipped'|'success'|'failure'|'error'} */ let outcome = 'skipped'; for (const workflow of workflows) { const status = await runWorkflow(request, workflow, dir, checkoutLog); outcome = worseStatus(outcome, status); } return outcome; } catch (err) { onNotice( `${label}: run abandoned (${err instanceof Error ? err.message : err})`, ); return 'error'; } finally { await discardCheckout(dir); } }; }