@secureci/cli 0.5.1 → 0.7.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.
@@ -50,6 +50,7 @@ const job_runtime_1 = require("./job-runtime");
50
50
  const job_container_1 = require("./job-container");
51
51
  const steps_1 = require("./steps");
52
52
  const actions_1 = require("./actions");
53
+ const scheduler_1 = require("./scheduler");
53
54
  // Public API, kept here so `executor` stays the entry point.
54
55
  var github_context_2 = require("./github-context");
55
56
  Object.defineProperty(exports, "parseRepositoryFromRemote", { enumerable: true, get: function () { return github_context_2.parseRepositoryFromRemote; } });
@@ -59,6 +60,8 @@ Object.defineProperty(exports, "topologicalJobOrder", { enumerable: true, get: f
59
60
  var job_container_2 = require("./job-container");
60
61
  Object.defineProperty(exports, "DEFAULT_CONTAINER_IMAGE", { enumerable: true, get: function () { return job_container_2.DEFAULT_CONTAINER_IMAGE; } });
61
62
  Object.defineProperty(exports, "DEFAULT_RUNNER_IMAGES", { enumerable: true, get: function () { return job_container_2.DEFAULT_RUNNER_IMAGES; } });
63
+ /** The error of a step killed because its job was cancelled (GitHub's). */
64
+ const CANCELLED_MESSAGE = 'The operation was canceled.';
62
65
  /**
63
66
  * Execute a reusable workflow called via `jobs.<id>.uses:`: a local path
64
67
  * (`./.github/workflows/x.yml`, relative to the repository root) or a
@@ -67,7 +70,7 @@ Object.defineProperty(exports, "DEFAULT_RUNNER_IMAGES", { enumerable: true, get:
67
70
  * (all of them with `secrets: inherit`), and its `on.workflow_call.outputs`
68
71
  * become the calling job's outputs.
69
72
  */
70
- async function executeReusableWorkflowJob(jobId, job, callerPath, options, ctx) {
73
+ async function executeReusableWorkflowJob(jobId, job, callerPath, options, ctx, internal) {
71
74
  const startTime = Date.now();
72
75
  const uses = job.uses;
73
76
  const failed = (error) => {
@@ -111,8 +114,10 @@ async function executeReusableWorkflowJob(jobId, job, callerPath, options, ctx)
111
114
  ...options,
112
115
  inputs,
113
116
  secrets,
114
- job: undefined
115
- });
117
+ // --job and --matrix select the caller's jobs, not the called ones.
118
+ job: undefined,
119
+ matrix: undefined
120
+ }, internal);
116
121
  return {
117
122
  job: jobId,
118
123
  success: subResult.success,
@@ -122,9 +127,11 @@ async function executeReusableWorkflowJob(jobId, job, callerPath, options, ctx)
122
127
  };
123
128
  }
124
129
  /**
125
- * Execute a workflow or action file
130
+ * Execute a workflow or action file. Jobs whose `needs:` are done, and
131
+ * matrix combinations, run concurrently up to `options.parallel` (default
132
+ * 1: one after another).
126
133
  */
