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.

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