~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
251 lines · 7.1 KB Raw History
/**
 * 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);
    }
  };
}