127
- async function executeWorkflow(filePath, options = {}) {
134
+ async function executeWorkflow(filePath, options = {}, internal = {}) {
128
135
  const startTime = Date.now();
129
136
  logger_1.logger.registerSecrets(options.secrets);
130
137
  // One run id for every job and called workflow of this run.
@@ -164,32 +171,55 @@ async function executeWorkflow(filePath, options = {}) {
164
171
  const event = (0, github_context_1.loadEvent)(options, options.inputs ?? {}, parsed.file.on);
165
172
  const inputs = (0, github_context_1.typedInputs)(parsed.file.on, options.inputs ?? {});
166
173
  fs.writeFileSync(path.join(runDir, 'event.json'), JSON.stringify(event.payload));
167
- const jobs = [];
168
- const jobResults = new Map();
169
- const jobOrder = (0, job_plan_1.topologicalJobOrder)(parsed.file.jobs);
170
- for (const jobId of jobOrder) {
171
- const job = parsed.file.jobs[jobId];
172
- // Skip if specific job requested and this isn't it
173
- if (options.job && jobId !== options.job) {
174
- continue;
174
+ const vars = options.vars ?? {};
175
+ const parallel = Math.max(1, Math.floor(options.parallel ?? 1));
176
+ const concurrent = parallel > 1;
177
+ const slots = internal.slots ?? new scheduler_1.Slots(parallel);
178
+ const matrixFilter = options.matrix ?? {};
179
+ const definedMatrixKeys = new Set();
180
+ let matrixUnmatched = false;
181
+ const order = (0, job_plan_1.topologicalJobOrder)(parsed.file.jobs).filter(
182
+ // With --job, only that job runs; its dependencies count as done.
183
+ (jobId) => !options.job || jobId === options.job);
184
+ // Label log lines with their job only when jobs can interleave.
185
+ const prefixJobs = concurrent &&
186
+ (order.length > 1 ||
187
+ order.some((jobId) => parsed.file.jobs[jobId].strategy?.matrix));
188
+ const tagged = (label, fn, replace = false) => (prefixJobs ? logger_1.logger.withJob(label, fn, replace) : fn());
189
+ const needsOf = (jobId) => {
190
+ const needs = parsed.file.jobs[jobId].needs;
191
+ return needs ? (Array.isArray(needs) ? needs : [needs]) : [];
192
+ };
193
+ const selectCombinations = (jobId, combinations) => {
194
+ for (const combo of combinations) {
195
+ Object.keys(combo).forEach((key) => definedMatrixKeys.add(key));
196
+ }
197
+ const { keys, matches } = (0, job_plan_1.filterMatrix)(combinations, matrixFilter);
198
+ if (keys.length > 0 && matches.length === 0) {
199
+ logger_1.logger.warn((0, job_plan_1.noMatrixMatchMessage)(jobId, matrixFilter, combinations));
200
+ matrixUnmatched = true;
175
201
  }
202
+ return matches;
203
+ };
204
+ // Each job's results, in matrix order; collected in `order` at the end
205
+ // so the report doesn't depend on which job finished first.
206
+ const resultsByJob = new Map();
207
+ const runJob = (jobId, depResults) => tagged(jobId, async () => {
208
+ const job = parsed.file.jobs[jobId];
209
+ const jobs = [];
210
+ resultsByJob.set(jobId, jobs);
176
211
  const github = (0, github_context_1.buildGithubContext)(parsed.file.name, jobId, process.cwd(), event, options.secrets?.GITHUB_TOKEN, options.runId);
177
212
  // `needs` results feed the job's `if:` (implicitly `success()`,
178
213
  // which requires every dependency to have succeeded). With --job,
179
214
  // dependencies aren't run and count as successful.
180
- const needsIds = job.needs
181
- ? Array.isArray(job.needs)
182
- ? job.needs
183
- : [job.needs]
184
- : [];
215
+ const needsIds = needsOf(jobId);
185
216
  const needs = {};
186
217
  for (const dep of needsIds) {
187
- needs[dep] = jobResults.get(dep) ?? {
218
+ needs[dep] = depResults[dep] ?? {
188
219
  result: 'success',
189
220
  outputs: {}
190
221
  };
191
222
  }
192
- const vars = options.vars ?? {};
193
223
  const jobIfCtx = {
194
224
  ...(0, expression_1.createContext)({}, inputs, options.secrets, github),
195
225
  needs,
@@ -199,276 +229,360 @@ async function executeWorkflow(filePath, options = {}) {
199
229
  anyFailed: Object.values(needs).some((n) => n.result === 'failure')
200
230
  }
201
231
  };
202
- let runJob;
232
+ let runThisJob;
203
233
  try {
204
- runJob = (0, expression_1.evaluateCondition)((0, expression_1.withImplicitSuccess)(job.if), jobIfCtx);
234
+ runThisJob = (0, expression_1.evaluateCondition)((0, expression_1.withImplicitSuccess)(job.if), jobIfCtx);
205
235
  }
206
236
  catch (error) {
207
237
  // An if: that can't be evaluated fails the job, as on GitHub.
208
238
  logger_1.logger.error((0, expression_1.ifEvaluationError)(`job '${jobId}'`, error));
209
239
  jobs.push({ job: jobId, success: false, steps: [], duration: 0 });
210
- jobResults.set(jobId, { result: 'failure', outputs: {} });
211
- continue;
240
+ return { result: 'failure', outputs: {} };
212
241
  }
213
- if (!runJob) {
242
+ if (!runThisJob) {
214
243
  const notOk = needsIds.find((dep) => needs[dep].result !== 'success');
215
244
  logger_1.logger.info(notOk
216
245
  ? `Skipping job: ${jobId} (dependency "${notOk}" ${needs[notOk].result === 'skipped' ? 'was skipped' : 'did not succeed'})`
217
246
  : `Skipping job: ${jobId} (condition not met)`);
218
- jobResults.set(jobId, { result: 'skipped', outputs: {} });
219
- continue;
220
- }
221
- // Handle reusable workflow calls (once per matrix combination)
222
- if (job.uses) {
223
- const combinations = (0, job_plan_1.expandMatrix)(job.strategy?.matrix, jobIfCtx);
224
- const callOutputs = {};
225
- let callFailed = false;
226
- for (const matrixValues of combinations) {
227
- if (callFailed && job.strategy?.['fail-fast'] !== false)
228
- break;
229
- const label = combinations.length > 1
230
- ? `${jobId} (${(0, job_plan_1.matrixLabel)(matrixValues)})`
231
- : jobId;
232
- const reusableResult = await executeReusableWorkflowJob(label, job, filePath, options, { ...jobIfCtx, matrix: matrixValues });
233
- jobs.push(reusableResult);
234
- Object.assign(callOutputs, reusableResult.outputs);
235
- if (!reusableResult.success)
236
- callFailed = true;
237
- }
238
- jobResults.set(jobId, {
239
- result: callFailed ? 'failure' : 'success',
240
- outputs: callOutputs
241
- });
242
- if (!callFailed) {
243
- logger_1.logger.success(`Job ${jobId} completed successfully`);
244
- }
245
- continue;
247
+ return { result: 'skipped', outputs: {} };
246
248
  }
247
249
  const matrixCombinations = (0, job_plan_1.expandMatrix)(job.strategy?.matrix, jobIfCtx);
250
+ const selected = selectCombinations(jobId, matrixCombinations);
251
+ if (selected.length === 0)
252
+ return { result: 'skipped', outputs: {} };
248
253
  const failFast = job.strategy?.['fail-fast'] !== false;
249
- let matrixFailed = false;
250
- const jobOutputs = {};
251
- for (const [jobIndex, matrixValues] of matrixCombinations.entries()) {
252
- if (matrixFailed && failFast) {
253
- logger_1.logger.info(`Skipping remaining matrix jobs for ${jobId} (fail-fast)`);
254
- break;
255
- }
256
- const matrixSuffix = matrixCombinations.length > 1
257
- ? ` (${(0, job_plan_1.matrixLabel)(matrixValues)})`
258
- : '';
259
- const jobLabel = `${jobId}${matrixSuffix}`;
260
- const runtime = (0, job_runtime_1.createJobRuntime)(github, runDir, {
261
- shell: job.defaults?.run?.shell ?? parsed.file.defaults?.run?.shell,
262
- workingDirectory: job.defaults?.run?.['working-directory'] ??
263
- parsed.file.defaults?.run?.['working-directory']
264
- });
265
- // Workflow-level env can't reference `env` itself; job-level env
266
- // sees the workflow env plus matrix (matching GitHub's contexts).
267
- const workflowEnv = {
268
- ...options.env,
269
- ...(0, expression_1.substituteRecord)(parsed.file.env, {
270
- ...(0, expression_1.createContext)({ ...options.env }, inputs, options.secrets, github),
254
+ const maxParallel = (0, job_plan_1.numberSetting)(job.strategy?.['max-parallel'], jobIfCtx);
255
+ const labelOf = (matrixValues) => matrixCombinations.length > 1
256
+ ? `${jobId} (${(0, job_plan_1.matrixLabel)(matrixValues)})`
257
+ : jobId;
258
+ // Each combination's result and outputs, by combination index.
259
+ const unitResults = [];
260
+ const unitOutputs = [];
261
+ let outcomes;
262
+ if (job.uses) {
263
+ // A reusable workflow call (once per matrix combination). The
264
+ // call itself holds no slot: the called workflow's jobs do.
265
+ outcomes = await (0, scheduler_1.runMatrix)(selected.length, (index, signal) => {
266
+ const { index: combinationIndex, values: matrixValues } = selected[index];
267
+ const label = labelOf(matrixValues);
268
+ return tagged(label, async () => {
269
+ const reusableResult = await executeReusableWorkflowJob(label, job, filePath, options, { ...jobIfCtx, matrix: matrixValues }, { slots, signal });
270
+ const cancelled = !reusableResult.success && signal.aborted;
271
+ unitResults[combinationIndex] = cancelled
272
+ ? { ...reusableResult, cancelled: true }
273
+ : reusableResult;
274
+ unitOutputs[combinationIndex] =
275
+ reusableResult.outputs ?? {};
276
+ return {
277
+ result: reusableResult.success
278
+ ? 'success'
279
+ : cancelled
280
+ ? 'cancelled'
281
+ : 'failure'
282
+ };
283
+ }, true);
284
+ }, { failFast, maxParallel, signal: internal.signal });
285
+ }
286
+ else {
287
+ // Outputs so far, as each combination's result reports them.
288
+ const jobOutputs = {};
289
+ // One matrix combination (or the job itself): its container,
290
+ // services and steps. A cancelled combination (fail-fast) has
291
+ // its running step killed and its containers removed.
292
+ const runCombination = async (jobIndex, matrixValues, signal) => {
293
+ const jobLabel = labelOf(matrixValues);
294
+ const runtime = (0, job_runtime_1.createJobRuntime)(github, runDir, {
295
+ shell: job.defaults?.run?.shell ?? parsed.file.defaults?.run?.shell,
296
+ workingDirectory: job.defaults?.run?.['working-directory'] ??
297
+ parsed.file.defaults?.run?.['working-directory']
298
+ });
299
+ runtime.signal = signal;
300
+ // Workflow-level env can't reference `env` itself; job-level
301
+ // env sees the workflow env plus matrix (matching GitHub's
302
+ // contexts).
303
+ const workflowEnv = {
304
+ ...options.env,
305
+ ...(0, expression_1.substituteRecord)(parsed.file.env, {
306
+ ...(0, expression_1.createContext)({ ...options.env }, inputs, options.secrets, github),
307
+ vars
308
+ })
309
+ };
310
+ const jobEnv = (0, expression_1.substituteRecord)(job.env, {
311
+ ...(0, expression_1.createContext)(workflowEnv, inputs, options.secrets, github, matrixValues),
312
+ needs,
271
313
  vars
272
- })
273
- };
274
- const jobEnv = (0, expression_1.substituteRecord)(job.env, {
275
- ...(0, expression_1.createContext)(workflowEnv, inputs, options.secrets, github, matrixValues),
276
- needs,
277
- vars
278
- });
279
- const jobCtx = {
280
- ...(0, expression_1.createContext)({ ...workflowEnv, ...jobEnv }, inputs, options.secrets, github, matrixValues),
281
- needs,
282
- vars,
283
- job: { status: 'success' },
284
- runner: (0, job_runtime_1.runnerContext)(runtime),
285
- strategy: {
286
- 'fail-fast': failFast,
287
- 'job-index': jobIndex,
288
- 'job-total': matrixCombinations.length,
289
- 'max-parallel': job.strategy?.['max-parallel'] ?? matrixCombinations.length
290
- }
291
- };
292
- logger_1.logger.step(`Executing job: ${jobLabel}`);
293
- const jobStartTime = Date.now();
294
- const jobTimeoutMinutes = (0, job_plan_1.numberSetting)(job['timeout-minutes'], jobCtx);
295
- const jobDeadline = jobTimeoutMinutes
296
- ? jobStartTime + jobTimeoutMinutes * 60000
297
- : undefined;
298
- const jobTimeoutMessage = `The job running on runner secureci has exceeded the maximum execution time of ${jobTimeoutMinutes} minutes.`;
299
- const stepResults = [];
300
- if (!(await (0, job_container_1.startJobServices)(job, jobCtx, options, runtime)) ||
301
- !(await (0, job_container_1.startJobContainer)(job, jobCtx, options, runtime, runDir))) {
302
- await (0, job_container_1.stopJobContainer)(runtime);
303
- jobs.push({
304
- job: jobLabel,
305
- success: false,
306
- steps: [],
307
- duration: Date.now() - jobStartTime
308
314
  });
309
- matrixFailed = true;
310
- continue;
311
- }
312
- if (!runtime.container) {
313
- (0, actions_1.warnUnsandboxedActions)(job.steps ?? [], jobCtx, options);
314
- }
315
- try {
316
- await (0, actions_1.runPreSteps)(job.steps ?? [], options, runtime, jobCtx, stepResults);
317
- // Execute each step
318
- for (const step of job.steps || []) {
319
- jobCtx.job = {
320
- ...jobCtx.job,
321
- status: jobCtx.jobFailed ? 'failure' : 'success'
322
- };
323
- // `$GITHUB_ENV` additions from earlier steps sit between the
324
- // job env and this step's own `env:`.
325
- const envCtx = {
326
- ...jobCtx,
327
- env: { ...jobCtx.env, ...runtime.env }
328
- };
329
- runtime.actionEnv = (0, job_runtime_1.stepActionEnv)(runtime, step);
330
- const stepCtx = {
331
- ...envCtx,
332
- github: {
333
- ...envCtx.github,
334
- action: runtime.actionEnv.GITHUB_ACTION
335
- },
336
- env: { ...envCtx.env, ...(0, expression_1.substituteRecord)(step.env, envCtx) }
337
- };
338
- // After a failure, only steps whose `if:` uses a status
339
- // function (always(), failure(), ...) still run.
340
- let runStep;
341
- try {
342
- runStep = (0, expression_1.evaluateCondition)((0, expression_1.withImplicitSuccess)(step.if), stepCtx);
315
+ const jobCtx = {
316
+ ...(0, expression_1.createContext)({ ...workflowEnv, ...jobEnv }, inputs, options.secrets, github, matrixValues),
317
+ needs,
318
+ vars,
319
+ job: { status: 'success' },
320
+ runner: (0, job_runtime_1.runnerContext)(runtime),
321
+ strategy: {
322
+ 'fail-fast': failFast,
323
+ 'job-index': jobIndex,
324
+ 'job-total': matrixCombinations.length,
325
+ 'max-parallel': job.strategy?.['max-parallel'] ?? matrixCombinations.length
343
326
  }
344
- catch (error) {
345
- // An if: that can't be evaluated fails the step.
346
- const message = (0, expression_1.ifEvaluationError)(`step '${(0, job_runtime_1.stepDisplayName)(step, stepCtx)}'`, error);
347
- logger_1.logger.error(message);
348
- stepResults.push({
349
- step,
327
+ };
328
+ logger_1.logger.step(`Executing job: ${jobLabel}`);
329
+ const jobStartTime = Date.now();
330
+ const jobTimeoutMinutes = (0, job_plan_1.numberSetting)(job['timeout-minutes'], jobCtx);
331
+ const jobDeadline = jobTimeoutMinutes
332
+ ? jobStartTime + jobTimeoutMinutes * 60000
333
+ : undefined;
334
+ const jobTimeoutMessage = `The job running on runner secureci has exceeded the maximum execution time of ${jobTimeoutMinutes} minutes.`;
335
+ const stepResults = [];
336
+ const cancelledResult = () => {
337
+ logger_1.logger.warn(`Cancelled job: ${jobLabel}`);
338
+ unitResults[jobIndex] = {
339
+ job: jobLabel,
340
+ success: false,
341
+ cancelled: true,
342
+ steps: stepResults,
343
+ duration: Date.now() - jobStartTime
344
+ };
345
+ return { result: 'cancelled' };
346
+ };
347
+ // Once the containers are up, cancelling removes them, which
348
+ // ends the step running in them.
349
+ let stopping;
350
+ const onAbort = () => {
351
+ stopping = (0, job_container_1.stopJobContainer)(runtime);
352
+ };
353
+ try {
354
+ if (!(await (0, job_container_1.startJobServices)(job, jobCtx, options, runtime)) ||
355
+ !(await (0, job_container_1.startJobContainer)(job, jobCtx, options, runtime, runDir))) {
356
+ await (0, job_container_1.stopJobContainer)(runtime);
357
+ if (signal.aborted)
358
+ return cancelledResult();
359
+ unitResults[jobIndex] = {
360
+ job: jobLabel,
350
361
  success: false,
351
- error: message,
352
- duration: 0
353
- });
354
- if (step.id) {
355
- jobCtx.steps[step.id] = {
356
- outcome: 'failure',
357
- conclusion: 'failure',
358
- outputs: {}
359
- };
360
- }
361
- jobCtx.jobFailed = true;
362
- continue;
363
- }
364
- if (!runStep) {
365
- logger_1.logger.info(`Skipping step: ${(0, job_runtime_1.stepDisplayName)(step, stepCtx)} (condition not met)`);
366
- const skippedResult = {
367
- step,
368
- success: true,
369
- outcome: 'skipped',
370
- output: 'Skipped: condition not met',
371
- duration: 0
362
+ steps: [],
363
+ duration: Date.now() - jobStartTime
372
364
  };
373
- stepResults.push(skippedResult);
374
- if (step.id) {
375
- jobCtx.steps[step.id] = {
376
- outcome: 'skipped',
377
- conclusion: 'skipped',
378
- outputs: {}
379
- };
380
- }
381
- continue;
365
+ return { result: 'failure' };
366
+ }
367
+ if (signal.aborted) {
368
+ await (0, job_container_1.stopJobContainer)(runtime);
369
+ return cancelledResult();
370
+ }
371
+ signal.addEventListener('abort', onAbort, { once: true });
372
+ if (!runtime.container) {
373
+ (0, actions_1.warnUnsandboxedActions)(job.steps ?? [], jobCtx, options);
382
374
  }
383
- // timeout-minutes: the step's own, or what's left of the job's.
384
- const stepMinutes = (0, job_plan_1.numberSetting)(step['timeout-minutes'], stepCtx);
385
- const jobLeft = jobDeadline !== undefined
386
- ? Math.max(jobDeadline - Date.now(), 1)
387
- : undefined;
388
- const stepMs = stepMinutes ? stepMinutes * 60000 : undefined;
389
- runtime.stepTimeout =
390
- jobLeft !== undefined &&
391
- (stepMs === undefined || jobLeft < stepMs)
392
- ? { ms: jobLeft, message: jobTimeoutMessage }
393
- : stepMs !== undefined
394
- ? {
395
- ms: stepMs,
396
- message: `The action '${(0, job_runtime_1.stepDisplayName)(step, stepCtx)}' has timed out after ${stepMinutes} minutes.`
375
+ try {
376
+ await (0, actions_1.runPreSteps)(job.steps ?? [], options, runtime, jobCtx, stepResults);
377
+ // Execute each step
378
+ for (const step of job.steps || []) {
379
+ if (signal.aborted)
380
+ break;
381
+ jobCtx.job = {
382
+ ...jobCtx.job,
383
+ status: jobCtx.jobFailed ? 'failure' : 'success'
384
+ };
385
+ // `$GITHUB_ENV` additions from earlier steps sit between
386
+ // the job env and this step's own `env:`.
387
+ const envCtx = {
388
+ ...jobCtx,
389
+ env: { ...jobCtx.env, ...runtime.env }
390
+ };
391
+ runtime.actionEnv = (0, job_runtime_1.stepActionEnv)(runtime, step);
392
+ const stepCtx = {
393
+ ...envCtx,
394
+ github: {
395
+ ...envCtx.github,
396
+ action: runtime.actionEnv.GITHUB_ACTION
397
+ },
398
+ env: {
399
+ ...envCtx.env,
400
+ ...(0, expression_1.substituteRecord)(step.env, envCtx)
397
401
  }
402
+ };
403
+ // After a failure, only steps whose `if:` uses a status
404
+ // function (always(), failure(), ...) still run.
405
+ let runStep;
406
+ try {
407
+ runStep = (0, expression_1.evaluateCondition)((0, expression_1.withImplicitSuccess)(step.if), stepCtx);
408
+ }
409
+ catch (error) {
410
+ // An if: that can't be evaluated fails the step.
411
+ const message = (0, expression_1.ifEvaluationError)(`step '${(0, job_runtime_1.stepDisplayName)(step, stepCtx)}'`, error);
412
+ logger_1.logger.error(message);
413
+ stepResults.push({
414
+ step,
415
+ success: false,
416
+ error: message,
417
+ duration: 0
418
+ });
419
+ if (step.id) {
420
+ jobCtx.steps[step.id] = {
421
+ outcome: 'failure',
422
+ conclusion: 'failure',
423
+ outputs: {}
424
+ };
425
+ }
426
+ jobCtx.jobFailed = true;
427
+ continue;
428
+ }
429
+ if (!runStep) {
430
+ logger_1.logger.info(`Skipping step: ${(0, job_runtime_1.stepDisplayName)(step, stepCtx)} (condition not met)`);
431
+ const skippedResult = {
432
+ step,
433
+ success: true,
434
+ outcome: 'skipped',
435
+ output: 'Skipped: condition not met',
436
+ duration: 0
437
+ };
438
+ stepResults.push(skippedResult);
439
+ if (step.id) {
440
+ jobCtx.steps[step.id] = {
441
+ outcome: 'skipped',
442
+ conclusion: 'skipped',
443
+ outputs: {}
444
+ };
445
+ }
446
+ continue;
447
+ }
448
+ // timeout-minutes: the step's own, or what's left of the
449
+ // job's.
450
+ const stepMinutes = (0, job_plan_1.numberSetting)(step['timeout-minutes'], stepCtx);
451
+ const jobLeft = jobDeadline !== undefined
452
+ ? Math.max(jobDeadline - Date.now(), 1)
398
453
  : undefined;
399
- const stepResult = await (0, steps_1.executeStep)(step, options, runtime, stepCtx);
400
- runtime.stepTimeout = undefined;
401
- stepResults.push(stepResult);
402
- if (jobDeadline !== undefined && Date.now() >= jobDeadline) {
403
- // The job is cancelled: no further steps run.
404
- if (stepResult.success) {
405
- stepResult.success = false;
406
- stepResult.error = jobTimeoutMessage;
454
+ const stepMs = stepMinutes
455
+ ? stepMinutes * 60000
456
+ : undefined;
457
+ runtime.stepTimeout =
458
+ jobLeft !== undefined &&
459
+ (stepMs === undefined || jobLeft < stepMs)
460
+ ? { ms: jobLeft, message: jobTimeoutMessage }
461
+ : stepMs !== undefined
462
+ ? {
463
+ ms: stepMs,
464
+ message: `The action '${(0, job_runtime_1.stepDisplayName)(step, stepCtx)}' has timed out after ${stepMinutes} minutes.`
465
+ }
466
+ : undefined;
467
+ const stepResult = await (0, steps_1.executeStep)(step, options, runtime, stepCtx);
468
+ runtime.stepTimeout = undefined;
469
+ stepResults.push(stepResult);
470
+ if (signal.aborted) {
471
+ // Cancelled: the step was killed; nothing else runs.
472
+ if (!stepResult.success) {
473
+ stepResult.error = CANCELLED_MESSAGE;
474
+ }
475
+ break;
476
+ }
477
+ if (jobDeadline !== undefined &&
478
+ Date.now() >= jobDeadline) {
479
+ // The job is cancelled: no further steps run.
480
+ if (stepResult.success) {
481
+ stepResult.success = false;
482
+ stepResult.error = jobTimeoutMessage;
483
+ }
484
+ logger_1.logger.error(jobTimeoutMessage);
485
+ jobCtx.jobFailed = true;
486
+ break;
487
+ }
488
+ const outcome = stepResult.success ? 'success' : 'failure';
489
+ if (step.id) {
490
+ jobCtx.steps[step.id] = {
491
+ outcome,
492
+ // continue-on-error turns a failure into a successful
493
+ // conclusion, which is what success()/failure() see.
494
+ conclusion: step['continue-on-error']
495
+ ? 'success'
496
+ : outcome,
497
+ outputs: stepResult.outputs || {}
498
+ };
499
+ }
500
+ if (!stepResult.success && !step['continue-on-error']) {
501
+ jobCtx.jobFailed = true;
502
+ logger_1.logger.error(`Step failed: ${(0, job_runtime_1.stepDisplayName)(step, stepCtx)}`);
503
+ }
407
504
  }
408
- logger_1.logger.error(jobTimeoutMessage);
409
- jobCtx.jobFailed = true;
410
- break;
411
- }
412
- const outcome = stepResult.success ? 'success' : 'failure';
413
- if (step.id) {
414
- jobCtx.steps[step.id] = {
415
- outcome,
416
- // continue-on-error turns a failure into a successful
417
- // conclusion, which is what success()/failure() see.
418
- conclusion: step['continue-on-error'] ? 'success' : outcome,
419
- outputs: stepResult.outputs || {}
505
+ if (signal.aborted)
506
+ return cancelledResult();
507
+ await (0, job_runtime_1.runPostSteps)(runtime, jobCtx, stepResults);
508
+ if (runtime.summary.trim()) {
509
+ logger_1.logger.info(`Job summary (${jobLabel}):\n${runtime.summary.trim()}`);
510
+ }
511
+ jobCtx.job = {
512
+ ...jobCtx.job,
513
+ status: jobCtx.jobFailed ? 'failure' : 'success'
420
514
  };
515
+ unitOutputs[jobIndex] = (0, expression_1.substituteRecord)(job.outputs, {
516
+ ...jobCtx,
517
+ env: { ...jobCtx.env, ...runtime.env }
518
+ });
519
+ Object.assign(jobOutputs, unitOutputs[jobIndex]);
421
520
  }
422
- if (!stepResult.success && !step['continue-on-error']) {
423
- jobCtx.jobFailed = true;
424
- logger_1.logger.error(`Step failed: ${(0, job_runtime_1.stepDisplayName)(step, stepCtx)}`);
521
+ finally {
522
+ signal.removeEventListener('abort', onAbort);
523
+ await stopping;
524
+ await (0, job_container_1.stopJobContainer)(runtime);
425
525
  }
426
526
  }
427
- await (0, job_runtime_1.runPostSteps)(runtime, jobCtx, stepResults);
428
- if (runtime.summary.trim()) {
429
- logger_1.logger.info(`Job summary (${jobLabel}):\n${runtime.summary.trim()}`);
527
+ finally {
528
+ await stopping;
430
529
  }
431
- jobCtx.job = {
432
- ...jobCtx.job,
433
- status: jobCtx.jobFailed ? 'failure' : 'success'
530
+ const jobSuccess = stepResults.every((s) => s.success || s.step['continue-on-error']);
531
+ // Job-level continue-on-error (often
532
+ // `${{ matrix.experimental }}`) lets this combination fail
533
+ // without failing the job or run.
534
+ const allowedFailure = !jobSuccess &&
535
+ (0, job_plan_1.isTruthySetting)(job['continue-on-error'], jobCtx);
536
+ unitResults[jobIndex] = {
537
+ job: jobLabel,
538
+ success: jobSuccess,
539
+ ...(allowedFailure ? { continueOnError: true } : {}),
540
+ ...(runtime.summary ? { summary: runtime.summary } : {}),
541
+ steps: stepResults,
542
+ outputs: { ...jobOutputs },
543
+ duration: Date.now() - jobStartTime
434
544
  };
435
- Object.assign(jobOutputs, (0, expression_1.substituteRecord)(job.outputs, {
436
- ...jobCtx,
437
- env: { ...jobCtx.env, ...runtime.env }
438
- }));
439
- }
440
- finally {
441
- await (0, job_container_1.stopJobContainer)(runtime);
442
- }
443
- const jobSuccess = stepResults.every((s) => s.success || s.step['continue-on-error']);
444
- // Job-level continue-on-error (often `${{ matrix.experimental }}`)
445
- // lets this combination fail without failing the job or run.
446
- const allowedFailure = !jobSuccess && (0, job_plan_1.isTruthySetting)(job['continue-on-error'], jobCtx);
447
- jobs.push({
448
- job: jobLabel,
449
- success: jobSuccess,
450
- ...(allowedFailure ? { continueOnError: true } : {}),
451
- ...(runtime.summary ? { summary: runtime.summary } : {}),
452
- steps: stepResults,
453
- outputs: { ...jobOutputs },
454
- duration: Date.now() - jobStartTime
545
+ return {
546
+ result: jobSuccess ? 'success' : 'failure',
547
+ allowedFailure
548
+ };
549
+ };
550
+ outcomes = await (0, scheduler_1.runMatrix)(selected.length, (index, signal) => {
551
+ const { index: jobIndex, values: matrixValues } = selected[index];
552
+ return tagged(labelOf(matrixValues), () => runCombination(jobIndex, matrixValues, signal), true);
553
+ }, {
554
+ failFast,
555
+ maxParallel,
556
+ slots,
557
+ signal: internal.signal,
558
+ onFailFast: () => logger_1.logger.info(concurrent
559
+ ? `Cancelling remaining matrix jobs for ${jobId} (fail-fast)`
560
+ : `Skipping remaining matrix jobs for ${jobId} (fail-fast)`)
455
561
  });
456
- if (!jobSuccess && !allowedFailure) {
457
- matrixFailed = true;
458
- }
459
562
  }
460
- const jobSuccess = !matrixFailed;
461
- jobResults.set(jobId, {
462
- result: jobSuccess ? 'success' : 'failure',
463
- outputs: jobOutputs
464
- });
465
- if (jobSuccess) {
563
+ for (const result of unitResults) {
564
+ if (result)
565
+ jobs.push(result);
566
+ }
567
+ const failed = outcomes.some((o) => o?.result === 'failure' && !o.allowedFailure);
568
+ const cancelled = !failed && outcomes.some((o) => o?.result === 'cancelled');
569
+ if (!failed && !cancelled) {
466
570
  logger_1.logger.success(`Job ${jobId} completed successfully`);
467
571
  }
572
+ return {
573
+ result: failed ? 'failure' : cancelled ? 'cancelled' : 'success',
574
+ // As if the combinations ran in order: the last one's value wins.
575
+ outputs: Object.assign({}, ...unitOutputs)
576
+ };
577
+ });
578
+ const jobResults = await (0, scheduler_1.runJobGraph)(order, needsOf, runJob, concurrent);
579
+ const jobs = order.flatMap((jobId) => resultsByJob.get(jobId) ?? []);
580
+ for (const key of (0, job_plan_1.unknownMatrixKeys)(matrixFilter, definedMatrixKeys)) {
581
+ logger_1.logger.warn(`--matrix ${key}: no matrix job defines this key; it was ignored`);
468
582
  }
469
583
  const callOutputs = (0, github_context_1.workflowCallOutputs)(parsed.file.on);
470
584
  return {
471
- success: jobs.every((j) => j.success || j.continueOnError),
585
+ success: !matrixUnmatched && jobs.every((j) => j.success || j.continueOnError),
472
586
  jobs,
473
587
  duration: Date.now() - startTime,
474
588
  ...(callOutputs