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));
}