import { YAML } from "bun"; // --------------------------------------------------------------------------- // Block-style YAML emitter (replaces Bun's flow-style YAML.stringify) // --------------------------------------------------------------------------- /** * Emit a JavaScript value as properly-formatted block-style YAML. * Handles the shapes found in GitHub Actions action.yml files. */ export function yamlify(value: unknown, indent = 0): string { const pad = " ".repeat(indent); if (value === null || value === undefined) return `${pad}null`; if (typeof value === "boolean") return `${pad}${value}`; if (typeof value === "number") return `${pad}${value}`; if (typeof value === "string") { // Multi-line strings use literal block scalar if (value.includes("\n")) { const lines = value.split("\n"); const content = lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines; const indented = content.map((l) => `${pad} ${l}`).join("\n"); return `${pad}|\n${indented}`; } // Quote strings that could be ambiguous or contain template expressions if ( value === "true" || value === "false" || value === "null" || value === "yes" || value === "no" || value === "on" || value === "off" || /^[0-9]/.test(value) || /[:{}#&*!|>"'%@`]/.test(value) || value.includes("${{ ") ) { return `${pad}"${value.replace(/"/g, '\\"')}"`; } return `${pad}${value}`; } if (Array.isArray(value)) { if (value.length === 0) return `${pad}[]`; return value .map((item) => { if (item === null || item === undefined) return `${pad}- null`; if (typeof item === "object" && !Array.isArray(item)) { const inner = yamlify(item, indent + 1); const lines = inner.split("\n"); const first = lines[0]!.slice(indent * 2 + 2); const rest = lines.slice(1); if (rest.length === 0) return `${pad}- ${first}`; return [`${pad}- ${first}`, ...rest].join("\n"); } const scalar = yamlify(item, 0).trimStart(); return `${pad}- ${scalar}`; }) .join("\n"); } // Object const entries = Object.entries(value as Record); if (entries.length === 0) return `${pad}{}`; return entries .map(([key, val]) => { if (val === null || val === undefined) return `${pad}${key}: null`; if (typeof val === "object" && !Array.isArray(val)) { return `${pad}${key}:\n${yamlify(val, indent + 1)}`; } if (Array.isArray(val)) { return `${pad}${key}:\n${yamlify(val, indent + 1)}`; } return `${pad}${key}: ${yamlify(val, 0).trimStart()}`; }) .join("\n"); } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ActionInput { name: string; description?: string; required?: boolean; default?: unknown; } export type ActionType = "composite" | "javascript" | "docker" | "unknown"; export interface ParsedAction { type: ActionType; name?: string; description?: string; inputs: ActionInput[]; main?: string; // JS action entrypoint raw: Record; // full parsed YAML document } // --------------------------------------------------------------------------- // Parse an action.yml / action.yaml string // --------------------------------------------------------------------------- /** * Parse a raw action.yml / action.yaml string using Bun's built-in YAML * parser and classify it as composite, JavaScript, or unknown. */ export function parseAction(raw: string): ParsedAction { const doc = (YAML.parse(raw) ?? {}) as Record; const runs = doc.runs as Record | undefined; const using = runs?.using as string | undefined; let type: ActionType = "unknown"; let main: string | undefined; if (using === "composite") { type = "composite"; } else if (using === "docker") { type = "docker"; } else if (using && /^node\d+$/.test(using)) { type = "javascript"; main = runs?.main as string | undefined; } const inputs: ActionInput[] = []; const inputsObj = doc.inputs as | Record> | undefined; if (inputsObj && typeof inputsObj === "object") { for (const [name, def] of Object.entries(inputsObj)) { if (def && typeof def === "object") { inputs.push({ name, description: def.description as string | undefined, required: def.required as boolean | undefined, default: def.default, }); } } } return { type, name: doc.name as string | undefined, description: doc.description as string | undefined, inputs, main, raw: doc, }; } // --------------------------------------------------------------------------- // Build composite wrapper for JavaScript actions // --------------------------------------------------------------------------- /** * Build a composite action.yml wrapper for a JavaScript action. * * The wrapper exposes the same inputs as the original but delegates to the * original action pinned at a specific commit SHA. */ export function buildJsWrapper( action: ParsedAction, owner: string, repo: string, sha: string, ): string { const wrapper: Record = {}; if (action.name) wrapper.name = action.name; if (action.description) wrapper.description = action.description; // Rebuild inputs block if (action.inputs.length > 0) { const inputs: Record = {}; for (const input of action.inputs) { const def: Record = {}; if (input.description !== undefined) def.description = input.description; if (input.required !== undefined) def.required = input.required; if (input.default !== undefined) def.default = input.default; inputs[input.name] = def; } wrapper.inputs = inputs; } // Build the single composite step const step: Record = { uses: `${owner}/${repo}@${sha}`, }; if (action.inputs.length > 0) { const withBlock: Record = {}; for (const input of action.inputs) { withBlock[input.name] = `\${{ inputs.${input.name} }}`; } step.with = withBlock; } // Append setup-bun + cleanup steps (same as injectCheckout) const steps = [step]; steps.push({ uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", }); steps.push({ name: "Cleanup Action", shell: "bash", run: "bun run $GITHUB_ACTION_PATH/index.js", if: "always()", }); wrapper.runs = { using: "composite", steps, }; return yamlify(wrapper); } // --------------------------------------------------------------------------- // Build composite wrapper for Docker actions // --------------------------------------------------------------------------- /** * Build a composite action.yml wrapper for a Docker action. * Same structure as buildJsWrapper — wraps the original pinned at a * commit SHA, then appends setup-bun + cleanup steps. */ export function buildDockerWrapper( action: ParsedAction, owner: string, repo: string, sha: string, ): string { const wrapper: Record = {}; if (action.name) wrapper.name = action.name; if (action.description) wrapper.description = action.description; if (action.inputs.length > 0) { const inputs: Record = {}; for (const input of action.inputs) { const def: Record = {}; if (input.description !== undefined) def.description = input.description; if (input.required !== undefined) def.required = input.required; if (input.default !== undefined) def.default = input.default; inputs[input.name] = def; } wrapper.inputs = inputs; } const steps = [ { uses: `${owner}/${repo}@${sha}`, with: action.inputs.length > 0 ? Object.fromEntries( action.inputs.map((i) => [i.name, `\${{ inputs.${i.name} }}`]), ) : undefined, }, { uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", }, { name: "Cleanup Action", if: "always()", shell: "bash", run: "bun run $GITHUB_ACTION_PATH/index.js", }, ]; wrapper.runs = { using: "composite", steps, }; return yamlify(wrapper); } // --------------------------------------------------------------------------- // Inject setup-bun + cleanup steps into composite actions // --------------------------------------------------------------------------- /** * Parse a composite action.yml and append setup-bun + cleanup steps to the * end of `runs.steps`. Returns the unmodified string when the action has * no steps or cannot be parsed. */ export function injectCheckout(raw: string): string { let doc: Record; try { doc = YAML.parse(raw) as Record; } catch { return raw; } const runs = doc.runs as Record | undefined; const steps = runs?.steps as Array> | undefined; if (!steps || !Array.isArray(steps)) return raw; steps.push({ uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", if: "always()", }); steps.push({ name: "Cleanup Action", if: "always()", shell: "bash", run: "bun run $GITHUB_ACTION_PATH/index.js", }); return yamlify(doc); }