takos-actions-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/clover.xml +3477 -0
  4. package/coverage/coverage-final.json +20 -0
  5. package/coverage/favicon.png +0 -0
  6. package/coverage/index.html +176 -0
  7. package/coverage/prettify.css +1 -0
  8. package/coverage/prettify.js +2 -0
  9. package/coverage/sort-arrow-sprite.png +0 -0
  10. package/coverage/sorter.js +210 -0
  11. package/coverage/src/context/base.ts.html +1792 -0
  12. package/coverage/src/context/env.ts.html +1243 -0
  13. package/coverage/src/context/index.html +161 -0
  14. package/coverage/src/context/index.ts.html +229 -0
  15. package/coverage/src/context/secrets.ts.html +1276 -0
  16. package/coverage/src/index.html +131 -0
  17. package/coverage/src/index.ts.html +502 -0
  18. package/coverage/src/parser/expression.ts.html +2854 -0
  19. package/coverage/src/parser/index.html +161 -0
  20. package/coverage/src/parser/index.ts.html +163 -0
  21. package/coverage/src/parser/validator.ts.html +1588 -0
  22. package/coverage/src/parser/workflow.ts.html +616 -0
  23. package/coverage/src/scheduler/dependency.ts.html +1138 -0
  24. package/coverage/src/scheduler/index.html +221 -0
  25. package/coverage/src/scheduler/index.ts.html +214 -0
  26. package/coverage/src/scheduler/job-context.ts.html +265 -0
  27. package/coverage/src/scheduler/job-policy.ts.html +559 -0
  28. package/coverage/src/scheduler/job.ts.html +1816 -0
  29. package/coverage/src/scheduler/listener-registry.ts.html +199 -0
  30. package/coverage/src/scheduler/step.ts.html +2206 -0
  31. package/coverage/src/scheduler/steps-context.ts.html +217 -0
  32. package/coverage/src/types.ts.html +1897 -0
  33. package/coverage/src/utils/index.html +116 -0
  34. package/coverage/src/utils/needs.ts.html +127 -0
  35. package/dist/__tests__/context/env.test.d.ts +2 -0
  36. package/dist/__tests__/context/env.test.d.ts.map +1 -0
  37. package/dist/__tests__/context/env.test.js +28 -0
  38. package/dist/__tests__/context/env.test.js.map +1 -0
  39. package/dist/__tests__/index.test.d.ts +2 -0
  40. package/dist/__tests__/index.test.d.ts.map +1 -0
  41. package/dist/__tests__/index.test.js +50 -0
  42. package/dist/__tests__/index.test.js.map +1 -0
  43. package/dist/__tests__/parser/expression.test.d.ts +2 -0
  44. package/dist/__tests__/parser/expression.test.d.ts.map +1 -0
  45. package/dist/__tests__/parser/expression.test.js +116 -0
  46. package/dist/__tests__/parser/expression.test.js.map +1 -0
  47. package/dist/__tests__/parser/workflow.test.d.ts +2 -0
  48. package/dist/__tests__/parser/workflow.test.d.ts.map +1 -0
  49. package/dist/__tests__/parser/workflow.test.js +134 -0
  50. package/dist/__tests__/parser/workflow.test.js.map +1 -0
  51. package/dist/__tests__/scheduler/dependency.test.d.ts +2 -0
  52. package/dist/__tests__/scheduler/dependency.test.d.ts.map +1 -0
  53. package/dist/__tests__/scheduler/dependency.test.js +41 -0
  54. package/dist/__tests__/scheduler/dependency.test.js.map +1 -0
  55. package/dist/__tests__/scheduler/job-context.test.d.ts +2 -0
  56. package/dist/__tests__/scheduler/job-context.test.d.ts.map +1 -0
  57. package/dist/__tests__/scheduler/job-context.test.js +108 -0
  58. package/dist/__tests__/scheduler/job-context.test.js.map +1 -0
  59. package/dist/__tests__/scheduler/job-policy.test.d.ts +2 -0
  60. package/dist/__tests__/scheduler/job-policy.test.d.ts.map +1 -0
  61. package/dist/__tests__/scheduler/job-policy.test.js +159 -0
  62. package/dist/__tests__/scheduler/job-policy.test.js.map +1 -0
  63. package/dist/__tests__/scheduler/job.test.d.ts +2 -0
  64. package/dist/__tests__/scheduler/job.test.d.ts.map +1 -0
  65. package/dist/__tests__/scheduler/job.test.js +826 -0
  66. package/dist/__tests__/scheduler/job.test.js.map +1 -0
  67. package/dist/__tests__/scheduler/listener-registry.test.d.ts +2 -0
  68. package/dist/__tests__/scheduler/listener-registry.test.d.ts.map +1 -0
  69. package/dist/__tests__/scheduler/listener-registry.test.js +79 -0
  70. package/dist/__tests__/scheduler/listener-registry.test.js.map +1 -0
  71. package/dist/__tests__/scheduler/step.test.d.ts +2 -0
  72. package/dist/__tests__/scheduler/step.test.d.ts.map +1 -0
  73. package/dist/__tests__/scheduler/step.test.js +209 -0
  74. package/dist/__tests__/scheduler/step.test.js.map +1 -0
  75. package/dist/__tests__/scheduler/steps-context.test.d.ts +2 -0
  76. package/dist/__tests__/scheduler/steps-context.test.d.ts.map +1 -0
  77. package/dist/__tests__/scheduler/steps-context.test.js +43 -0
  78. package/dist/__tests__/scheduler/steps-context.test.js.map +1 -0
  79. package/dist/constants.d.ts +47 -0
  80. package/dist/constants.d.ts.map +1 -0
  81. package/dist/constants.js +53 -0
  82. package/dist/constants.js.map +1 -0
  83. package/dist/context.d.ts +37 -0
  84. package/dist/context.d.ts.map +1 -0
  85. package/dist/context.js +105 -0
  86. package/dist/context.js.map +1 -0
  87. package/dist/index.d.ts +9 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +10 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/parser/evaluator-builtins.d.ts +14 -0
  92. package/dist/parser/evaluator-builtins.d.ts.map +1 -0
  93. package/dist/parser/evaluator-builtins.js +258 -0
  94. package/dist/parser/evaluator-builtins.js.map +1 -0
  95. package/dist/parser/evaluator.d.ts +38 -0
  96. package/dist/parser/evaluator.d.ts.map +1 -0
  97. package/dist/parser/evaluator.js +257 -0
  98. package/dist/parser/evaluator.js.map +1 -0
  99. package/dist/parser/expression.d.ts +20 -0
  100. package/dist/parser/expression.d.ts.map +1 -0
  101. package/dist/parser/expression.js +128 -0
  102. package/dist/parser/expression.js.map +1 -0
  103. package/dist/parser/tokenizer.d.ts +26 -0
  104. package/dist/parser/tokenizer.d.ts.map +1 -0
  105. package/dist/parser/tokenizer.js +162 -0
  106. package/dist/parser/tokenizer.js.map +1 -0
  107. package/dist/parser/validator.d.ts +13 -0
  108. package/dist/parser/validator.d.ts.map +1 -0
  109. package/dist/parser/validator.js +383 -0
  110. package/dist/parser/validator.js.map +1 -0
  111. package/dist/parser/workflow.d.ts +30 -0
  112. package/dist/parser/workflow.d.ts.map +1 -0
  113. package/dist/parser/workflow.js +152 -0
  114. package/dist/parser/workflow.js.map +1 -0
  115. package/dist/scheduler/dependency.d.ts +37 -0
  116. package/dist/scheduler/dependency.d.ts.map +1 -0
  117. package/dist/scheduler/dependency.js +133 -0
  118. package/dist/scheduler/dependency.js.map +1 -0
  119. package/dist/scheduler/job-policy.d.ts +23 -0
  120. package/dist/scheduler/job-policy.d.ts.map +1 -0
  121. package/dist/scheduler/job-policy.js +117 -0
  122. package/dist/scheduler/job-policy.js.map +1 -0
  123. package/dist/scheduler/job.d.ts +151 -0
  124. package/dist/scheduler/job.d.ts.map +1 -0
  125. package/dist/scheduler/job.js +348 -0
  126. package/dist/scheduler/job.js.map +1 -0
  127. package/dist/scheduler/step-output-parser.d.ts +14 -0
  128. package/dist/scheduler/step-output-parser.d.ts.map +1 -0
  129. package/dist/scheduler/step-output-parser.js +70 -0
  130. package/dist/scheduler/step-output-parser.js.map +1 -0
  131. package/dist/scheduler/step.d.ts +74 -0
  132. package/dist/scheduler/step.d.ts.map +1 -0
  133. package/dist/scheduler/step.js +387 -0
  134. package/dist/scheduler/step.js.map +1 -0
  135. package/dist/types.d.ts +499 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +5 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/workflow-models.d.ts +504 -0
  140. package/dist/workflow-models.d.ts.map +1 -0
  141. package/dist/workflow-models.js +5 -0
  142. package/dist/workflow-models.js.map +1 -0
  143. package/package.json +29 -0
  144. package/src/__tests__/context/env.test.ts +38 -0
  145. package/src/__tests__/index.test.ts +55 -0
  146. package/src/__tests__/parser/expression.test.ts +151 -0
  147. package/src/__tests__/parser/workflow.test.ts +151 -0
  148. package/src/__tests__/scheduler/dependency.test.ts +51 -0
  149. package/src/__tests__/scheduler/job-context.test.ts +119 -0
  150. package/src/__tests__/scheduler/job-policy.test.ts +195 -0
  151. package/src/__tests__/scheduler/job.test.ts +1014 -0
  152. package/src/__tests__/scheduler/listener-registry.test.ts +95 -0
  153. package/src/__tests__/scheduler/step.test.ts +258 -0
  154. package/src/__tests__/scheduler/steps-context.test.ts +49 -0
  155. package/src/constants.ts +61 -0
  156. package/src/context.ts +153 -0
  157. package/src/index.ts +64 -0
  158. package/src/parser/evaluator-builtins.ts +315 -0
  159. package/src/parser/evaluator.ts +333 -0
  160. package/src/parser/expression.ts +154 -0
  161. package/src/parser/tokenizer.ts +191 -0
  162. package/src/parser/validator.ts +444 -0
  163. package/src/parser/workflow.ts +176 -0
  164. package/src/scheduler/dependency.ts +180 -0
  165. package/src/scheduler/job-policy.ts +198 -0
  166. package/src/scheduler/job.ts +523 -0
  167. package/src/scheduler/step-output-parser.ts +94 -0
  168. package/src/scheduler/step.ts +543 -0
  169. package/src/workflow-models.ts +593 -0
  170. package/tsconfig.json +14 -0
  171. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Step execution management
