import { interpolate, plainText } from "./segments"; import type { Segment } from "./segments"; import type { ExprContext } from "./expressions"; import type { StepSpec } from "./spec"; export type CacheMode = "both" | "restore" | "save"; export type CacheSpec = { mode: CacheMode; key: Segment[]; restoreKeys: Segment[][]; paths: string[]; version: string; }; export type BuiltinStep = | { kind: "noop"; name: string } | { kind: "upload-artifact"; name: string; artifact: { name: string; path: string } } | { kind: "cache"; name: string; cache: CacheSpec }; const CHECKOUT_ACTION = /^(actions\/)?checkout(@.*)?$/; const UPLOAD_ACTION = /^(actions\/)?upload-artifact(@.*)?$/; const SETUP_ACTION = /^(actions\/setup-node|oven-sh\/setup-bun)(@.*)?$/; const CACHE_ACTION = /^(actions\/)?cache(\/(restore|save))?(@.*)?$/; /** Same pattern the runners accept in artifact markers. */ const ARTIFACT_NAME = /^[A-Za-z0-9._-]{1,100}$/; const MAX_CACHE_PATHS = 20; const MAX_RESTORE_KEYS = 10; const MAX_KEY_LENGTH = 512; const FNV_OFFSET = 0x811c9dc5; const FNV_PRIME = 0x01000193; function lines(value: string | undefined): string[] { return (value ?? "") .split("\n") .map((line) => line.trim()) .filter(Boolean); } /** Cache entries are only compatible when they were saved from the same set of paths. */ function cacheVersion(paths: string[]): string { let hash = FNV_OFFSET; for (const char of `v2\n${paths.join("\n")}`) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, FNV_PRIME) >>> 0; } return hash.toString(16).padStart(8, "0"); } function compileCache(step: StepSpec, index: number, context: ExprContext, mode: CacheMode): BuiltinStep { const raw = step.with ?? {}; const keySource = raw.key ?? (mode === "save" ? raw["primary-key"] : undefined); if (!keySource) throw new Error(`step ${index + 1}: cache requires 'with.key'`); const paths = lines(plainText(interpolate(raw.path ?? "", context))); if (paths.length === 0) throw new Error(`step ${index + 1}: cache requires 'with.path'`); if (paths.length > MAX_CACHE_PATHS) throw new Error(`step ${index + 1}: cache accepts at most ${MAX_CACHE_PATHS} paths`); if (keySource.length > MAX_KEY_LENGTH) throw new Error(`step ${index + 1}: cache keys are limited to 512 characters`); const restoreKeys = lines(raw["restore-keys"]) .slice(0, MAX_RESTORE_KEYS) .map((line) => interpolate(line, context)); return { kind: "cache", name: step.name ?? (mode === "save" ? "Save cache" : "Cache"), cache: { mode, key: interpolate(keySource, context), restoreKeys, paths, version: cacheVersion(paths) }, }; } /** Version inputs the built-in setup steps can't honour; with one set, the real action runs instead. */ const SETUP_VERSION_INPUTS = ["node-version", "node-version-file", "bun-version", "bun-version-file"]; /** * Whether the forge implements `uses` itself. `setup-node`/`setup-bun` are only built in (the image already has * Node and Bun) when no version is requested; a requested version runs the real action so it is honoured. */ export function isBuiltinAction(step: StepSpec): boolean { const uses = step.uses ?? ""; if (UPLOAD_ACTION.test(uses) || CACHE_ACTION.test(uses) || CHECKOUT_ACTION.test(uses)) return true; if (!SETUP_ACTION.test(uses)) return false; return !SETUP_VERSION_INPUTS.some((name) => (step.with?.[name] ?? "").trim() !== ""); } /** Steps implemented by the forge itself instead of fetched actions. */ export function compileUses(step: StepSpec, index: number, context: ExprContext): BuiltinStep { const uses = step.uses ?? ""; if (UPLOAD_ACTION.test(uses)) { const withValues = Object.fromEntries( Object.entries(step.with ?? {}).map(([key, value]) => [key, plainText(interpolate(value, context))]), ); const name = withValues.name ?? "artifact"; const path = withValues.path; if (!path) throw new Error(`step ${index + 1}: upload-artifact requires 'with.path'`); if (!ARTIFACT_NAME.test(name)) throw new Error( `step ${index + 1}: artifact name '${name.slice(0, 100)}' may only contain letters, digits, '.', '_' and '-'`, ); return { kind: "upload-artifact", name: step.name ?? `Upload ${name}`, artifact: { name, path }, }; } const cache = CACHE_ACTION.exec(uses); if (cache) { const variant = cache[3]; return compileCache(step, index, context, variant === "restore" ? "restore" : variant === "save" ? "save" : "both"); } if (CHECKOUT_ACTION.test(uses) || SETUP_ACTION.test(uses)) return { kind: "noop", name: step.name ?? uses }; throw new Error(`step ${index + 1}: '${uses}' is not a built-in action`); }