Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

554 lines · 23.6 KBRaw History
1import { existsSync } from "node:fs";
2import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3import path from "node:path";
4import type { StepSpec } from "@diffinery/shared";
5import { getBuiltin, makeBuiltinContext } from "../builtins/index.js";
6import { errMsg, failure, StepFailure, type JobRunner, type Scope, type StepIO } from "../job.js";
7import { exec, shellWords } from "../util.js";
8import { parseYaml } from "../yaml.js";
9import type { StepExec } from "./run.js";
10
11export type UsesRef =
12 | { kind: "docker"; image: string }
13 | { kind: "local"; path: string }
14 | { kind: "remote"; owner: string; repo: string; subpath: string; ref: string; name: string };
15
16export function parseUses(uses: string): UsesRef {
17 const u = uses.trim();
18 if (u.startsWith("docker://")) return { kind: "docker", image: u.slice("docker://".length) };
19 if (u.startsWith("./") || u.startsWith("../") || u === ".") return { kind: "local", path: u };
20 const at = u.lastIndexOf("@");
21 if (at <= 0) throw failure(`Expected format {owner}/{repo}[/path]@ref. Actual '${u}'`);
22 const ref = u.slice(at + 1);
23 const parts = u.slice(0, at).split("/");
24 if (parts.length < 2 || !parts[0] || !parts[1] || !ref) throw failure(`Expected format {owner}/{repo}[/path]@ref. Actual '${u}'`);
25 return { kind: "remote", owner: parts[0], repo: parts[1], subpath: parts.slice(2).join("/"), ref, name: parts.join("/") };
26}
27
28export interface ActionInput {
29 description?: string;
30 required?: boolean;
31 default?: unknown;
32 deprecationMessage?: string;
33}
34
35export interface ActionMeta {
36 name?: string;
37 inputs?: Record<string, ActionInput | null>;
38 outputs?: Record<string, { description?: string; value?: string } | null>;
39 runs: {
40 using: string;
41 main?: string;
42 pre?: string;
43 "pre-if"?: string;
44 post?: string;
45 "post-if"?: string;
46 steps?: StepSpec[];
47 image?: string;
48 env?: Record<string, string>;
49 entrypoint?: string;
50 args?: unknown[];
51 "pre-entrypoint"?: string;
52 "post-entrypoint"?: string;
53 };
54}
55
56export async function loadActionMeta(dir: string): Promise<ActionMeta> {
57 for (const f of ["action.yml", "action.yaml"]) {
58 const p = path.join(dir, f);
59 if (existsSync(p)) {
60 const meta = parseYaml(await readFile(p, "utf8")) as ActionMeta | null;
61 if (!meta || typeof meta !== "object" || !meta.runs || typeof meta.runs !== "object") {
62 throw failure(`Invalid action metadata in ${p}: missing 'runs'`);
63 }
64 return meta;
65 }
66 }
67 if (existsSync(path.join(dir, "Dockerfile"))) return { runs: { using: "docker", image: "Dockerfile" } };
68 throw failure(`Can't find 'action.yml' or 'action.yaml' under '${dir}'. Did you forget to run actions/checkout before running your local action?`);
69}
70
71/** Where actions come from on GitHub, and the first retry's delay (tests point these at local servers and shorten it). */
72export const actionDownload = { codeload: "https://codeload.github.com", git: "https://github.com", backoffMs: 1000 };
73
74/** How long the tarball download waits for GitHub to answer before it counts as a failed attempt. */
75const ANSWER_TIMEOUT_MS = 60_000;
76/** How long the `git fetch` fallback may take. */
77const GIT_TIMEOUT_MS = 300_000;
78
79/**
80 * Downloads `owner/repo@ref` into the action cache and returns its root dir: from GitHub, and when
81 * GitHub fails, from the copy Diffinery keeps of every action jobs have used (lib/action-mirror.ts
82 * on the server). After a download from GitHub the runner tells Diffinery, which refreshes its copy
83 * by fetching the action from GitHub itself; the job doesn't wait for that.
84 */
85export async function downloadAction(job: JobRunner, io: StepIO, owner: string, repo: string, ref: string): Promise<string> {
86 const dest = path.join(job.actionsDir, owner, repo, ref);
87 if (existsSync(path.join(dest, ".diffinery-complete"))) return dest;
88 const custom = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
89 const tpl = custom ?? `${actionDownload.codeload}/{owner}/{repo}/tar.gz/{ref}`;
90 const url = tpl.replace("{owner}", owner).replace("{repo}", repo).replace("{ref}", encodeURIComponent(ref));
91 job.logger.line(io.logIndex, `Download action repository '${owner}/${repo}@${ref}'`);
92 const headers: Record<string, string> = { "user-agent": "diffinery-runner" };
93 const ghToken = GITHUB_TOKEN;
94 if (ghToken && url.startsWith("https://codeload.github.com/")) headers.authorization = `token ${ghToken}`;
95 let lastErr: unknown;
96 for (let attempt = 0; attempt < 4; attempt++) {
97 if (attempt) await new Promise((r) => setTimeout(r, actionDownload.backoffMs * 2 ** attempt));
98 try {
99 // A GitHub outage can leave connections hanging rather than refused.
100 const answered = new AbortController();
101 const timer = setTimeout(() => answered.abort(new Error(`no answer from ${new URL(url).host} in ${ANSWER_TIMEOUT_MS / 1000} s`)), ANSWER_TIMEOUT_MS);
102 const res = await fetch(url, { headers, redirect: "follow", signal: answered.signal }).finally(() => clearTimeout(timer));
103 if (res.status === 404) throw failure(`Unable to resolve action '${owner}/${repo}@${ref}', repository or version not found`);
104 if (res.status === 401 || res.status === 403) {
105 lastErr = new Error(`HTTP ${res.status} downloading ${url}`);
106 break;
107 }
108 if (!res.ok) throw new Error(`HTTP ${res.status} downloading ${url}`);
109 await extractAction(dest, Buffer.from(await res.arrayBuffer()));
110 if (!custom) reportAction(job, owner, repo, ref);
111 return dest;
112 } catch (e) {
113 if (e instanceof StepFailure) throw e;
114 lastErr = e;
115 }
116 }
117 if (!custom) {
118 // The tarball endpoint can be unreachable (proxies, rate limits) while git still works.
119 if (await cloneAction(dest, owner, repo, ref, ghToken)) {
120 reportAction(job, owner, repo, ref);
121 return dest;
122 }
123 // GitHub is down: Diffinery's copy, if a job has used this action before.
124 const fetched = await mirroredAction(job, dest, owner, repo, ref);
125 if (fetched) {
126 const msg = `GitHub is unreachable (${errMsg(lastErr)}); using Diffinery's copy of '${owner}/${repo}@${ref}', downloaded from GitHub at ${fetched}`;
127 job.logger.line(io.logIndex, `##[warning]${msg}`);
128 job.addAnnotation(io.logIndex, { level: "warning", message: msg });
129 return dest;
130 }
131 }
132 throw failure(`Failed to download action '${owner}/${repo}@${ref}': ${errMsg(lastErr)}`);
133}
134
135/** Unpacks a GitHub source tarball (one top-level directory) into `dest`. */
136async function extractAction(dest: string, tarball: Buffer): Promise<void> {
137 const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
138 await mkdir(tmp, { recursive: true });
139 try {
140 const tarFile = `${tmp}.tar.gz`;
141 await writeFile(tarFile, tarball);
142 const r = await exec("tar", ["-xzf", tarFile, "-C", tmp, "--strip-components=1"], { capture: true });
143 await rm(tarFile, { force: true });
144 if (r.exitCode !== 0) throw new Error(`Failed to extract action archive: ${r.stderr.trim()}`);
145 await rm(dest, { recursive: true, force: true });
146 await mkdir(path.dirname(dest), { recursive: true });
147 await rename(tmp, dest);
148 await writeFile(path.join(dest, ".diffinery-complete"), "");
149 } catch (e) {
150 await rm(tmp, { recursive: true, force: true });
151 throw e;
152 }
153}
154
155/** Asks Diffinery to keep a fresh copy of an action this job got from GitHub. In the background: never fails or delays the job. */
156function reportAction(job: JobRunner, owner: string, repo: string, ref: string): void {
157 if (!job.api.online) return;
158 job.api.request("/action-mirror", { method: "POST", json: { owner, repo, ref }, retries: 0 }).then(
159 (res) => res.body?.cancel(),
160 () => {},
161 );
162}
163
164/** Diffinery's copy of the action, unpacked into `dest`: when it was downloaded from GitHub, or null when there is none. */
165async function mirroredAction(job: JobRunner, dest: string, owner: string, repo: string, ref: string): Promise<string | null> {
166 if (!job.api.online) return null;
167 try {
168 const q = new URLSearchParams({ owner, repo, ref });
169 const res = await job.api.request(`/action-mirror?${q}`, { retries: 2, allow: [404] });
170 if (res.status === 404) {
171 await res.body?.cancel();
172 return null;
173 }
174 const fetched = res.headers.get("x-diffinery-fetched-at") ?? "an earlier time";
175 await extractAction(dest, Buffer.from(await res.arrayBuffer()));
176 return fetched;
177 } catch {
178 return null;
179 }
180}
181
182/** Fetches `owner/repo@ref` (branch, tag or commit SHA) from github.com with git; false when that fails. */
183async function cloneAction(dest: string, owner: string, repo: string, ref: string, token: string | undefined): Promise<boolean> {
184 if (!/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo) || ref.startsWith("-") || /[\s\\]|\.\.|[~^:?*[]/.test(ref)) return false;
185 const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
186 // The header goes through the environment, not argv, where other processes could read it.
187 const n = Number(process.env.GIT_CONFIG_COUNT) || 0;
188 const auth: Record<string, string> = token
189 ? { GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: "http.https://github.com/.extraheader", [`GIT_CONFIG_VALUE_${n}`]: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}` }
190 : {};
191 const git = (args: string[]) => exec("git", args, { cwd: tmp, capture: true, timeoutMs: GIT_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...auth } });
192 try {
193 await mkdir(tmp, { recursive: true });
194 for (const args of [["init", "-q"], ["fetch", "-q", "--depth", "1", `${actionDownload.git}/${owner}/${repo}`, ref], ["checkout", "-q", "FETCH_HEAD"]]) {
195 if ((await git(args)).exitCode !== 0) throw new Error(args[0]);
196 }
197 await rm(path.join(tmp, ".git"), { recursive: true, force: true });
198 await rm(dest, { recursive: true, force: true });
199 await mkdir(path.dirname(dest), { recursive: true });
200 await rename(tmp, dest);
201 await writeFile(path.join(dest, ".diffinery-complete"), "");
202 return true;
203 } catch {
204 await rm(tmp, { recursive: true, force: true });
205 return false;
206 }
207}
208
209export function inputEnvName(name: string): string {
210 return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`;
211}
212
213/** Evaluates `with:` plus defaults from action.yml. */
214export function computeInputs(job: JobRunner, x: StepExec, meta: ActionMeta | null): Record<string, string> {
215 const inputs: Record<string, string> = {};
216 const given = new Map<string, string>();
217 for (const [k, v] of Object.entries(x.step.with ?? {})) {
218 given.set(k.toLowerCase(), job.interp(v === null || v === undefined ? "" : String(v), x.ctx, x.scope));
219 inputs[k] = given.get(k.toLowerCase())!;
220 }
221 for (const [k, def] of Object.entries(meta?.inputs ?? {})) {
222 if (given.has(k.toLowerCase())) {
223 // normalise the key to the declared spelling
224 const actual = Object.keys(inputs).find((x) => x.toLowerCase() === k.toLowerCase())!;
225 if (actual !== k) {
226 inputs[k] = inputs[actual]!;
227 delete inputs[actual];
228 }
229 if (def?.deprecationMessage) job.logger.line(x.io.logIndex, `##[warning]Input '${k}' has been deprecated with message: ${def.deprecationMessage}`);
230 continue;
231 }
232 if (def && def.default !== undefined && def.default !== null) {
233 inputs[k] = job.interp(String(def.default), x.ctx, x.scope);
234 } else if (def?.required) {
235 job.logger.line(x.io.logIndex, `##[warning]Input required and not supplied: ${k}`);
236 }
237 }
238 return inputs;
239}
240
241export async function runAction(job: JobRunner, x: StepExec): Promise<void> {
242 const uses = job.interp(String(x.step.uses), x.ctx, x.scope);
243 const ref = parseUses(uses);
244 if (ref.kind === "docker") {
245 // `uses: docker://image`: `with.args` and `with.entrypoint` drive the container; the rest become INPUT_*.
246 const given = computeInputs(job, x, null);
247 const inputs: Record<string, string> = {};
248 let args: string[] = [];
249 let entrypoint: string | undefined;
250 for (const [k, v] of Object.entries(given)) {
251 if (k.toLowerCase() === "args") args = shellWords(v);
252 else if (k.toLowerCase() === "entrypoint") entrypoint = v || undefined;
253 else inputs[k] = v;
254 }
255 logActionHeader(job, x.io, uses, given);
256 await runDockerAction(job, x, { image: `docker://${ref.image}`, dir: null, args, entrypoint, env: {}, inputs, action: { path: "", repository: "", ref: "" } });
257 return;
258 }
259
260 if (ref.kind === "remote") {
261 const given = computeInputs(job, x, null);
262 const builtin = getBuiltin(ref.name, ref.ref, given, x.envCtx);
263 if (builtin) {
264 const inputs = given;
265 logActionHeader(job, x.io, uses, inputs);
266 const c = makeBuiltinContext(job, x, inputs, uses);
267 await builtin(c);
268 return;
269 }
270 }
271
272 let dir: string;
273 let repository: string;
274 let actionRef: string;
275 if (ref.kind === "local") {
276 dir = path.resolve(job.workspace, ref.path);
277 repository = "";
278 actionRef = "";
279 } else {
280 const root = await downloadAction(job, x.io, ref.owner, ref.repo, ref.ref);
281 dir = ref.subpath ? path.join(root, ref.subpath) : root;
282 repository = `${ref.owner}/${ref.repo}`;
283 actionRef = ref.ref;
284 }
285 const meta = await loadActionMeta(dir);
286 const using = String(meta.runs.using ?? "").toLowerCase();
287 const inputs = computeInputs(job, x, meta);
288 logActionHeader(job, x.io, uses, inputs);
289 const actionInfo = { path: dir, repository, ref: actionRef };
290
291 if (using === "docker" || meta.runs.image) {
292 const runs = meta.runs;
293 if (!runs.image) throw failure(`Action '${uses}' uses Docker but has no 'runs.image'`);
294 const actx = job.contexts({ ...x.scope, inputs }, x.envCtx);
295 const ev = (v: unknown) => job.interp(v === undefined || v === null ? "" : String(v), actx, x.scope);
296 const args = Array.isArray(runs.args) ? runs.args.map(ev) : [];
297 const env: Record<string, string> = {};
298 for (const [k, v] of Object.entries(runs.env ?? {})) env[k] = ev(v);
299 await runDockerAction(job, x, {
300 image: runs.image,
301 dir,
302 args,
303 entrypoint: runs.entrypoint ? ev(runs.entrypoint) : undefined,
304 pre: runs["pre-entrypoint"] ? ev(runs["pre-entrypoint"]) : undefined,
305 post: runs["post-entrypoint"] ? ev(runs["post-entrypoint"]) : undefined,
306 preIf: runs["pre-if"],
307 postIf: runs["post-if"],
308 env,
309 inputs,
310 action: actionInfo,
311 });
312 return;
313 }
314 if (using === "composite") {
315 await runComposite(job, x, meta, inputs, actionInfo);
316 return;
317 }
318 if (/^node\d+$/.test(using)) {
319 await runNodeAction(job, x, meta, inputs, actionInfo);
320 return;
321 }
322 throw failure(`Unsupported action runtime 'runs.using: ${meta.runs.using}' in ${uses}`);
323}
324
325function logActionHeader(job: JobRunner, io: StepIO, uses: string, inputs: Record<string, string>): void {
326 job.logger.line(io.logIndex, `##[group]Run ${uses}`);
327 const keys = Object.keys(inputs);
328 if (keys.length) {
329 job.logger.line(io.logIndex, "with:");
330 for (const k of keys) job.logger.line(io.logIndex, ` ${k}: ${inputs[k]!.split("\n").join("\n ")}`);
331 }
332 job.logger.line(io.logIndex, "##[endgroup]");
333}
334
335async function runNodeAction(
336 job: JobRunner,
337 x: StepExec,
338 meta: ActionMeta,
339 inputs: Record<string, string>,
340 action: { path: string; repository: string; ref: string },
341): Promise<void> {
342 const runs = meta.runs;
343 if (!runs.main) throw failure(`Action '${x.step.uses}' has no 'runs.main'`);
344 const inputEnv: Record<string, string> = {};
345 for (const [k, v] of Object.entries(inputs)) inputEnv[inputEnvName(k)] = v;
346 const baseExtra: Record<string, string> = {
347 ...job.runtimeEnv,
348 ...inputEnv,
349 GITHUB_ACTION: x.step.id ?? "__" + (action.repository || "local").replace(/[^A-Za-z0-9]/g, "_"),
350 GITHUB_ACTION_PATH: action.path,
351 GITHUB_ACTION_REPOSITORY: action.repository,
352 GITHUB_ACTION_REF: action.ref,
353 };
354 // @actions/artifact refuses to run on anything that looks like GitHub Enterprise Server
355 // (any GITHUB_SERVER_URL but github.com), so the artifact actions see github.com; their
356 // API calls still go to GITHUB_API_URL and the runtime services, and the URLs they
357 // output are pointed back at this server.
358 const serverUrl = job.processEnv(x.envCtx).GITHUB_SERVER_URL ?? "";
359 const artifactAction = ARTIFACT_ACTIONS.has(action.repository.toLowerCase());
360 if (artifactAction) baseExtra.GITHUB_SERVER_URL = "https://github.com";
361 const state: Record<string, string> = {};
362 const runScriptFile = async (file: string, io: StepIO, envCtx: Record<string, string>, extra: Record<string, string>, label: string) => {
363 const res = await job.runStepProcess([job.nodePath(), path.join(action.path, file)], {
364 cwd: job.workspace,
365 envCtx,
366 io,
367 timeoutMs: x.timeoutMs,
368 extraEnv: extra,
369 wrap: job.containerWrap(),
370 });
371 if (job.isStopping) return;
372 if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true);
373 if (res.exitCode !== 0) {
374 const msg = `${label}: Process completed with exit code ${res.exitCode}.`;
375 job.logger.line(io.logIndex, `##[error]${msg}`);
376 job.addAnnotation(io.logIndex, { level: "error", message: msg });
377 io.reason = msg;
378 throw failure(msg, true);
379 }
380 };
381
382 const ctxScope = x.scope;
383 if (runs.pre) {
384 const cond = runs["pre-if"] ?? "always()";
385 let ok = true;
386 try {
387 ok = job.evalIf(cond, x.ctx, ctxScope);
388 } catch (e) {
389 throw failure(`Error evaluating pre-if: ${errMsg(e)}`);
390 }
391 if (ok) {
392 const preIo: StepIO = { logIndex: x.io.logIndex, outputs: {}, state: {} };
393 job.logger.line(x.io.logIndex, `Running pre: ${runs.pre}`);
394 await runScriptFile(runs.pre, preIo, x.envCtx, baseExtra, "pre");
395 Object.assign(state, preIo.state);
396 }
397 }
398 if (runs.post) {
399 const postFile = runs.post;
400 const envAtMain = { ...x.envCtx };
401 job.registerPost({
402 name: `Post ${x.name}`,
403 condition: runs["post-if"] ?? "always()",
404 run: async (logIndex) => {
405 const stateEnv: Record<string, string> = {};
406 for (const [k, v] of Object.entries(state)) stateEnv[`STATE_${k}`] = v;
407 const postIo: StepIO = { logIndex, outputs: {}, state: {} };
408 await runScriptFile(postFile, postIo, { ...envAtMain, ...job.envAccum }, { ...baseExtra, ...stateEnv }, "post");
409 },
410 });
411 }
412 const mainIo = x.io;
413 try {
414 await runScriptFile(runs.main, mainIo, x.envCtx, { ...baseExtra, ...stateEnvOf(state) }, "main");
415 } finally {
416 Object.assign(state, mainIo.state);
417 const url = mainIo.outputs["artifact-url"];
418 if (artifactAction && serverUrl && url?.startsWith("https://github.com/")) mainIo.outputs["artifact-url"] = `${serverUrl}/${url.slice("https://github.com/".length)}`;
419 }
420}
421
422const ARTIFACT_ACTIONS = new Set(["actions/upload-artifact", "actions/download-artifact"]);
423
424/**
425 * The operator's github.com token for downloading actions (rate limits, private
426 * actions), read once when the runner starts. Steps never see it: job.ts strips
427 * it from every child environment.
428 */
429const GITHUB_TOKEN = process.env.DIFFINERY_GITHUB_TOKEN || undefined;
430
431/** Docker container actions: `uses: docker://image`, or an action with `runs.using: docker`. */
432async function runDockerAction(
433 job: JobRunner,
434 x: StepExec,
435 d: {
436 image: string;
437 dir: string | null;
438 args: string[];
439 entrypoint?: string;
440 pre?: string;
441 post?: string;
442 preIf?: string;
443 postIf?: string;
444 env: Record<string, string>;
445 inputs: Record<string, string>;
446 action: { path: string; repository: string; ref: string };
447 },
448): Promise<void> {
449 const image = await job.docker.prepareAction(x.io, d.image, d.dir);
450 const extra: Record<string, string> = {
451 ...job.runtimeEnv,
452 GITHUB_ACTION: x.step.id ?? "__" + (d.action.repository || "docker").replace(/[^A-Za-z0-9]/g, "_"),
453 GITHUB_ACTION_REPOSITORY: d.action.repository,
454 GITHUB_ACTION_REF: d.action.ref,
455 };
456 for (const [k, v] of Object.entries(d.inputs)) extra[inputEnvName(k)] = v;
457 const runIn = async (io: StepIO, envCtx: Record<string, string>, o: { entrypoint?: string; args: string[] }, label: string) => {
458 const wrap = await job.docker.runWrap(io, image, o);
459 const res = await job.runStepProcess([], { cwd: job.workspace, envCtx: { ...envCtx, ...d.env }, io, timeoutMs: x.timeoutMs, extraEnv: extra, wrap });
460 if (job.isStopping) return;
461 if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true);
462 if (res.exitCode !== 0) {
463 const msg = `${label}Docker run failed with exit code ${res.exitCode}`;
464 job.logger.line(io.logIndex, `##[error]${msg}`);
465 job.addAnnotation(io.logIndex, { level: "error", message: msg });
466 io.reason = msg;
467 throw failure(msg, true);
468 }
469 };
470 if (d.pre) {
471 let ok = true;
472 try {
473 ok = job.evalIf(d.preIf ?? "always()", x.ctx, x.scope);
474 } catch (e) {
475 throw failure(`Error evaluating pre-if: ${errMsg(e)}`);
476 }
477 if (ok) await runIn({ logIndex: x.io.logIndex, outputs: {}, state: {} }, x.envCtx, { entrypoint: d.pre, args: [] }, "pre: ");
478 }
479 if (d.post) {
480 const post = d.post;
481 const envAtMain = { ...x.envCtx };
482 job.registerPost({
483 name: `Post ${x.name}`,
484 condition: d.postIf ?? "always()",
485 run: async (logIndex) => runIn({ logIndex, outputs: {}, state: {} }, { ...envAtMain, ...job.envAccum }, { entrypoint: post, args: [] }, "post: "),
486 });
487 }
488 await runIn(x.io, x.envCtx, { entrypoint: d.entrypoint, args: d.args }, "");
489}
490
491function stateEnvOf(state: Record<string, string>): Record<string, string> {
492 const out: Record<string, string> = {};
493 for (const [k, v] of Object.entries(state)) out[`STATE_${k}`] = v;
494 return out;
495}
496
497async function runComposite(
498 job: JobRunner,
499 x: StepExec,
500 meta: ActionMeta,
501 inputs: Record<string, string>,
502 action: { path: string; repository: string; ref: string },
503): Promise<void> {
504 if (x.scope.depth > 9) throw failure("Composite action nesting is too deep (max 10).");
505 const steps = Array.isArray(meta.runs.steps) ? meta.runs.steps : [];
506 const scope: Scope = {
507 steps: {},
508 inputs,
509 failed: false,
510 inheritedEnv: { ...x.envCtx },
511 composite: true,
512 action,
513 depth: x.scope.depth + 1,
514 };
515 for (const s of steps) {
516 if (job.isStopping) break;
517 const sub = normaliseStep(s);
518 await job.executeStep(sub, scope, x.io.logIndex);
519 }
520 // outputs
521 const ctx = job.contexts(scope, job.baseEnvContext(scope));
522 for (const [k, o] of Object.entries(meta.outputs ?? {})) {
523 if (!o || o.value === undefined || o.value === null) continue;
524 try {
525 x.io.outputs[k] = job.interp(String(o.value), ctx, scope);
526 } catch (e) {
527 job.logger.line(x.io.logIndex, `##[warning]Failed to evaluate output '${k}': ${errMsg(e)}`);
528 }
529 }
530 if (job.isStopping) return;
531 if (scope.failed) throw failure(`Composite action '${x.step.uses}' failed.`, true);
532}
533
534function normaliseStep(s: unknown): StepSpec {
535 const o = (s ?? {}) as Record<string, unknown>;
536 const step: StepSpec = {};
537 if (o.id !== undefined) step.id = String(o.id);
538 if (o.name !== undefined) step.name = String(o.name);
539 if (o.if !== undefined && o.if !== null) step.if = typeof o.if === "boolean" ? String(o.if) : String(o.if);
540 if (o.run !== undefined) step.run = String(o.run);
541 if (o.uses !== undefined) step.uses = String(o.uses);
542 if (o.shell !== undefined) step.shell = String(o.shell);
543 if (o["working-directory"] !== undefined) step["working-directory"] = String(o["working-directory"]);
544 if (o["continue-on-error"] !== undefined) step["continue-on-error"] = o["continue-on-error"] as boolean | string;
545 if (o["timeout-minutes"] !== undefined) step["timeout-minutes"] = Number(o["timeout-minutes"]);
546 const strMap = (m: unknown) => {
547 const r: Record<string, string> = {};
548 if (m && typeof m === "object") for (const [k, v] of Object.entries(m)) r[k] = v === null || v === undefined ? "" : String(v);
549 return r;
550 };
551 if (o.with) step.with = strMap(o.with);
552 if (o.env) step.env = strMap(o.env);
553 return step;
554}