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/**"] * pull_request: # opened, synchronize (pushed to) and reopened, by default * branches: [main] # the pull request's base branch * issues: * types: [opened, labeled] * issue_comment: * 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; if: StepCondition; continueOnError: boolean; timeoutMinutes: number | null; }; export type JobSpec = { key: string; name: string; runsOn: string[]; needs: string[]; env: Record; timeoutMinutes: number; steps: StepSpec[]; }; export type RefFilter = { include: string[] | null; ignore: string[] | null }; export type PushTrigger = { branches: RefFilter; tags: RefFilter; paths: RefFilter }; /** Events (besides pushes) that start workflows, with the activity types each has. */ export const EVENT_TRIGGERS = { issues: ["opened", "edited", "deleted", "closed", "reopened", "assigned", "unassigned", "labeled", "unlabeled", "status_changed"], issue_comment: ["created"], pull_request: ["opened", "edited", "closed", "merged", "reopened", "synchronize", "ready_for_review", "converted_to_draft"], pull_request_review: ["submitted"], } as const; export type EventTriggerName = keyof typeof EVENT_TRIGGERS; /** Which activity types start the workflow (null: all), and for pull requests, which base branches and paths. */ export type EventTrigger = { types: string[] | null; branches: RefFilter; paths: RefFilter }; const DEFAULT_TYPES: Partial> = { pull_request: ["opened", "synchronize", "reopened"] }; export type Workflow = { name: string; push: PushTrigger | null; dispatch: boolean; events: Partial>; jobs: JobSpec[]; }; /** Whether an event's activity type is one a trigger runs on. A merge is a kind of closing, like on GitHub. */ export function triggerMatches(trigger: EventTrigger, action: string | null) { if (!trigger.types) return true; if (!action) return false; return trigger.types.includes(action) || (action === "merged" && trigger.types.includes("closed")); } 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; 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 = {}; 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`), }; } const NO_FILTER: RefFilter = { include: null, ignore: null }; function eventTrigger(name: EventTriggerName, config: unknown): EventTrigger { const where = `on.${name}`; if (config !== null && !isObj(config)) fail(where, "expected a map"); const c = config ?? {}; const known: readonly string[] = EVENT_TRIGGERS[name]; let types: string[] | null = DEFAULT_TYPES[name] ?? null; if (c.types !== undefined) { types = strList(c.types, `${where}.types`); for (const t of types) if (!known.includes(t)) fail(`${where}.types`, `“${t}” isn't one of ${known.join(", ")}`); } const pr = name === "pull_request" || name === "pull_request_review"; for (const key of Object.keys(c)) { if (key === "types" || (pr && /^(branches|paths)(-ignore)?$/.test(key))) continue; fail(`${where}.${key}`, pr ? "only types, branches and paths are supported" : "only types is supported"); } return { types, branches: pr ? refFilter(c, "branches", where) : NO_FILTER, paths: pr ? refFilter(c, "paths", where) : NO_FILTER }; } function triggers(on: unknown) { let push: PushTrigger | null = null; let dispatch = false; const events: Partial> = {}; const entries: [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 entries) { if (event in EVENT_TRIGGERS) { events[event as EventTriggerName] = eventTrigger(event as EventTriggerName, config); } else 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, pull_request, issues, issue_comment, pull_request_review or workflow_dispatch)`); } } return { push, dispatch, events }; } 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(); const visiting = new Set(); 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; } /** Whether a name matches one glob pattern (`*` within a path segment, `**` across them). */ export const matchesGlob = (pattern: string, value: string) => globToRegExp(pattern).test(value); 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)); }