/** * How a run's steps get executed. The daemon holds an `Executor` and knows * nothing else about it, so an isolate or container backend replaces the * shell one without touching the trigger or the publishing path. */ import { spawn } from 'node:child_process'; /** A step this long is assumed hung. */ const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1000; /** * The runner's own credentials. A step that reads the app password can * publish checks as the runner, for every repository it watches. These * names never reach a step's environment. The secrets file path goes with * them: a step receives its granted secrets by value, never the file. */ const RUNNER_ONLY_VARS = [ 'PDSJS_CI_IDENTIFIER', 'PDSJS_CI_PASSWORD', 'PDSJS_CI_SECRETS', ]; /** * The environment one step runs with: the daemon's, minus the runner's * credentials, plus what the request adds. * @param {Record} extra * @returns {Record} */ function stepEnv(extra) { const base = { ...process.env }; for (const name of RUNNER_ONLY_VARS) delete base[name]; return { ...base, ...extra }; } /** * @typedef {Object} ExecutionRequest * @property {string} dir - working tree the steps run against * @property {import('./workflow.js').WorkflowStep[]} steps * @property {Record} env * @property {(chunk: string) => void} onLog * @property {AbortSignal} [signal] */ /** * @typedef {Object} StepResult * @property {string} name * @property {number} exitCode - negative when a signal ended the step * @property {number} durationMs */ /** * Runs the steps in order and stops at the first failure. The results cover * the steps that ran, so a short array means an earlier step failed. * @typedef {(request: ExecutionRequest) => Promise} Executor */ /** * @param {import('./workflow.js').WorkflowStep} step * @param {ExecutionRequest} request * @param {number} timeoutMs * @returns {Promise} exit code */ function runStep(step, request, timeoutMs) { return new Promise((resolve, reject) => { const child = spawn(step.run, { cwd: request.dir, env: stepEnv(request.env), shell: true, stdio: ['ignore', 'pipe', 'pipe'], }); const timer = setTimeout(() => { request.onLog(`\n[timed out after ${timeoutMs}ms]\n`); child.kill('SIGKILL'); }, timeoutMs); const abort = () => child.kill('SIGKILL'); request.signal?.addEventListener('abort', abort, { once: true }); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', request.onLog); child.stderr.on('data', request.onLog); const settle = () => { clearTimeout(timer); request.signal?.removeEventListener('abort', abort); }; child.on('error', (err) => { settle(); reject(err); }); // A killed step reports its signal, not a code; report it as non-zero so // a timeout reads as a failure rather than a pass. child.on('close', (code, signal) => { settle(); resolve(code ?? (signal ? -1 : 1)); }); }); } /** * @param {{timeoutMs?: number}} [options] * @returns {Executor} */ export function createShellExecutor(options = {}) { const timeoutMs = options.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS; return async function shellExecutor(request) { /** @type {StepResult[]} */ const results = []; for (const step of request.steps) { request.onLog(`\n=== ${step.name} ===\n$ ${step.run}\n`); const startedAt = Date.now(); const exitCode = await runStep(step, request, timeoutMs); results.push({ name: step.name, exitCode, durationMs: Date.now() - startedAt, }); if (exitCode !== 0) { request.onLog(`\n[${step.name} exited ${exitCode}]\n`); break; } } return results; }; }