import { parse as parseYaml } from "yaml"; import { z } from "zod"; /** The JavaScript runtimes GitHub has offered; all run on the runner image's Node (24). */ const NODE_RUNTIMES = new Set(["node12", "node16", "node20", "node24"]); const ENTRY_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\0]{1,512}$/; const MAX_INPUTS = 100; const scalar = z.union([z.string(), z.number(), z.boolean()]).transform((value) => String(value)); const manifestSchema = z.object({ name: z.string().optional(), inputs: z .record( z.string(), z .object({ default: scalar.optional(), required: z.union([z.boolean(), z.string()]).optional(), }) .nullable(), ) .optional(), runs: z.object({ using: z.string(), main: z.string().optional(), pre: z.string().optional(), post: z.string().optional(), "pre-if": z.union([z.string(), z.boolean()]).optional(), "post-if": z.union([z.string(), z.boolean()]).optional(), image: z.string().optional(), entrypoint: z.string().optional(), "pre-entrypoint": z.string().optional(), "post-entrypoint": z.string().optional(), args: z.array(scalar).optional(), env: z.record(z.string(), scalar).optional(), }), }); export type ActionInput = { name: string; default: string | null; required: boolean }; /** * How a Docker action runs: `image` is either a registry image (`docker://...`, pulled through the forge's * image cache) or a Dockerfile path inside the action, built on the runner. `args` and `env` values may use * `${{ inputs.* }}` and are interpolated per step. */ export type DockerManifest = { image: { kind: "registry"; ref: string } | { kind: "dockerfile"; path: string }; entrypoint: string | null; preEntrypoint: string | null; postEntrypoint: string | null; args: string[]; env: Array<[string, string]>; }; /** A JavaScript or Docker action's entry points; composite actions are rejected with a clear message. */ export type ActionManifest = { name: string; runtime: string; /** The JavaScript entry point; empty for Docker actions. */ main: string; docker?: DockerManifest | null; pre: string | null; post: string | null; preIf: string | boolean | null; postIf: string | boolean | null; inputs: ActionInput[]; }; function entry(value: string | undefined, field: string, label: string): string | null { if (value === undefined) return null; const trimmed = value.trim(); if (!ENTRY_PATH.test(trimmed)) throw new Error(`${label}: runs.${field} must be a relative path inside the action`); return trimmed; } export function parseActionManifest(source: string, label: string): ActionManifest { let document: unknown; try { document = parseYaml(source); } catch (error) { throw new Error( `${label}: action.yml is not valid YAML (${error instanceof Error ? error.message : "parse error"})`, ); } const parsed = manifestSchema.safeParse(document); if (!parsed.success) { const issue = parsed.error.issues[0]; throw new Error( `${label}: invalid action.yml (${issue ? `${issue.path.join(".")}: ${issue.message}` : "unknown"})`, ); } const { runs } = parsed.data; const using = runs.using.trim().toLowerCase(); if (using === "composite") throw new Error(`${label}: composite actions are not supported yet`); const inputs = Object.entries(parsed.data.inputs ?? {}) .slice(0, MAX_INPUTS) .map(([name, spec]) => ({ name, default: spec?.default ?? null, required: spec?.required === true || spec?.required === "true", })); if (using === "docker") { return { name: parsed.data.name ?? label, runtime: "docker", main: "", docker: dockerManifest(runs, label), pre: null, post: null, preIf: runs["pre-if"] ?? null, postIf: runs["post-if"] ?? null, inputs, }; } if (!NODE_RUNTIMES.has(using)) throw new Error(`${label}: unsupported action runtime '${runs.using}'`); const main = entry(runs.main, "main", label); if (!main) throw new Error(`${label}: runs.main is required for JavaScript actions`); return { name: parsed.data.name ?? label, runtime: using, main, pre: entry(runs.pre, "pre", label), post: entry(runs.post, "post", label), preIf: runs["pre-if"] ?? null, postIf: runs["post-if"] ?? null, inputs, }; } const MAX_DOCKER_ARGS = 100; const MAX_DOCKER_ENV = 100; const DOCKER_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const ENTRYPOINT = /^[^\0\n]{1,512}$/; function entrypoint(value: string | undefined, field: string, label: string): string | null { if (value === undefined) return null; const trimmed = value.trim(); if (!ENTRYPOINT.test(trimmed)) throw new Error(`${label}: runs.${field} is not a valid entrypoint`); return trimmed; } function dockerManifest(runs: z.infer["runs"], label: string): DockerManifest { const image = runs.image?.trim(); if (!image) throw new Error(`${label}: runs.image is required for Docker actions`); let source: DockerManifest["image"]; if (image.startsWith("docker://")) source = { kind: "registry", ref: image.slice("docker://".length) }; else { const path = entry(image, "image", label); if (!path) throw new Error(`${label}: runs.image must be docker:// or a Dockerfile path`); source = { kind: "dockerfile", path }; } const env = Object.entries(runs.env ?? {}).slice(0, MAX_DOCKER_ENV); const invalid = env.find(([name]) => !DOCKER_ENV_NAME.test(name)); if (invalid) throw new Error(`${label}: invalid runs.env name '${invalid[0].slice(0, 100)}'`); return { image: source, entrypoint: entrypoint(runs.entrypoint, "entrypoint", label), preEntrypoint: entrypoint(runs["pre-entrypoint"], "pre-entrypoint", label), postEntrypoint: entrypoint(runs["post-entrypoint"], "post-entrypoint", label), args: (runs.args ?? []).slice(0, MAX_DOCKER_ARGS), env, }; } /** `INPUT_` as the actions toolkit reads it: spaces become `_`, the name is upper-cased, `-` is kept. */ export function inputVariable(name: string): string { return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; }