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.

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