| 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 { cpuTimes, runnerInfo, 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 { RuntimeClient } from "./runtime.js"; |
| 14 | import { runAction } from "./steps/actions.js"; |
| 15 | import { runScript } from "./steps/run.js"; |
| 16 | import { exec, hashFiles, killTree, nowIso, runnerArch, type ExecResult } from "./util.js"; |
| 17 | |
| 18 | export type Outcome = "success" | "failure" | "cancelled" | "skipped"; |
| 19 | |
| 20 | export 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. */ |
| 27 | export 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 | |
| 39 | export interface StepIO extends OutputSink { |
| 40 | /** Short reason recorded when the step fails (e.g. exit code). */ |
| 41 | reason?: string; |
| 42 | } |
| 43 | |
| 44 | export interface StepResult { |
| 45 | outcome: Outcome; |
| 46 | conclusion: Outcome; |
| 47 | outputs: Record<string, string>; |
| 48 | } |
| 49 | |
| 50 | export 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 | |
| 57 | export 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 | |
| 66 | export class StepFailure extends Error {} |
| 67 | |
| 68 | /** The runner's own credentials: never in a step's environment. */ |
| 69 | const SENSITIVE_ENV = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET", "DIFFINERY_GITHUB_TOKEN"]; |
| 70 | |
| 71 | export 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 | // The platform's SIGTERM (a rollout, or a move) 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 stopping the runner."; |
| 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 | |
| 804 | export function errMsg(e: unknown): string { |
| 805 | return e instanceof Error ? e.message : String(e); |
| 806 | } |
| 807 | |
| 808 | export 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 | |
| 814 | export 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 | |
| 821 | function safeName(step: StepSpec, fallback: string): string { |
| 822 | return step.name && !String(step.name).includes("${{") ? String(step.name) : fallback; |
| 823 | } |