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