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.
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +3477 -0
- package/coverage/coverage-final.json +20 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +176 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/context/base.ts.html +1792 -0
- package/coverage/src/context/env.ts.html +1243 -0
- package/coverage/src/context/index.html +161 -0
- package/coverage/src/context/index.ts.html +229 -0
- package/coverage/src/context/secrets.ts.html +1276 -0
- package/coverage/src/index.html +131 -0
- package/coverage/src/index.ts.html +502 -0
- package/coverage/src/parser/expression.ts.html +2854 -0
- package/coverage/src/parser/index.html +161 -0
- package/coverage/src/parser/index.ts.html +163 -0
- package/coverage/src/parser/validator.ts.html +1588 -0
- package/coverage/src/parser/workflow.ts.html +616 -0
- package/coverage/src/scheduler/dependency.ts.html +1138 -0
- package/coverage/src/scheduler/index.html +221 -0
- package/coverage/src/scheduler/index.ts.html +214 -0
- package/coverage/src/scheduler/job-context.ts.html +265 -0
- package/coverage/src/scheduler/job-policy.ts.html +559 -0
- package/coverage/src/scheduler/job.ts.html +1816 -0
- package/coverage/src/scheduler/listener-registry.ts.html +199 -0
- package/coverage/src/scheduler/step.ts.html +2206 -0
- package/coverage/src/scheduler/steps-context.ts.html +217 -0
- package/coverage/src/types.ts.html +1897 -0
- package/coverage/src/utils/index.html +116 -0
- package/coverage/src/utils/needs.ts.html +127 -0
- package/dist/__tests__/context/env.test.d.ts +2 -0
- package/dist/__tests__/context/env.test.d.ts.map +1 -0
- package/dist/__tests__/context/env.test.js +28 -0
- package/dist/__tests__/context/env.test.js.map +1 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +50 -0
- package/dist/__tests__/index.test.js.map +1 -0
- package/dist/__tests__/parser/expression.test.d.ts +2 -0
- package/dist/__tests__/parser/expression.test.d.ts.map +1 -0
- package/dist/__tests__/parser/expression.test.js +116 -0
- package/dist/__tests__/parser/expression.test.js.map +1 -0
- package/dist/__tests__/parser/workflow.test.d.ts +2 -0
- package/dist/__tests__/parser/workflow.test.d.ts.map +1 -0
- package/dist/__tests__/parser/workflow.test.js +134 -0
- package/dist/__tests__/parser/workflow.test.js.map +1 -0
- package/dist/__tests__/scheduler/dependency.test.d.ts +2 -0
- package/dist/__tests__/scheduler/dependency.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/dependency.test.js +41 -0
- package/dist/__tests__/scheduler/dependency.test.js.map +1 -0
- package/dist/__tests__/scheduler/job-context.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job-context.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job-context.test.js +108 -0
- package/dist/__tests__/scheduler/job-context.test.js.map +1 -0
- package/dist/__tests__/scheduler/job-policy.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job-policy.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job-policy.test.js +159 -0
- package/dist/__tests__/scheduler/job-policy.test.js.map +1 -0
- package/dist/__tests__/scheduler/job.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job.test.js +826 -0
- package/dist/__tests__/scheduler/job.test.js.map +1 -0
- package/dist/__tests__/scheduler/listener-registry.test.d.ts +2 -0
- package/dist/__tests__/scheduler/listener-registry.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/listener-registry.test.js +79 -0
- package/dist/__tests__/scheduler/listener-registry.test.js.map +1 -0
- package/dist/__tests__/scheduler/step.test.d.ts +2 -0
- package/dist/__tests__/scheduler/step.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/step.test.js +209 -0
- package/dist/__tests__/scheduler/step.test.js.map +1 -0
- package/dist/__tests__/scheduler/steps-context.test.d.ts +2 -0
- package/dist/__tests__/scheduler/steps-context.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/steps-context.test.js +43 -0
- package/dist/__tests__/scheduler/steps-context.test.js.map +1 -0
- package/dist/constants.d.ts +47 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +53 -0
- package/dist/constants.js.map +1 -0
- package/dist/context.d.ts +37 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +105 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/evaluator-builtins.d.ts +14 -0
- package/dist/parser/evaluator-builtins.d.ts.map +1 -0
- package/dist/parser/evaluator-builtins.js +258 -0
- package/dist/parser/evaluator-builtins.js.map +1 -0
- package/dist/parser/evaluator.d.ts +38 -0
- package/dist/parser/evaluator.d.ts.map +1 -0
- package/dist/parser/evaluator.js +257 -0
- package/dist/parser/evaluator.js.map +1 -0
- package/dist/parser/expression.d.ts +20 -0
- package/dist/parser/expression.d.ts.map +1 -0
- package/dist/parser/expression.js +128 -0
- package/dist/parser/expression.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +26 -0
- package/dist/parser/tokenizer.d.ts.map +1 -0
- package/dist/parser/tokenizer.js +162 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/parser/validator.d.ts +13 -0
- package/dist/parser/validator.d.ts.map +1 -0
- package/dist/parser/validator.js +383 -0
- package/dist/parser/validator.js.map +1 -0
- package/dist/parser/workflow.d.ts +30 -0
- package/dist/parser/workflow.d.ts.map +1 -0
- package/dist/parser/workflow.js +152 -0
- package/dist/parser/workflow.js.map +1 -0
- package/dist/scheduler/dependency.d.ts +37 -0
- package/dist/scheduler/dependency.d.ts.map +1 -0
- package/dist/scheduler/dependency.js +133 -0
- package/dist/scheduler/dependency.js.map +1 -0
- package/dist/scheduler/job-policy.d.ts +23 -0
- package/dist/scheduler/job-policy.d.ts.map +1 -0
- package/dist/scheduler/job-policy.js +117 -0
- package/dist/scheduler/job-policy.js.map +1 -0
- package/dist/scheduler/job.d.ts +151 -0
- package/dist/scheduler/job.d.ts.map +1 -0
- package/dist/scheduler/job.js +348 -0
- package/dist/scheduler/job.js.map +1 -0
- package/dist/scheduler/step-output-parser.d.ts +14 -0
- package/dist/scheduler/step-output-parser.d.ts.map +1 -0
- package/dist/scheduler/step-output-parser.js +70 -0
- package/dist/scheduler/step-output-parser.js.map +1 -0
- package/dist/scheduler/step.d.ts +74 -0
- package/dist/scheduler/step.d.ts.map +1 -0
- package/dist/scheduler/step.js +387 -0
- package/dist/scheduler/step.js.map +1 -0
- package/dist/types.d.ts +499 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workflow-models.d.ts +504 -0
- package/dist/workflow-models.d.ts.map +1 -0
- package/dist/workflow-models.js +5 -0
- package/dist/workflow-models.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/context/env.test.ts +38 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/parser/expression.test.ts +151 -0
- package/src/__tests__/parser/workflow.test.ts +151 -0
- package/src/__tests__/scheduler/dependency.test.ts +51 -0
- package/src/__tests__/scheduler/job-context.test.ts +119 -0
- package/src/__tests__/scheduler/job-policy.test.ts +195 -0
- package/src/__tests__/scheduler/job.test.ts +1014 -0
- package/src/__tests__/scheduler/listener-registry.test.ts +95 -0
- package/src/__tests__/scheduler/step.test.ts +258 -0
- package/src/__tests__/scheduler/steps-context.test.ts +49 -0
- package/src/constants.ts +61 -0
- package/src/context.ts +153 -0
- package/src/index.ts +64 -0
- package/src/parser/evaluator-builtins.ts +315 -0
- package/src/parser/evaluator.ts +333 -0
- package/src/parser/expression.ts +154 -0
- package/src/parser/tokenizer.ts +191 -0
- package/src/parser/validator.ts +444 -0
- package/src/parser/workflow.ts +176 -0
- package/src/scheduler/dependency.ts +180 -0
- package/src/scheduler/job-policy.ts +198 -0
- package/src/scheduler/job.ts +523 -0
- package/src/scheduler/step-output-parser.ts +94 -0
- package/src/scheduler/step.ts +543 -0
- package/src/workflow-models.ts +593 -0
- package/tsconfig.json +14 -0
- 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
|
+
}
|