import { z } from "zod"; import { parse as parseYaml } from "yaml"; const stringList = z .union([z.string(), z.array(z.string())]) .transform((value) => (Array.isArray(value) ? value : [value])); const scalar = z.union([z.string(), z.number(), z.boolean()]).transform((value) => String(value)); const envMap = z.record(z.string(), scalar); const refFilters = z .object({ branches: stringList.optional(), "branches-ignore": stringList.optional(), tags: stringList.optional(), "tags-ignore": stringList.optional(), paths: stringList.optional(), "paths-ignore": stringList.optional(), types: stringList.optional(), workflows: stringList.optional(), }) .nullable() .transform((value) => value ?? {}); export type RefFilters = z.infer; const dispatchInput = z.object({ description: z.string().optional(), required: z.boolean().optional(), default: scalar.optional(), type: z.enum(["string", "boolean", "choice", "number"]).optional(), options: z.array(scalar).optional(), }); const schedule = z.array(z.object({ cron: z.string().min(1).max(100) })).max(20); const triggerMap = z.object({ push: refFilters.optional(), pull_request: refFilters.optional(), release: refFilters.optional(), issues: refFilters.optional(), issue_comment: refFilters.optional(), pull_request_review: refFilters.optional(), workflow_run: refFilters.optional(), create: refFilters.optional(), delete: refFilters.optional(), schedule: schedule.optional(), workflow_dispatch: z .object({ inputs: z.record(z.string(), dispatchInput).optional() }) .nullable() .transform((value) => value ?? {}) .optional(), }); const triggers = z.preprocess( (value) => { if (typeof value === "string") return { [value]: null }; if (Array.isArray(value)) return Object.fromEntries(value.map((event) => [String(event), null])); return value; }, triggerMap.refine((value) => Object.keys(value).length > 0, { message: "no supported triggers" }), ); const condition = z.union([z.string(), z.boolean()]).optional(); const step = z .object({ name: z.string().optional(), id: z.string().optional(), run: scalar.optional(), uses: z.string().optional(), with: z.record(z.string(), scalar).optional(), shell: z.enum(["bash", "sh", "python", "python3", "node", "bun"]).optional(), "working-directory": z.string().optional(), env: envMap.optional(), if: condition, "continue-on-error": z.boolean().optional(), "timeout-minutes": z.number().positive().optional(), }) .refine((value) => (value.run === undefined) !== (value.uses === undefined), { message: "each step needs exactly one of `run` or `uses`", }); export type StepSpec = z.infer; const PERMISSION_LEVEL = z.enum(["read", "write", "none"]); const permissions = z.union([z.enum(["read-all", "write-all"]), z.record(z.string(), PERMISSION_LEVEL)]); export type PermissionsSpec = z.infer; const environment = z.union([z.string().min(1), z.object({ name: z.string().min(1), url: z.string().optional() })]); const matrixValue = z.union([z.string(), z.number(), z.boolean()]); const matrixRow = z.record(z.string(), matrixValue); const matrix = z .object({ include: z.array(matrixRow).optional(), exclude: z.array(matrixRow).optional(), }) .catchall(z.array(matrixValue)); const job = z.object({ name: z.string().optional(), "runs-on": stringList.default(["cloudflare"]), needs: stringList.default([]), if: condition, env: envMap.optional(), "timeout-minutes": z.number().positive().max(360).default(60), "continue-on-error": z.boolean().optional(), outputs: z.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]*$/), scalar).optional(), environment: environment.optional(), permissions: permissions.optional(), strategy: z .object({ matrix, "fail-fast": z.boolean().optional(), "max-parallel": z.number().optional(), }) .optional(), steps: z.array(step).min(1).max(100), }); export type JobSpec = z.infer; const JOB_KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/; const workflow = z .object({ name: z.string().optional(), "run-name": z.string().optional(), on: triggers, env: envMap.optional(), permissions: permissions.optional(), jobs: z.record(z.string().regex(JOB_KEY, "job ids must be identifiers"), job), }) .superRefine((value, ctx) => { for (const [key, spec] of Object.entries(value.jobs)) { for (const dependency of spec.needs) { if (!(dependency in value.jobs)) { ctx.addIssue({ code: "custom", message: `job '${key}' needs unknown job '${dependency}'` }); } } } }); export type WorkflowSpec = z.infer; export type ParsedWorkflow = { ok: true; spec: WorkflowSpec } | { ok: false; error: string }; export function parseWorkflow(source: string): ParsedWorkflow { let document: unknown; try { document = parseYaml(source); } catch (error) { return { ok: false, error: error instanceof Error ? error.message : "Invalid YAML" }; } const result = workflow.safeParse(document); if (!result.success) { return { ok: false, error: result.error.issues.map((issue) => `${issue.path.join(".") || "workflow"}: ${issue.message}`).join("; "), }; } if (hasCycle(result.data)) return { ok: false, error: "job dependencies contain a cycle" }; return { ok: true, spec: result.data }; } function hasCycle(spec: WorkflowSpec): boolean { const visiting = new Set(); const done = new Set(); const visit = (key: string): boolean => { if (done.has(key)) return false; if (visiting.has(key)) return true; visiting.add(key); const cyclic = (spec.jobs[key]?.needs ?? []).some(visit); visiting.delete(key); done.add(key); return cyclic; }; return Object.keys(spec.jobs).some(visit); } export const WORKFLOW_DIR = ".gitfor/workflows";