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.

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