Vectrum
Sign In
298 lines · 11.1 KB · TypeScript
import { parse as parseYaml } from "yaml";

/*
 * Workflow files: `.vectrum/workflows/*.yml`, in a subset of GitHub Actions syntax.
 *
 *   name: CI
 *   on:
 *     push:
 *       branches: [main, "release/**"]
 *     workflow_dispatch:
 *   env: { NODE_ENV: test }
 *   jobs:
 *     test:
 *       runs-on: linux                # labels a runner must have (string or list)
 *       needs: [build]                # jobs that must succeed first
 *       timeout-minutes: 30
 *       steps:
 *         - name: Test
 *           run: bun test
 *           working-directory: app
 *           if: always()              # success() (default), failure() or always()
 *           continue-on-error: true
 *
 * The repository is checked out before the first step. `uses: actions/checkout@…` steps are accepted
 * (and skipped) so workflows copied from GitHub keep working; other `uses:` steps are not supported.
 */

export const WORKFLOW_DIR = ".vectrum/workflows";

export type StepCondition = "success" | "failure" | "always";

export type StepSpec = {
  name: string;
  run: string;
  shell: string | null;
  workingDirectory: string | null;
  env: Record<string, string>;
  if: StepCondition;
  continueOnError: boolean;
  timeoutMinutes: number | null;
};

export type JobSpec = {
  key: string;
  name: string;
  runsOn: string[];
  needs: string[];
  env: Record<string, string>;
  timeoutMinutes: number;
  steps: StepSpec[];
};

export type RefFilter = { include: string[] | null; ignore: string[] | null };

export type PushTrigger = { branches: RefFilter; tags: RefFilter; paths: RefFilter };

export type Workflow = {
  name: string;
  push: PushTrigger | null;
  dispatch: boolean;
  jobs: JobSpec[];
};

export class WorkflowError extends Error {}

const MAX_JOBS = 50;
const MAX_STEPS = 100;
const DEFAULT_TIMEOUT_MINUTES = 360;
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;

type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);

function fail(where: string, message: string): never {
  throw new WorkflowError(where ? `${where}: ${message}` : message);
}

function str(v: unknown, where: string) {
  if (typeof v === "string") return v;
  if (typeof v === "number" || typeof v === "boolean") return String(v);
  fail(where, "expected a string");
}

function strList(v: unknown, where: string) {
  if (v === undefined || v === null) return [];
  return (Array.isArray(v) ? v : [v]).map((item, i) => str(item, `${where}[${i}]`));
}

function env(v: unknown, where: string) {
  if (v === undefined || v === null) return {};
  if (!isObj(v)) fail(where, "expected a map of variables");
  const out: Record<string, string> = {};
  for (const [k, value] of Object.entries(v)) {
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) fail(where, `“${k}” is not a valid variable name`);
    out[k] = value === null ? "" : str(value, `${where}.${k}`);
  }
  return out;
}

function minutes(v: unknown, where: string) {
  if (v === undefined || v === null) return null;
  if (typeof v !== "number" || !(v > 0) || v > 7 * 24 * 60) fail(where, "expected a number of minutes");
  return v;
}

function condition(v: unknown, where: string): StepCondition {
  if (v === undefined || v === null) return "success";
  const text = str(v, where).replace(/^\$\{\{\s*|\s*\}\}$/g, "").trim();
  const m = /^(success|failure|always)\(\)$/.exec(text);
  if (!m) fail(where, "only success(), failure() and always() are supported");
  return m[1] as StepCondition;
}

function refFilter(trigger: Obj, key: string, where: string): RefFilter {
  const include = trigger[key];
  const ignore = trigger[`${key}-ignore`];
  if (include !== undefined && ignore !== undefined) fail(where, `use either ${key} or ${key}-ignore, not both`);
  return {
    include: include === undefined ? null : strList(include, `${where}.${key}`),
    ignore: ignore === undefined ? null : strList(ignore, `${where}.${key}-ignore`),
  };
}

