chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<import('./workflow.js').Workflow[]>}
*/
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<typeof createRunner>} 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);
}
};
}