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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
148 lines · 4.6 KB Raw History
/**
 * 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<string, string>} 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<string, unknown>} */ (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<string, unknown>} */ (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<string, string>} */
  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 };
}