chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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 };
}