function triggers(on: unknown) {
  let push: PushTrigger | null = null;
  let dispatch = false;
  const events: [string, unknown][] =
    typeof on === "string"
      ? [[on, null]]
      : Array.isArray(on)
        ? on.map((e, i) => [str(e, `on[${i}]`), null])
        : isObj(on)
          ? Object.entries(on)
          : fail("on", "expected an event name, a list or a map of events");

  for (const [event, config] of events) {
    if (event === "push") {
      if (config !== null && !isObj(config)) fail("on.push", "expected a map");
      const c = config ?? {};
      const branches = refFilter(c, "branches", "on.push");
      const tags = refFilter(c, "tags", "on.push");
      // Like GitHub: naming only branches (or only tags) means pushes of the other kind don't trigger.
      const hasBranches = branches.include !== null || branches.ignore !== null;
      const hasTags = tags.include !== null || tags.ignore !== null;
      push = {
        branches: hasTags && !hasBranches ? { include: [], ignore: null } : branches,
        tags: hasBranches && !hasTags ? { include: [], ignore: null } : tags,
        paths: refFilter(c, "paths", "on.push"),
      };
    } else if (event === "workflow_dispatch") {
      dispatch = true;
    } else {
      fail("on", `the “${event}” event isn't supported (use push or workflow_dispatch)`);
    }
  }
  return { push, dispatch };
}

function step(v: unknown, where: string): StepSpec | null {
  if (!isObj(v)) fail(where, "expected a map");
  if (v.uses !== undefined) {
    // The repository is always checked out, so a checkout action has nothing left to do.
    if (/^actions\/checkout(@|$)/.test(str(v.uses, `${where}.uses`))) return null;
    fail(`${where}.uses`, "actions aren't supported; use run to run a command");
  }
  if (v.run === undefined) fail(where, "a step needs a run command");
  const run = str(v.run, `${where}.run`);
  const firstLine = run.trim().split("\n")[0] ?? "";
  return {
    name: v.name !== undefined ? str(v.name, `${where}.name`) : `Run ${firstLine.slice(0, 80)}`,
    run,
    shell: v.shell !== undefined ? str(v.shell, `${where}.shell`) : null,
    workingDirectory: v["working-directory"] !== undefined ? str(v["working-directory"], `${where}.working-directory`) : null,
    env: env(v.env, `${where}.env`),
    if: condition(v.if, `${where}.if`),
    continueOnError: v["continue-on-error"] === true,
    timeoutMinutes: minutes(v["timeout-minutes"], `${where}.timeout-minutes`),
  };
}

function job(key: string, v: unknown): JobSpec {
  const where = `jobs.${key}`;
  if (!KEY_RE.test(key)) fail(where, "job ids may only contain letters, digits, '-' and '_'");
  if (!isObj(v)) fail(where, "expected a map");
  if (v.strategy !== undefined) fail(`${where}.strategy`, "matrix builds aren't supported");
  if (v.uses !== undefined) fail(`${where}.uses`, "reusable workflows aren't supported");
  const runsOn = strList(v["runs-on"], `${where}.runs-on`);
  if (runsOn.length === 0) fail(where, "runs-on is required");
  if (!Array.isArray(v.steps) || v.steps.length === 0) fail(where, "a job needs at least one step");
  if (v.steps.length > MAX_STEPS) fail(where, `a job can have at most ${MAX_STEPS} steps`);
  return {
    key,
    name: v.name !== undefined ? str(v.name, `${where}.name`) : key,
    runsOn,
    needs: strList(v.needs, `${where}.needs`),
    env: env(v.env, `${where}.env`),
    timeoutMinutes: minutes(v["timeout-minutes"], `${where}.timeout-minutes`) ?? DEFAULT_TIMEOUT_MINUTES,
    steps: v.steps.map((s, i) => step(s, `${where}.steps[${i}]`)).filter((s) => s !== null),
  };
}