3
+ */
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { delimiter as pathDelimiter, join } from 'node:path';
8
+
9
+ import {
10
+ DEFAULT_TIMEOUT_MINUTES,
11
+ MAX_COMMAND_FILE_BYTES,
12
+ MINUTES_TO_MS,
13
+ } from '../constants.js';
14
+ import type {
15
+ Step,
16
+ StepResult,
17
+ ExecutionContext,
18
+ ActionResolver,
19
+ } from '../workflow-models.js';
20
+ import { parseGitHubEnvFile } from '../context.js';
21
+ import {
22
+ evaluateCondition,
23
+ interpolateString,
24
+ interpolateObject,
25
+ } from '../parser/expression.js';
26
+ import {
27
+ parseOutputs,
28
+ iterateNormalizedLines,
29
+ parsePathFile,
30
+ } from './step-output-parser.js';
31
+
32
+ /**
33
+ * Step runner options
34
+ */
35
+ export interface StepRunnerOptions {
36
+ /** Custom action resolver */
37
+ actionResolver?: ActionResolver;
38
+ /** Custom shell command executor */
39
+ shellExecutor?: ShellExecutor;
40
+ /** Default timeout in minutes */
41
+ defaultTimeout?: number;
42
+ /** Working directory */
43
+ workingDirectory?: string;
44
+ /** Default shell */
45
+ defaultShell?: Step['shell'];
46
+ }
47
+
48
+ /**
49
+ * Metadata for step execution
50
+ */
51
+ export interface StepRunMetadata {
52
+ /** Zero-based step index within its job */
53
+ index?: number;
54
+ }
55
+
56
+ /**
57
+ * Shell executor function type
58
+ */
59
+ export type ShellExecutor = (
60
+ command: string,
61
+ options: ShellExecutorOptions
62
+ ) => Promise<ShellExecutorResult>;
63
+
64
+ /**
65
+ * Shell executor options
66
+ */
67
+ export interface ShellExecutorOptions {
68
+ shell?: Step['shell'];
69
+ workingDirectory?: string;
70
+ env?: Record<string, string>;
71
+ timeout?: number;
72
+ }
73
+
74
+ /**
75
+ * Shell executor result
76
+ */
77
+ export interface ShellExecutorResult {
78
+ exitCode: number;
79
+ stdout: string;
80
+ stderr: string;
81
+ }
82
+
83
+ interface StepCommandFiles {
84
+ directory: string;
85
+ env: string;
86
+ output: string;
87
+ path: string;
88
+ }
89
+
90
+ const BUILTIN_NOOP_ACTIONS = new Set(['actions/checkout', 'actions/setup-node']);
91
+
92
+ function resolvePlatformDefaultShell(): Step['shell'] {
93
+ return process.platform === 'win32' ? 'pwsh' : 'bash';
94
+ }
95
+
96
+ /**
97
+ * Resolve shell name to executable
98
+ */
99
+ function resolveShellExecutable(shell: Step['shell'] | undefined): string | true {
100
+ if (!shell) {
101
+ return true;
102
+ }
103
+
104
+ switch (shell) {
105
+ case 'cmd':
106
+ return process.platform === 'win32' ? 'cmd.exe' : 'cmd';
107
+ case 'powershell':
108
+ return process.platform === 'win32' ? 'powershell.exe' : 'powershell';
109
+ default:
110
+ return shell;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Default shell executor
116
+ */
117
+ const defaultShellExecutor: ShellExecutor = async (
118
+ command: string,
119
+ options: ShellExecutorOptions
120
+ ): Promise<ShellExecutorResult> => {
121
+ return new Promise<ShellExecutorResult>((resolve, reject) => {
122
+ const shellExecutable = resolveShellExecutable(options.shell);
123
+
124
+ // Always invoke the shell as a separate binary with the command passed as
125
+ // an argument so that shell: false can be used and user-supplied content in
126
+ // `command` is never interpreted as a shell command name.
127
+ let spawnFile: string;
128
+ let spawnArgs: string[];
129
+
130
+ if (shellExecutable === true) {
131
+ // No explicit shell requested – fall back to the platform default shell
132
+ // but still spawn it explicitly with shell: false.
133
+ if (process.platform === 'win32') {
134
+ spawnFile = 'cmd.exe';
135
+ spawnArgs = ['/d', '/s', '/c', command];
136
+ } else {
137
+ spawnFile = '/bin/sh';
138
+ spawnArgs = ['-c', command];
139
+ }
140
+ } else {
141
+ // An explicit shell binary was resolved (bash, powershell, cmd.exe, …).
142
+ if (shellExecutable === 'cmd.exe' || shellExecutable === 'cmd') {
143
+ spawnArgs = ['/d', '/s', '/c', command];
144
+ } else if (
145
+ shellExecutable === 'powershell.exe' ||
146
+ shellExecutable === 'powershell' ||
147
+ shellExecutable === 'pwsh'
148
+ ) {
149
+ spawnArgs = ['-NonInteractive', '-Command', command];
150
+ } else {
151
+ // Generic POSIX-compatible shell (bash, sh, zsh, …)
152
+ spawnArgs = ['-c', command];
153
+ }
154
+ spawnFile = shellExecutable;
155
+ }
156
+
157
+ // Only pass safe host environment variables to prevent leaking secrets.
158
+ // Workflow-level env vars are provided via options.env.
159
+ const safeHostEnv: Record<string, string> = {};
160
+ const ALLOWED_HOST_VARS = [
161
+ 'PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'LC_CTYPE',
162
+ 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'HOSTNAME',
163
+ 'NODE_ENV', 'CI',
164
+ ];
165
+ for (const key of ALLOWED_HOST_VARS) {
166
+ if (process.env[key]) {
167
+ safeHostEnv[key] = process.env[key]!;
168
+ }
169
+ }
170
+
171
+ const child = spawn(spawnFile, spawnArgs, {
172
+ cwd: options.workingDirectory,
173
+ env: {
174
+ ...safeHostEnv,
175
+ ...(options.env ?? {}),
176
+ },
177
+ shell: false,
178
+ windowsHide: true,
179
+ });
180
+
181
+ let stdout = '';
182
+ let stderr = '';
183
+ let timedOut = false;
184
+
185
+ const timeout =
186
+ typeof options.timeout === 'number' && options.timeout > 0
187
+ ? setTimeout(() => {
188
+ timedOut = true;
189
+ child.kill();
190
+ }, options.timeout)
191
+ : undefined;
192
+
193
+ timeout?.unref?.();
194
+
195
+ child.stdout?.on('data', (chunk: string | Buffer) => {
196
+ stdout += chunk.toString();
197
+ });
198
+ child.stderr?.on('data', (chunk: string | Buffer) => {
199
+ stderr += chunk.toString();
200
+ });
201
+
202
+ child.on('error', (error) => {
203
+ if (timeout) {
204
+ clearTimeout(timeout);
205
+ }
206
+ reject(error);
207
+ });
208
+
209
+ child.on('close', (code, signal) => {
210
+ if (timeout) {
211
+ clearTimeout(timeout);
212
+ }
213
+
214
+ const exitCode =
215
+ typeof code === 'number' ? code : timedOut ? 124 : signal ? 128 : 1;
216
+
217
+ if (timedOut) {
218
+ const timeoutMessage = `Command timed out after ${options.timeout}ms`;
219
+ stderr = appendStderrMessage(stderr, timeoutMessage);
220
+ } else if (signal) {
221
+ const signalMessage = `Process terminated by signal: ${signal}`;
222
+ stderr = appendStderrMessage(stderr, signalMessage);
223
+ }
224
+
225
+ resolve({
226
+ exitCode,
227
+ stdout,
228
+ stderr,
229
+ });
230
+ });
231
+ });
232
+ };
233
+
234
+ function appendStderrMessage(stderr: string, message: string): string {
235
+ return stderr.length > 0 ? `${stderr}\n${message}` : message;
236
+ }
237
+
238
+ /**
239
+ * Default action resolver
240
+ */
241
+ const defaultActionResolver: ActionResolver = async (uses: string) => {
242
+ const normalizedUses = uses.trim().toLowerCase();
243
+ const actionName = normalizedUses.split('@')[0];
244
+
245
+ if (BUILTIN_NOOP_ACTIONS.has(actionName)) {
246
+ return {
247
+ run: async (step, context): Promise<StepResult> => {
248
+ const outputs: Record<string, string> = {};
249
+
250
+ // Keep checkout compatibility for workflows that read steps.<id>.outputs.path.
251
+ if (actionName === 'actions/checkout') {
252
+ const configuredPath =
253
+ typeof step.with?.path === 'string' && step.with.path.length > 0
254
+ ? step.with.path
255
+ : context.github.workspace;
256
+ outputs.path = configuredPath;
257
+ }
258
+
259
+ return {
260
+ id: step.id,
261
+ name: step.name,
262
+ status: 'completed',
263
+ conclusion: 'success',
264
+ outputs,
265
+ };
266
+ },
267
+ };
268
+ }
269
+
270
+ return {
271
+ run: async (): Promise<StepResult> => {
272
+ throw new Error(
273
+ `Unsupported action: ${uses}. Provide StepRunnerOptions.actionResolver for action steps.`
274
+ );
275
+ },
276
+ };
277
+ };
278
+
279
+ /**
280
+ * Step runner for executing individual steps
281
+ */
282
+ export class StepRunner {
283
+ private options: StepRunnerOptions;
284
+ private actionResolver: ActionResolver;
285
+ private shellExecutor: ShellExecutor;
286
+
287
+ constructor(options: StepRunnerOptions = {}) {
288
+ this.options = {
289
+ defaultTimeout: options.defaultTimeout ?? DEFAULT_TIMEOUT_MINUTES,
290
+ workingDirectory: options.workingDirectory ?? process.cwd(),
291
+ defaultShell: options.defaultShell ?? resolvePlatformDefaultShell(),
292
+ ...options,
293
+ };
294
+ this.actionResolver = options.actionResolver ?? defaultActionResolver;
295
+ this.shellExecutor = options.shellExecutor ?? defaultShellExecutor;
296
+ }
297
+
298
+ /**
299
+ * Run a single step
300
+ */
301
+ async runStep(
302
+ step: Step,
303
+ context: ExecutionContext,
304
+ _metadata: StepRunMetadata = {}
305
+ ): Promise<StepResult> {
306
+ const startedAt = new Date();
307
+ const result: StepResult = {
308
+ id: step.id,
309
+ name: step.name,
310
+ status: 'queued',
311
+ outputs: {},
312
+ startedAt,
313
+ };
314
+
315
+ try {
316
+ // Check condition
317
+ if (step.if !== undefined) {
318
+ const shouldRun = evaluateCondition(step.if, context);
319
+ if (!shouldRun) {
320
+ result.status = 'completed';
321
+ result.conclusion = 'skipped';
322
+ result.completedAt = new Date();
323
+ return result;
324
+ }
325
+ }
326
+
327
+ result.status = 'in_progress';
328
+
329
+ // Merge environment variables
330
+ const env = {
331
+ ...context.env,
332
+ ...(step.env || {}),
333
+ };
334
+
335
+ // Interpolate environment variables
336
+ const interpolatedEnv = interpolateObject(env, context);
337
+
338
+ // Create step context with interpolated env
339
+ const stepContext: ExecutionContext = {
340
+ ...context,
341
+ env: interpolatedEnv,
342
+ };
343
+
344
+ // Execute based on step type
345
+ if (step.uses) {
346
+ await this.runAction(step, stepContext, result);
347
+ } else if (step.run) {
348
+ await this.runShell(step, stepContext, context.env, result);
349
+ } else {
350
+ throw new Error('Step must have either "uses" or "run"');
351
+ }
352
+
353
+ result.status = 'completed';
354
+ result.conclusion = result.conclusion ?? 'success';
355
+ } catch (error) {
356
+ result.status = 'completed';
357
+ result.conclusion = step['continue-on-error'] ? 'success' : 'failure';
358
+ result.error = error instanceof Error ? error.message : String(error);
359
+ }
360
+
361
+ result.completedAt = new Date();
362
+ return result;
363
+ }
364
+
365
+ /**
366
+ * Run an action step
367
+ */
368
+ private async runAction(
369
+ step: Step,
370
+ context: ExecutionContext,
371
+ result: StepResult
372
+ ): Promise<void> {
373
+ const uses = interpolateString(step.uses!, context);
374
+ const action = await this.actionResolver(uses);
375
+
376
+ if (!action) {
377
+ throw new Error(`Action not found: ${uses}`);
378
+ }
379
+
380
+ // Interpolate with parameters
381
+ const interpolatedWith = step.with
382
+ ? interpolateObject(step.with, context)
383
+ : {};
384
+
385
+ const stepWithInterpolated: Step = {
386
+ ...step,
387
+ uses,
388
+ with: interpolatedWith,
389
+ };
390
+
391
+ const actionResult = await action.run(stepWithInterpolated, context);
392
+
393
+ // Merge outputs
394
+ Object.assign(result.outputs, actionResult.outputs);
395
+ result.conclusion = actionResult.conclusion;
396
+ }
397
+
398
+ /**
399
+ * Run a shell command step
400
+ */
401
+ private async runShell(
402
+ step: Step,
403
+ context: ExecutionContext,
404
+ sharedEnv: Record<string, string>,
405
+ result: StepResult
406
+ ): Promise<void> {
407
+ // Interpolate command
408
+ const command = interpolateString(step.run!, context);
409
+
410
+ // Determine shell
411
+ const shell = step.shell ?? this.options.defaultShell;
412
+
413
+ // Determine working directory
414
+ const workingDirectory =
415
+ step['working-directory'] ?? this.options.workingDirectory;
416
+ const interpolatedWorkDir = interpolateString(workingDirectory!, context);
417
+
418
+ // Calculate timeout
419
+ const timeout = (step['timeout-minutes'] ?? this.options.defaultTimeout!) * MINUTES_TO_MS;
420
+
421
+ const commandFiles = await this.createCommandFiles(context);
422
+ const runnerTemp = this.resolveRunnerTemp(context);
423
+ const shellEnv = {
424
+ ...context.env,
425
+ RUNNER_TEMP: runnerTemp,
426
+ GITHUB_ENV: commandFiles.env,
427
+ GITHUB_OUTPUT: commandFiles.output,
428
+ GITHUB_PATH: commandFiles.path,
429
+ };
430
+
431
+ try {
432
+ // Execute command
433
+ const shellResult = await this.shellExecutor(command, {
434
+ shell,
435
+ workingDirectory: interpolatedWorkDir,
436
+ env: shellEnv,
437
+ timeout,
438
+ });
439
+
440
+ // Parse outputs from stdout (GitHub Actions format)
441
+ const stdoutOutputs = parseOutputs(shellResult.stdout);
442
+ Object.assign(result.outputs, stdoutOutputs);
443
+
444
+ // Merge command-file outputs (echo "name=value" >> $GITHUB_OUTPUT)
445
+ const commandFileOutputs = await this.parseCommandFileOutputs(commandFiles.output);
446
+ Object.assign(result.outputs, commandFileOutputs);
447
+
448
+ // Persist GITHUB_ENV and GITHUB_PATH updates for later steps.
449
+ await this.applyCommandFileEnvironmentUpdates(sharedEnv, commandFiles, shellEnv);
450
+
451
+ // Set conclusion based on exit code
452
+ result.conclusion = shellResult.exitCode === 0 ? 'success' : 'failure';
453
+
454
+ if (shellResult.exitCode !== 0) {
455
+ result.error = `Exit code: ${shellResult.exitCode}`;
456
+ if (shellResult.stderr) {
457
+ result.error += `\n${shellResult.stderr}`;
458
+ }
459
+ }
460
+ } finally {
461
+ await this.removeCommandFilesDirectory(commandFiles.directory);
462
+ }
463
+ }
464
+
465
+ private resolveRunnerTemp(context: ExecutionContext): string {
466
+ return context.env.RUNNER_TEMP || context.runner.temp || tmpdir();
467
+ }
468
+
469
+ private async createCommandFiles(context: ExecutionContext): Promise<StepCommandFiles> {
470
+ const runnerTemp = this.resolveRunnerTemp(context);
471
+ let directory: string;
472
+
473
+ try {
474
+ directory = await mkdtemp(join(runnerTemp, 'actions-engine-step-'));
475
+ } catch {
476
+ directory = await mkdtemp(join(tmpdir(), 'actions-engine-step-'));
477
+ }
478
+
479
+ return {
480
+ directory,
481
+ env: join(directory, 'github-env'),
482
+ output: join(directory, 'github-output'),
483
+ path: join(directory, 'github-path'),
484
+ };
485
+ }
486
+
487
+ private async parseCommandFileOutputs(outputPath: string): Promise<Record<string, string>> {
488
+ const outputContent = await this.readCommandFile(outputPath);
489
+ if (outputContent.length === 0) {
490
+ return {};
491
+ }
492
+ return parseGitHubEnvFile(outputContent);
493
+ }
494
+
495
+ private async applyCommandFileEnvironmentUpdates(
496
+ sharedEnv: Record<string, string>,
497
+ commandFiles: StepCommandFiles,
498
+ shellEnv: Record<string, string>
499
+ ): Promise<void> {
500
+ const envContent = await this.readCommandFile(commandFiles.env);
501
+ if (envContent.length > 0) {
502
+ const updates = parseGitHubEnvFile(envContent);
503
+ Object.assign(sharedEnv, updates);
504
+ }
505
+
506
+ const pathContent = await this.readCommandFile(commandFiles.path);
507
+ const appendedPaths = parsePathFile(pathContent);
508
+ if (appendedPaths.length > 0) {
509
+ const basePath = sharedEnv.PATH ?? shellEnv.PATH ?? process.env.PATH ?? '';
510
+ const prefix = appendedPaths.join(pathDelimiter);
511
+ sharedEnv.PATH = basePath.length > 0 ? `${prefix}${pathDelimiter}${basePath}` : prefix;
512
+ }
513
+ }
514
+
515
+ /** @see {@link MAX_COMMAND_FILE_BYTES} in constants.ts */
516
+ private static readonly MAX_COMMAND_FILE_BYTES = MAX_COMMAND_FILE_BYTES;
517
+
518
+ private async readCommandFile(path: string): Promise<string> {
519
+ try {
520
+ const { stat } = await import('node:fs/promises');
521
+ const stats = await stat(path);
522
+ if (stats.size > StepRunner.MAX_COMMAND_FILE_BYTES) {
523
+ throw new Error(
524
+ `Command file ${path} exceeds maximum size of ${StepRunner.MAX_COMMAND_FILE_BYTES} bytes (actual: ${stats.size})`
525
+ );
526
+ }
527
+ return await readFile(path, 'utf8');
528
+ } catch (error) {
529
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
530
+ return '';
531
+ }
532
+ throw error;
533
+ }
534
+ }
535
+
536
+ private async removeCommandFilesDirectory(path: string): Promise<void> {
537
+ try {
538
+ await rm(path, { recursive: true, force: true });
539
+ } catch {
540
+ // Command-file cleanup should never fail step execution.
541
+ }
542
+ }
543
+ }