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.

755 lines · 29.5 KBRaw History
1import type { ChildProcess } from "node:child_process";
2import { randomUUID } from "node:crypto";
3import { existsSync } from "node:fs";
4import { mkdir, readFile, writeFile } from "node:fs/promises";
5import path from "node:path";
6import type { Conclusion, ContainerSpec, JobCompletion, JobSpec, StepReport, StepSpec } from "@diffinery/shared";
7import { evaluate, evaluateIf, interpolate, type StatusFns } from "@diffinery/workflow-yaml";
8import type { RunnerApi } from "./api.js";
9import { parseKeyValueFile, parsePathFile } from "./commands.js";
10import { Docker, type Wrap } from "./docker.js";
11import { JobLogger, OutputProcessor, type Annotation, type OutputSink } from "./log.js";
12import { Masker } from "./mask.js";
13import { runAction } from "./steps/actions.js";
14import { runScript } from "./steps/run.js";
15import { exec, hashFiles, killTree, nowIso, runnerArch, type ExecResult } from "./util.js";
16
17export type Outcome = "success" | "failure" | "cancelled" | "skipped";
18
19export interface StepContextEntry {
20 outputs: Record<string, string>;
21 outcome: Outcome;
22 conclusion: Outcome;
23}
24
25/** Execution scope: the job itself, or the inside of a composite action. */
26export interface Scope {
27 steps: Record<string, StepContextEntry>;
28 inputs: Record<string, unknown>;
29 /** A step in this scope failed (without continue-on-error). */
30 failed: boolean;
31 /** Env inherited from the calling step (composite actions). */
32 inheritedEnv: Record<string, string>;
33 composite: boolean;
34 action?: { path: string; repository: string; ref: string };
35 depth: number;
36}
37
38export interface StepIO extends OutputSink {
39 /** Short reason recorded when the step fails (e.g. exit code). */
40 reason?: string;
41}
42
43export interface StepResult {
44 outcome: Outcome;
45 conclusion: Outcome;
46 outputs: Record<string, string>;
47}
48
49export interface PostStep {
50 name: string;
51 /** Expression evaluated with job status (e.g. `always()`, `success()`). */
52 condition: string;
53 run: (logIndex: number) => Promise<void>;
54}
55
56export interface RunnerOptions {
57 workRoot: string;
58 /** Tool cache dir; defaults to `${workRoot}/_tool` (or RUNNER_TOOL_CACHE). */
59 toolCache?: string;
60 echoLogs?: boolean;
61 /** Base env for child processes (defaults to process.env). */
62 baseEnv?: NodeJS.ProcessEnv;
63}
64
65export class StepFailure extends Error {}
66
67const SENSITIVE_ENV = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET"];
68
69export class JobRunner {
70 readonly masker = new Masker();
71 readonly logger: JobLogger;
72 readonly workspace: string;
73 readonly tempDir: string;
74 readonly toolCache: string;
75 readonly actionsDir: string;
76 readonly fileCmdDir: string;
77 readonly eventPath: string;
78 readonly debug: boolean;
79
80 /** Accumulated GITHUB_ENV values. */
81 envAccum: Record<string, string> = {};
82 /** Accumulated GITHUB_PATH entries, most recent first. */
83 pathAccum: string[] = [];
84 jobEnv: Record<string, string> = {};
85 readonly summaries: string[] = [];
86 readonly posts: PostStep[] = [];
87 readonly children = new Set<ChildProcess>();
88 readonly stepErrors = new Map<number, Annotation[]>();
89 readonly stepReasons = new Map<number, string>();
90 /** Job and service containers, and Docker actions (self-hosted runners with Docker). */
91 readonly docker: Docker;
92
93 private stopping: null | "cancelled" | "timeout" = null;
94 private firstFailure: { step?: number; message: string } | null = null;
95 private githubCtx: Record<string, unknown> = {};
96 private defaultEnv: Record<string, string> = {};
97 private baseEnv: NodeJS.ProcessEnv;
98 private secrets: Record<string, string>;
99
100 constructor(
101 readonly spec: JobSpec,
102 readonly api: RunnerApi,
103 logger: JobLogger | null,
104 readonly opts: RunnerOptions,
105 batcher: import("./api.js").LogBatcher | null = null,
106 ) {
107 this.logger = logger ?? new JobLogger(this.masker, batcher, !!opts.echoLogs);
108 this.workspace = path.join(opts.workRoot, spec.repo.name);
109 this.tempDir = path.join(opts.workRoot, "_temp");
110 this.toolCache = opts.toolCache ?? process.env.RUNNER_TOOL_CACHE ?? path.join(opts.workRoot, "_tool");
111 this.actionsDir = path.join(opts.workRoot, "_actions");
112 this.fileCmdDir = path.join(this.tempDir, "_runner_file_commands");
113 this.eventPath = path.join(this.tempDir, "_github_workflow", "event.json");
114 this.secrets = { ...spec.secrets, GITHUB_TOKEN: spec.token };
115 this.debug = this.secrets.ACTIONS_STEP_DEBUG === "true" || spec.vars?.ACTIONS_STEP_DEBUG === "true";
116 const base = { ...(opts.baseEnv ?? process.env) };
117 for (const k of SENSITIVE_ENV) delete base[k];
118 this.baseEnv = base;
119 for (const v of Object.values(this.secrets)) this.masker.add(v);
120 this.docker = new Docker(this);
121 }
122
123 /** The runner's own environment (for internal tools such as the docker CLI). */
124 hostEnv(): NodeJS.ProcessEnv {
125 return { ...this.baseEnv };
126 }
127
128 // ------------------------------------------------------------ public
129
130 get isStopping(): boolean {
131 return this.stopping !== null;
132 }
133
134 /** SIGTERM / external cancellation. */
135 cancel(reason: "cancelled" | "timeout" = "cancelled"): void {
136 if (this.stopping) return;
137 this.stopping = reason;
138 for (const c of this.children) killTree(c);
139 }
140
141 async run(): Promise<JobCompletion> {
142 const spec = this.spec;
143 let jobTimer: NodeJS.Timeout | undefined;
144 if (spec.timeoutMinutes > 0) {
145 jobTimer = setTimeout(() => {
146 this.logger.line(-1, `##[error]The job running on runner has exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.`);
147 this.cancel("timeout");
148 }, spec.timeoutMinutes * 60_000);
149 jobTimer.unref();
150 }
151 const top: Scope = { steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 };
152 try {
153 await this.setup();
154 await this.initContainers(top);
155 for (let i = 0; i < spec.steps.length; i++) {
156 const step = spec.steps[i]!;
157 const fallbackName = defaultStepName(step);
158 if (this.stopping) {
159 await this.report(i, { status: "completed", conclusion: "cancelled", name: safeName(step, fallbackName), completedAt: nowIso() });
160 continue;
161 }
162 const startedAt = nowIso();
163 let started = false;
164 const res = await this.executeStep(step, top, i, async (name) => {
165 started = true;
166 await this.report(i, { status: "in_progress", name, startedAt });
167 });
168 await this.report(i, {
169 status: "completed",
170 conclusion: res.conclusion,
171 name: res.name ?? fallbackName,
172 ...(started ? { startedAt } : {}),
173 completedAt: nowIso(),
174 });
175 }
176 if (!this.stopping) await this.runPosts(top);
177 } catch (e) {
178 const msg = e instanceof Error ? e.message : String(e);
179 this.logger.line(-1, `##[error]${msg}`);
180 top.failed = true;
181 this.firstFailure ??= { message: msg };
182 } finally {
183 if (jobTimer) clearTimeout(jobTimer);
184 try {
185 await this.docker.cleanup({ logIndex: -1, outputs: {}, state: {} });
186 } catch (e) {
187 this.logger.line(-1, `##[warning]Could not remove the job's containers: ${errMsg(e)}`);
188 }
189 }
190
191 let conclusion: Conclusion = top.failed ? "failure" : "success";
192 if (this.stopping === "cancelled") conclusion = "cancelled";
193 if (this.stopping === "timeout") {
194 conclusion = "failure";
195 this.firstFailure = { ...(this.firstFailure ?? {}), message: `The job exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.` };
196 }
197 const outputs = this.jobOutputs(top);
198 const completion: JobCompletion = { conclusion, outputs };
199 const summary = this.summaries.filter((s) => s.trim() !== "").join("\n");
200 if (summary) completion.summary = this.masker.mask(summary);
201 if (conclusion === "failure" && this.firstFailure) completion.failure = { ...this.firstFailure, message: this.masker.mask(this.firstFailure.message) };
202 if (conclusion === "cancelled") completion.failure = { message: "The job was cancelled." };
203 this.logger.line(-1, `Job completed with conclusion: ${conclusion}`);
204 return completion;
205 }
206
207 // ------------------------------------------------------------ setup
208
209 private async setup(): Promise<void> {
210 const spec = this.spec;
211 await mkdir(this.workspace, { recursive: true });
212 await mkdir(this.fileCmdDir, { recursive: true });
213 await mkdir(this.toolCache, { recursive: true });
214 await mkdir(this.actionsDir, { recursive: true });
215 await mkdir(path.dirname(this.eventPath), { recursive: true });
216 const g = spec.github ?? {};
217 const refName = String(g.ref_name ?? spec.ref.replace(/^refs\/(heads|tags)\//, ""));
218 const refType = String(g.ref_type ?? (spec.ref.startsWith("refs/tags/") ? "tag" : "branch"));
219 const server = spec.apiUrl.replace(/\/$/, "");
220 const repoFull = `${spec.repo.owner}/${spec.repo.name}`;
221 this.githubCtx = {
222 repository: repoFull,
223 repository_owner: spec.repo.owner,
224 sha: spec.sha,
225 ref: spec.ref,
226 ref_name: refName,
227 ref_type: refType,
228 run_id: spec.runId,
229 run_number: g.run_number ?? "1",
230 run_attempt: g.run_attempt ?? "1",
231 job: spec.jobKey,
232 workflow: spec.workflowPath,
233 event_name: "push",
234 event: {},
235 actor: "diffinery",
236 server_url: server,
237 api_url: `${server}/api/v3`,
238 graphql_url: `${server}/api/graphql`,
239 ...g,
240 workspace: this.workspace,
241 token: spec.token,
242 action: "",
243 action_path: "",
244 };
245 await writeFile(this.eventPath, JSON.stringify(this.githubCtx.event ?? {}, null, 2));
246 const str = (v: unknown) => (v === undefined || v === null ? "" : String(v));
247 const d: Record<string, string> = {
248 CI: "true",
249 GITHUB_ACTIONS: "true",
250 DIFFINERY: "true",
251 GITHUB_REPOSITORY: repoFull,
252 GITHUB_REPOSITORY_OWNER: spec.repo.owner,
253 GITHUB_SHA: spec.sha,
254 GITHUB_REF: spec.ref,
255 GITHUB_REF_NAME: refName,
256 GITHUB_REF_TYPE: refType,
257 GITHUB_HEAD_REF: str(g.head_ref),
258 GITHUB_BASE_REF: str(g.base_ref),
259 GITHUB_RUN_ID: spec.runId,
260 GITHUB_RUN_NUMBER: str(this.githubCtx.run_number),
261 GITHUB_RUN_ATTEMPT: str(this.githubCtx.run_attempt),
262 GITHUB_JOB: spec.jobKey,
263 GITHUB_ACTOR: str(this.githubCtx.actor),
264 GITHUB_TRIGGERING_ACTOR: str(g.triggering_actor ?? this.githubCtx.actor),
265 GITHUB_EVENT_NAME: str(this.githubCtx.event_name),
266 GITHUB_EVENT_PATH: this.eventPath,
267 GITHUB_SERVER_URL: server,
268 GITHUB_API_URL: `${server}/api/v3`,
269 GITHUB_GRAPHQL_URL: `${server}/api/graphql`,
270 GITHUB_WORKFLOW: str(g.workflow ?? spec.workflowPath),
271 GITHUB_WORKFLOW_REF: str(g.workflow_ref ?? `${repoFull}/${spec.workflowPath}@${spec.ref}`),
272 GITHUB_WORKSPACE: this.workspace,
273 GITHUB_RETENTION_DAYS: "90",
274 RUNNER_OS: "Linux",
275 RUNNER_ARCH: runnerArch(),
276 RUNNER_NAME: "diffinery",
277 RUNNER_ENVIRONMENT: "diffinery-hosted",
278 RUNNER_TEMP: this.tempDir,
279 RUNNER_TOOL_CACHE: this.toolCache,
280 RUNNER_WORKSPACE: path.dirname(this.workspace),
281 AGENT_TOOLSDIRECTORY: this.toolCache,
282 };
283 if (this.debug) d.RUNNER_DEBUG = "1";
284 if (spec.idToken && this.api.online) {
285 // @actions/core appends `&audience=...`, so the URL must already carry a query string.
286 d.ACTIONS_ID_TOKEN_REQUEST_URL = `${this.api.url("/oidc")}?api-version=2.0`;
287 d.ACTIONS_ID_TOKEN_REQUEST_TOKEN = spec.token;
288 }
289 this.defaultEnv = d;
290 this.logger.line(-1, `Diffinery runner: job ${spec.jobKey} (${spec.name}) for ${repoFull}@${spec.sha.slice(0, 12)}`);
291 this.logger.line(-1, `Workspace: ${this.workspace}`);
292 // job-level env
293 const ctx = this.contexts({ steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }, {});
294 for (const [k, v] of Object.entries(spec.env ?? {})) {
295 this.jobEnv[k] = this.interp(String(v ?? ""), ctx);
296 }
297 }
298
299 // ------------------------------------------------------------ containers
300
301 private evalContainer(spec: ContainerSpec, ctx: Record<string, unknown>): ContainerSpec {
302 const i = (v: unknown) => this.interp(v === undefined || v === null ? "" : String(v), ctx);
303 const out: ContainerSpec = { image: i(spec.image).trim() };
304 if (spec.credentials) out.credentials = { username: i(spec.credentials.username), password: i(spec.credentials.password) };
305 if (spec.env) out.env = Object.fromEntries(Object.entries(spec.env).map(([k, v]) => [k, i(v)]));
306 if (spec.ports) out.ports = spec.ports.map((p) => i(p)).filter(Boolean);
307 if (spec.volumes) out.volumes = spec.volumes.map((v) => i(v)).filter(Boolean);
308 if (spec.options) out.options = i(spec.options);
309 return out;
310 }
311
312 /** Starts service containers and the job container, like GitHub's "Initialize containers". */
313 private async initContainers(top: Scope): Promise<void> {
314 const spec = this.spec;
315 if (!spec.container && !Object.keys(spec.services ?? {}).length) return;
316 const ctx = this.contexts(top, { ...this.jobEnv });
317 const container = spec.container ? this.evalContainer(spec.container, ctx) : null;
318 // A service whose image evaluates to an empty string is skipped (GitHub does the same).
319 const services = Object.entries(spec.services ?? {})
320 .map(([k, s]) => [k, this.evalContainer(s, ctx)] as const)
321 .filter(([, s]) => s.image);
322 if (!container?.image && !services.length) return;
323 const io: StepIO = { logIndex: -1, outputs: {}, state: {} };
324 this.logger.line(-1, "##[group]Initialize containers");
325 try {
326 await this.docker.ensure(io, container?.image ? "Job containers (jobs.<id>.container)" : "Service containers (jobs.<id>.services)");
327 for (const [key, s] of services) await this.docker.startService(io, key, s);
328 if (container?.image) await this.docker.startJobContainer(io, container);
329 } finally {
330 this.logger.line(-1, "##[endgroup]");
331 }
332 }
333
334 /** Wraps a step process so it runs in the job container, when there is one. */
335 containerWrap(): Wrap | undefined {
336 return this.docker.execWrap();
337 }
338
339 /** The node binary JavaScript actions run with (the runner's own, mounted into a job container). */
340 nodePath(): string {
341 return this.docker.container ? "/__e/node" : process.execPath;
342 }
343
344 // ------------------------------------------------------------ contexts
345
346 statusFns(scope: Scope): StatusFns {
347 return {
348 success: () => !scope.failed && this.stopping === null,
349 failure: () => scope.failed,
350 cancelled: () => this.stopping !== null,
351 always: () => true,
352 };
353 }
354
355 extra(): { hashFiles: (...p: string[]) => string } {
356 return { hashFiles: (...patterns: string[]) => hashFiles(this.workspace, patterns) };
357 }
358
359 /** Env context visible to a step before its own `env:` is applied. */
360 baseEnvContext(scope: Scope): Record<string, string> {
361 return { ...this.jobEnv, ...this.envAccum, ...scope.inheritedEnv };
362 }
363
364 contexts(scope: Scope, env: Record<string, string>): Record<string, unknown> {
365 const spec = this.spec;
366 const github = { ...this.githubCtx };
367 if (scope.action) {
368 github.action_path = scope.action.path;
369 github.action_repository = scope.action.repository;
370 github.action_ref = scope.action.ref;
371 }
372 return {
373 github,
374 env,
375 vars: spec.vars ?? {},
376 secrets: this.secrets,
377 matrix: spec.matrix ?? {},
378 needs: spec.needs ?? {},
379 steps: scope.steps,
380 runner: {
381 os: "Linux",
382 arch: runnerArch(),
383 name: "diffinery",
384 temp: this.tempDir,
385 tool_cache: this.toolCache,
386 workspace: path.dirname(this.workspace),
387 debug: this.debug ? "1" : "",
388 environment: "diffinery-hosted",
389 },
390 inputs: scope.inputs,
391 job: {
392 status: this.stopping ? "cancelled" : scope.failed ? "failure" : "success",
393 container: this.docker.container ? { id: this.docker.container.id, network: this.docker.container.network } : {},
394 services: Object.fromEntries(Object.entries(this.docker.services).map(([k, v]) => [k, { id: v.id, network: v.network, ports: v.ports }])),
395 },
396 strategy: spec.strategy ?? {},
397 };
398 }
399
400 interp(template: string, ctx: Record<string, unknown>, scope?: Scope): string {
401 if (!template.includes("${{")) return template;
402 const status = scope ? this.statusFns(scope) : undefined;
403 return interpolate(template, ctx, status, this.extra());
404 }
405
406 evalIf(cond: string | undefined, ctx: Record<string, unknown>, scope: Scope): boolean {
407 return evaluateIf(cond, ctx, this.statusFns(scope), this.extra());
408 }
409
410 evalExpr(expr: string, ctx: Record<string, unknown>, scope: Scope): unknown {
411 return evaluate(expr, ctx, this.statusFns(scope), this.extra());
412 }
413
414 /** Builds the environment for a child process. */
415 processEnv(envCtx: Record<string, string>, extra: Record<string, string> = {}): NodeJS.ProcessEnv {
416 const env: NodeJS.ProcessEnv = { ...this.baseEnv, ...envCtx, ...this.defaultEnv, ...extra };
417 const basePath = envCtx.PATH ?? this.baseEnv.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
418 env.PATH = [...this.pathAccum, basePath].filter(Boolean).join(path.delimiter);
419 return env;
420 }
421
422 // ------------------------------------------------------------ steps
423
424 /**
425 * Evaluates `if`, env, name; runs the step; updates the scope's steps
426 * context and failure state.
427 */
428 async executeStep(step: StepSpec, scope: Scope, logIndex: number, onStart?: (name: string) => Promise<void>): Promise<StepResult & { name: string }> {
429 const baseEnv = this.baseEnvContext(scope);
430 let ctx = this.contexts(scope, baseEnv);
431 let name = defaultStepName(step);
432 try {
433 name = step.name !== undefined ? this.interp(String(step.name), ctx, scope) : name;
434 } catch {
435 /* keep default */
436 }
437 const id = step.id;
438 const record = (outcome: Outcome, conclusion: Outcome, outputs: Record<string, string>) => {
439 if (id) scope.steps[id] = { outputs, outcome, conclusion };
440 };
441
442 let run: boolean;
443 try {
444 run = evaluateIf(step.if, ctx, this.statusFns(scope), this.extra());
445 } catch (e) {
446 this.logger.line(logIndex, `##[error]Error evaluating 'if' condition: ${errMsg(e)}`);
447 this.noteFailure(logIndex, `Error evaluating 'if' condition of step '${name}': ${errMsg(e)}`);
448 scope.failed = true;
449 record("failure", "failure", {});
450 return { outcome: "failure", conclusion: "failure", outputs: {}, name };
451 }
452 if (!run) {
453 if (scope.depth > 0) this.logger.line(logIndex, `Skipping step '${name}' (condition not met)`);
454 record("skipped", "skipped", {});
455 return { outcome: "skipped", conclusion: "skipped", outputs: {}, name };
456 }
457 await onStart?.(name);
458
459 const io: StepIO = { logIndex, outputs: {}, state: {} };
460 let outcome: Outcome = "success";
461 let continueOnError = false;
462 try {
463 continueOnError = this.evalBoolish(step["continue-on-error"], ctx, scope);
464 const stepEnv: Record<string, string> = {};
465 const envCtx = { ...baseEnv };
466 ctx = this.contexts(scope, envCtx);
467 for (const [k, v] of Object.entries(step.env ?? {})) {
468 stepEnv[k] = this.interp(String(v ?? ""), ctx, scope);
469 envCtx[k] = stepEnv[k]!;
470 }
471 const timeoutMin = this.evalNumber(step["timeout-minutes"], ctx, scope);
472 const timeoutMs = timeoutMin > 0 ? timeoutMin * 60_000 : 0;
473 if (step.run !== undefined) {
474 await runScript(this, { step, scope, ctx, envCtx, io, timeoutMs, name });
475 } else if (step.uses) {
476 await runAction(this, { step, scope, ctx, envCtx, io, timeoutMs, name });
477 } else {
478 throw new StepFailure("Step has neither `run` nor `uses`.");
479 }
480 if (this.stopping) outcome = "cancelled";
481 } catch (e) {
482 if (this.stopping) {
483 outcome = "cancelled";
484 } else {
485 outcome = "failure";
486 const msg = errMsg(e);
487 if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) {
488 this.logger.line(logIndex, `##[error]${msg}`);
489 this.addAnnotation(logIndex, { level: "error", message: msg });
490 }
491 io.reason ??= msg;
492 }
493 }
494
495 let conclusion: Outcome = outcome;
496 if (outcome === "failure") {
497 if (continueOnError) {
498 conclusion = "success";
499 this.logger.line(logIndex, "##[warning]Step failed but continue-on-error is set; continuing.");
500 } else {
501 scope.failed = true;
502 this.noteFailure(logIndex, io.reason);
503 }
504 }
505 record(outcome, conclusion, io.outputs);
506 return { outcome, conclusion, outputs: io.outputs, name };
507 }
508
509 private evalBoolish(v: unknown, ctx: Record<string, unknown>, scope: Scope): boolean {
510 if (v === undefined || v === null || v === false) return false;
511 if (v === true) return true;
512 let s = String(v).trim();
513 if (s.includes("${{")) s = this.interp(s, ctx, scope);
514 return s === "true";
515 }
516
517 private evalNumber(v: unknown, ctx: Record<string, unknown>, scope: Scope): number {
518 if (v === undefined || v === null || v === "") return 0;
519 if (typeof v === "number") return v;
520 let s = String(v);
521 if (s.includes("${{")) s = this.interp(s, ctx, scope);
522 const n = Number(s);
523 return Number.isFinite(n) ? n : 0;
524 }
525
526 addAnnotation(logIndex: number, a: Annotation): void {
527 let list = this.stepErrors.get(logIndex);
528 if (!list) this.stepErrors.set(logIndex, (list = []));
529 list.push(a);
530 }
531
532 private noteFailure(logIndex: number, reason?: string): void {
533 if (this.firstFailure) return;
534 const errs = (this.stepErrors.get(logIndex) ?? []).filter((a) => a.level === "error");
535 // prefer annotations emitted by the step itself over our generic "exit code" message
536 const userErr = [...errs].reverse().find((a) => a.message !== reason);
537 let message: string;
538 if (userErr) {
539 const loc = userErr.file ? `${userErr.file}${userErr.line ? `:${userErr.line}` : ""}: ` : "";
540 message = `${loc}${userErr.title ? `${userErr.title}: ` : ""}${userErr.message}`;
541 } else {
542 const tail = this.logger.tail(logIndex, 20).filter((l) => !l.startsWith("##[error]"));
543 message = [reason ?? "Step failed", ...(tail.length ? ["", ...tail] : [])].join("\n");
544 }
545 this.firstFailure = { step: logIndex >= 0 ? logIndex : undefined, message };
546 if (this.firstFailure.step === undefined) delete this.firstFailure.step;
547 }
548
549 /**
550 * Runs a process for a step with fresh file-command files, interpreting
551 * workflow commands, then applies GITHUB_ENV/PATH/OUTPUT/STATE/SUMMARY.
552 */
553 async runStepProcess(
554 argv: string[],
555 o: {
556 cwd: string;
557 envCtx: Record<string, string>;
558 io: StepIO;
559 timeoutMs?: number;
560 extraEnv?: Record<string, string>;
561 timeoutLabel?: string;
562 /** Run the process somewhere else (the job container, or a Docker action's container). */
563 wrap?: Wrap;
564 },
565 ): Promise<ExecResult> {
566 const id = randomUUID();
567 const files = {
568 GITHUB_ENV: path.join(this.fileCmdDir, `set_env_${id}`),
569 GITHUB_PATH: path.join(this.fileCmdDir, `add_path_${id}`),
570 GITHUB_OUTPUT: path.join(this.fileCmdDir, `set_output_${id}`),
571 GITHUB_STATE: path.join(this.fileCmdDir, `save_state_${id}`),
572 GITHUB_STEP_SUMMARY: path.join(this.fileCmdDir, `step_summary_${id}`),
573 };
574 await mkdir(this.fileCmdDir, { recursive: true });
575 await Promise.all(Object.values(files).map((f) => writeFile(f, "")));
576 const proc = this.processor(o.io);
577 if (!existsSync(o.cwd)) {
578 throw new StepFailure(`An error occurred trying to start process '${argv[0]}' with working directory '${o.cwd}'. No such file or directory`);
579 }
580 let cmd = argv;
581 let cwd = o.cwd;
582 let env = this.processEnv(o.envCtx, { ...(o.extraEnv ?? {}), ...files });
583 if (o.wrap) ({ argv: cmd, cwd, env } = o.wrap(argv, { ...o.envCtx, ...this.defaultEnv, ...(o.extraEnv ?? {}), ...files }, o.cwd));
584 const res = await exec(cmd[0]!, cmd.slice(1), {
585 cwd,
586 env,
587 timeoutMs: o.timeoutMs,
588 onLine: (l) => proc.handle(l),
589 onSpawn: (c) => {
590 this.children.add(c);
591 c.once("close", () => this.children.delete(c));
592 },
593 });
594 await this.applyFileCommands(files, o.io);
595 if (res.timedOut) {
596 const mins = (o.timeoutMs ?? 0) / 60000;
597 const msg = `The ${o.timeoutLabel ?? "step"} has timed out after ${Number(mins.toFixed(2))} minutes.`;
598 this.logger.line(o.io.logIndex, `##[error]${msg}`);
599 this.addAnnotation(o.io.logIndex, { level: "error", message: msg });
600 o.io.reason = msg;
601 }
602 return res;
603 }
604
605 processor(io: StepIO): OutputProcessor {
606 return new OutputProcessor(this.logger, this.masker, io, {
607 debug: this.debug,
608 onAnnotation: (a) => this.addAnnotation(io.logIndex, a),
609 });
610 }
611
612 private async applyFileCommands(files: Record<string, string>, io: StepIO): Promise<void> {
613 const read = async (f: string) => {
614 try {
615 return await readFile(f, "utf8");
616 } catch {
617 return "";
618 }
619 };
620 const [envC, pathC, outC, stateC, sumC] = await Promise.all([
621 read(files.GITHUB_ENV!),
622 read(files.GITHUB_PATH!),
623 read(files.GITHUB_OUTPUT!),
624 read(files.GITHUB_STATE!),
625 read(files.GITHUB_STEP_SUMMARY!),
626 ]);
627 const safe = (label: string, fn: () => void) => {
628 try {
629 fn();
630 } catch (e) {
631 this.logger.line(io.logIndex, `##[error]Unable to process file command '${label}' successfully: ${errMsg(e)}`);
632 this.addAnnotation(io.logIndex, { level: "error", message: `Unable to process file command '${label}': ${errMsg(e)}` });
633 }
634 };
635 safe("env", () => {
636 for (const [k, v] of parseKeyValueFile(envC)) {
637 if (k === "NODE_OPTIONS" && /--require|-r\b/.test(v)) continue;
638 this.envAccum[k] = v;
639 }
640 });
641 safe("path", () => {
642 for (const p of parsePathFile(pathC)) this.addPath(p);
643 });
644 safe("output", () => {
645 for (const [k, v] of parseKeyValueFile(outC)) io.outputs[k] = v;
646 });
647 safe("state", () => {
648 for (const [k, v] of parseKeyValueFile(stateC)) io.state[k] = v;
649 });
650 if (sumC.trim()) this.summaries.push(sumC);
651 }
652
653 addPath(p: string): void {
654 this.pathAccum = [p, ...this.pathAccum.filter((x) => x !== p)];
655 }
656
657 /** Runs an internal tool (git, tar, ...) streaming its output into the step log. */
658 async tool(cmd: string, args: string[], o: { io: StepIO; cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult> {
659 return exec(cmd, args, {
660 cwd: o.cwd ?? this.workspace,
661 env: o.env ?? this.processEnv(this.baseEnvContext({ steps: {}, inputs: {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 })),
662 capture: o.capture,
663 input: o.input,
664 onLine: o.silent ? undefined : (l) => this.logger.line(o.io.logIndex, l),
665 onSpawn: (c) => {
666 this.children.add(c);
667 c.once("close", () => this.children.delete(c));
668 },
669 });
670 }
671
672 registerPost(p: PostStep): void {
673 this.posts.push(p);
674 }
675
676 private async runPosts(top: Scope): Promise<void> {
677 const posts = [...this.posts].reverse();
678 let index = this.spec.steps.length;
679 for (const p of posts) {
680 const logIndex = index++;
681 if (this.stopping) break;
682 let ok: boolean;
683 try {
684 ok = evaluateIf(p.condition, this.contexts(top, this.baseEnvContext(top)), this.statusFns(top), this.extra());
685 } catch {
686 ok = false;
687 }
688 if (!ok) continue;
689 const startedAt = nowIso();
690 await this.report(logIndex, { status: "in_progress", name: p.name, startedAt });
691 let conclusion: Conclusion = "success";
692 try {
693 await p.run(logIndex);
694 } catch (e) {
695 conclusion = "failure";
696 const msg = errMsg(e);
697 if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) {
698 this.logger.line(logIndex, `##[error]${msg}`);
699 this.addAnnotation(logIndex, { level: "error", message: msg });
700 }
701 top.failed = true;
702 this.noteFailure(logIndex, msg);
703 }
704 if (this.stopping) conclusion = "cancelled";
705 await this.report(logIndex, { status: "completed", conclusion, name: p.name, startedAt, completedAt: nowIso() });
706 }
707 }
708
709 private jobOutputs(top: Scope): Record<string, string> {
710 const out: Record<string, string> = {};
711 const ctx = this.contexts(top, this.baseEnvContext(top));
712 for (const [k, expr] of Object.entries(this.spec.outputs ?? {})) {
713 try {
714 const v = this.interp(String(expr), ctx, top);
715 if (this.masker.containsSecret(v)) {
716 this.logger.line(-1, `##[warning]Skip output '${k}' since it may contain secret.`);
717 continue;
718 }
719 out[k] = v;
720 } catch (e) {
721 this.logger.line(-1, `##[warning]Failed to evaluate job output '${k}': ${errMsg(e)}`);
722 }
723 }
724 return out;
725 }
726
727 private async report(index: number, r: StepReport): Promise<void> {
728 try {
729 await this.api.postStep(index, { ...r, name: this.masker.mask(r.name) });
730 } catch (e) {
731 this.logger.line(-1, `##[warning]Failed to report step ${index}: ${errMsg(e)}`);
732 }
733 }
734}
735
736export function errMsg(e: unknown): string {
737 return e instanceof Error ? e.message : String(e);
738}
739
740export function failure(message: string, logged = false): StepFailure {
741 const f = new StepFailure(message) as StepFailure & { logged?: boolean };
742 f.logged = logged;
743 return f;
744}
745
746export function defaultStepName(step: StepSpec): string {
747 if (step.name) return String(step.name);
748 if (step.uses) return `Run ${step.uses}`;
749 if (step.run) return `Run ${String(step.run).trim().split("\n")[0]}`;
750 return "Step";
751}
752
753function safeName(step: StepSpec, fallback: string): string {
754 return step.name && !String(step.name).includes("${{") ? String(step.name) : fallback;
755}