/** Fails if a job needs one that doesn't exist, or the needs go round in a circle. */
function checkNeeds(jobs: JobSpec[]) {
  const byKey = new Map(jobs.map((j) => [j.key, j]));
  for (const j of jobs) {
    for (const n of j.needs) if (!byKey.has(n)) fail(`jobs.${j.key}.needs`, `there is no job “${n}”`);
  }
  const done = new Set<string>();
  const visiting = new Set<string>();
  const visit = (j: JobSpec) => {
    if (done.has(j.key)) return;
    if (visiting.has(j.key)) fail(`jobs.${j.key}.needs`, "jobs can't depend on each other in a cycle");
    visiting.add(j.key);
    for (const n of j.needs) visit(byKey.get(n)!);
    visiting.delete(j.key);
    done.add(j.key);
  };
  jobs.forEach(visit);
}

/** Parses and validates a workflow file; throws a WorkflowError that says what is wrong. */
export function parseWorkflow(source: string, path: string): Workflow {
  let doc: unknown;
  try {
    doc = parseYaml(source, { prettyErrors: true });
  } catch (e) {
    throw new WorkflowError(`Invalid YAML: ${(e as Error).message}`);
  }
  if (!isObj(doc)) fail("", "the workflow must be a map with on and jobs");
  if (doc.on === undefined) fail("", "on is required, e.g. on: push");
  if (!isObj(doc.jobs) || Object.keys(doc.jobs).length === 0) fail("", "jobs is required");
  const workflowEnv = env(doc.env, "env");
  const entries = Object.entries(doc.jobs);
  if (entries.length > MAX_JOBS) fail("jobs", `a workflow can have at most ${MAX_JOBS} jobs`);
  const jobs = entries.map(([key, value]) => job(key, value));
  checkNeeds(jobs);
  for (const j of jobs) j.env = { ...workflowEnv, ...j.env };
  return {
    name: doc.name !== undefined ? str(doc.name, "name") : path.split("/").pop()!.replace(/\.ya?ml$/, ""),
    ...triggers(doc.on),
    jobs,
  };
}

/** A workflow's display name without validating it, for listing files that may be broken. */
export function workflowName(source: string, path: string) {
  try {
    const doc = parseYaml(source);
    if (isObj(doc) && typeof doc.name === "string" && doc.name.trim()) return doc.name.trim();
  } catch {
    // Fall back to the file name.
  }
  return path.split("/").pop()!.replace(/\.ya?ml$/, "");
}

export const isWorkflowFile = (name: string) => /\.ya?ml$/i.test(name);

// ---------------------------------------------------------------------------
// Filters

/** GitHub's filter patterns: `*` within a path segment, `**` across them, `?` one character. */
function globToRegExp(pattern: string) {
  let re = "";
  for (let i = 0; i < pattern.length; i++) {
    const c = pattern[i];
    if (c === "*" && pattern[i + 1] === "*") {
      re += ".*";
      i++;
    } else if (c === "*") re += "[^/]*";
    else if (c === "?") re += "[^/]";
    else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
  }
  return new RegExp(`^${re}$`);
}

/** Patterns are applied in order, and a later `!pattern` excludes what an earlier one included. */
function matchesList(patterns: string[], value: string) {
  let matched = false;
  for (const p of patterns) {
    if (p.startsWith("!")) {
      if (globToRegExp(p.slice(1)).test(value)) matched = false;
    } else if (globToRegExp(p).test(value)) matched = true;
  }
  return matched;
}

export function matchesFilter(filter: RefFilter, value: string) {
  if (filter.include) return matchesList(filter.include, value);
  if (filter.ignore) return !matchesList(filter.ignore, value);
  return true;
}

/** Whether a set of changed files passes a paths filter (null: the changes aren't known, so yes). */
export function matchesPaths(filter: RefFilter, changed: string[] | null) {
  if (!filter.include && !filter.ignore) return true;
  if (changed === null) return true;
  return changed.some((file) => matchesFilter(filter, file));
}