actions-warden 0.2.0 → 0.4.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 (63) hide show
  1. package/AGENTS.md +199 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +284 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +30 -3
  6. package/docs/AI-AGENTS.md +474 -0
  7. package/docs/CLI.md +474 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +373 -0
  10. package/docs/GITHUB-ACTION.md +285 -0
  11. package/docs/JAVASCRIPT-API.md +357 -0
  12. package/docs/OUTPUTS.md +414 -0
  13. package/docs/README.md +27 -0
  14. package/examples/org-scan.yml +42 -0
  15. package/examples/upgrade-pr.yml +57 -0
  16. package/llms.txt +38 -0
  17. package/package.json +32 -10
  18. package/skills/actions-warden/SKILL.md +151 -40
  19. package/src/action.js +306 -0
  20. package/src/cli.js +494 -56
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +544 -0
  23. package/src/commands/pin.js +59 -56
  24. package/src/commands/report.js +122 -10
  25. package/src/commands/upgrade.js +102 -62
  26. package/src/commands/verify.js +193 -0
  27. package/src/index.js +21 -4
  28. package/src/lib/action-status.js +27 -0
  29. package/src/lib/agent-mode.js +174 -0
  30. package/src/lib/annotations.js +250 -0
  31. package/src/lib/baseline.js +103 -0
  32. package/src/lib/cache.js +47 -10
  33. package/src/lib/concurrency.js +27 -0
  34. package/src/lib/config.js +185 -0
  35. package/src/lib/execution.js +71 -0
  36. package/src/lib/formatter.js +127 -8
  37. package/src/lib/github-org.js +374 -0
  38. package/src/lib/identity.js +62 -0
  39. package/src/lib/ignore.js +7 -6
  40. package/src/lib/org-checkpoint.js +461 -0
  41. package/src/lib/org-progress.js +60 -0
  42. package/src/lib/parser.js +326 -52
  43. package/src/lib/patcher.js +199 -0
  44. package/src/lib/path-equality.js +30 -0
  45. package/src/lib/paths.js +35 -12
  46. package/src/lib/redact.js +65 -4
  47. package/src/lib/resolver.js +225 -43
  48. package/src/lib/targets.js +28 -0
  49. package/src/lib/triggers.js +12 -0
  50. package/src/lib/writer.js +48 -8
  51. package/src/rules/excessive-permissions.js +24 -33
  52. package/src/rules/index.js +19 -1
  53. package/src/rules/pull-request-target-checkout.js +149 -18
  54. package/src/rules/reusable-workflow-secrets.js +32 -0
  55. package/src/rules/script-injection.js +77 -12
  56. package/src/rules/secrets-in-env.js +101 -18
  57. package/src/rules/unpinned-action.js +3 -2
  58. package/src/rules/unpinned-container-image.js +39 -0
  59. package/src/rules/unpinned-docker-action.js +30 -0
  60. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  61. package/src/rules/workflow-run-artifact-execution.js +122 -0
  62. package/src/rules/workflow-structure.js +396 -0
  63. package/src/version.js +3 -0
package/src/lib/parser.js CHANGED
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { readFile } from 'node:fs/promises';
10
- import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
10
+ import {
11
+ parseDocument,
12
+ isAlias,
13
+ isMap,
14
+ isSeq,
15
+ isPair,
16
+ isScalar,
17
+ } from 'yaml';
11
18
 
12
19
  /**
13
20
  * @typedef {object} ActionRef
@@ -16,30 +23,71 @@ import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
16
23
  * @property {string|null} repo
17
24
  * @property {string|null} subpath - reusable-workflow sub-path
18
25
  * @property {string|null} ref - tag, branch, or SHA after `@`
19
- * @property {'external'|'reusable-workflow'|'local'|'docker'|'unknown'} kind
26
+ * @property {'external'|'reusable-workflow'|'local'|'self'|'docker'|'unknown'} kind
20
27
  * @property {number} line
28
+ * @property {number} column
29
+ * @property {number} start
30
+ * @property {number} end
31
+ * @property {number} lineStart
32
+ * @property {number} lineEnd
33
+ * @property {boolean} alias
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} ImageRef
38
+ * @property {string} raw
39
+ * @property {number} line
40
+ * @property {number} start
41
+ * @property {number} end
42
+ * @property {string} context
43
+ * @property {string|null} jobName
21
44
  */
22
45
 
23
46
  /**
24
47
  * @typedef {object} StepNode
25
48
  * @property {string|null} name
26
49
  * @property {string|null} id
50
+ * @property {boolean} ifDeclared
27
51
  * @property {ActionRef|null} uses
52
+ * @property {boolean} usesDeclared
28
53
  * @property {string|null} run
29
- * @property {object|null} env
30
- * @property {object|null} with_
54
+ * @property {boolean} runDeclared
55
+ * @property {unknown} env
56
+ * @property {unknown} with_
57
+ * @property {string|null} workingDirectory
58
+ * @property {string|null} shell
59
+ * @property {boolean} shellDeclared
60
+ * @property {'wait'|'wait-all'|'cancel'|'parallel'|null} control
61
+ * @property {unknown} controlValue
62
+ * @property {number} primaryCount
63
+ * @property {unknown} background
64
+ * @property {boolean} backgroundDeclared
65
+ * @property {boolean} parallelValid
66
+ * @property {number} parallelDepth
31
67
  * @property {number} line
68
+ * @property {number} runLine
69
+ * @property {number} envLine
32
70
  */
33
71
 
34
72
  /**
35
73
  * @typedef {object} JobNode
36
74
  * @property {string} name
37
- * @property {object|null} permissions
38
- * @property {string|null} runsOn
39
- * @property {object|null} env
40
- * @property {ActionRef|null} uses reusable workflow called at job level
75
+ * @property {unknown} permissions
76
+ * @property {boolean} permissionsDeclared
77
+ * @property {unknown} runsOn
78
+ * @property {boolean} runsOnDeclared
79
+ * @property {unknown} env
80
+ * @property {ActionRef|null} uses
81
+ * @property {boolean} usesDeclared
82
+ * @property {unknown} with_
83
+ * @property {unknown} secrets
41
84
  * @property {StepNode[]} steps
85
+ * @property {boolean} stepsDeclared
86
+ * @property {boolean} stepsValid
87
+ * @property {boolean} validMapping
42
88
  * @property {number} line
89
+ * @property {number} permissionsLine
90
+ * @property {number} envLine
43
91
  */
44
92
 
45
93
  /**
@@ -49,19 +97,24 @@ import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
49
97
  * @property {'workflow'|'composite-action'|'unknown'} kind
50
98
  * @property {string|null} name
51
99
  * @property {unknown} on
52
- * @property {object|null} permissions
100
+ * @property {boolean} onDeclared
101
+ * @property {unknown} permissions
102
+ * @property {boolean} permissionsDeclared
53
103
  * @property {object|null} env
54
104
  * @property {JobNode[]} jobs
55
- * @property {StepNode[]} actionSteps steps from a composite action's `runs` block
105
+ * @property {ImageRef[]} images
106
+ * @property {number} permissionsLine
107
+ * @property {number} envLine
56
108
  * @property {object} raw - the parsed plain object (for rules to query)
57
109
  */
58
110
 
59
111
  /**
60
112
  * @param {string} raw e.g. `actions/checkout@v3` or `./local`
61
113
  * @param {number} line
114
+ * @param {{column?: number, start?: number, end?: number, lineStart?: number, lineEnd?: number}} [location]
62
115
  * @returns {ActionRef}
63
116
  */
64
- export function parseActionRef(raw, line) {
117
+ export function parseActionRef(raw, line, location = {}) {
65
118
  /** @type {ActionRef} */
66
119
  const base = {
67
120
  raw,
@@ -71,11 +124,20 @@ export function parseActionRef(raw, line) {
71
124
  ref: null,
72
125
  kind: 'unknown',
73
126
  line,
127
+ column: location.column ?? 0,
128
+ start: location.start ?? 0,
129
+ end: location.end ?? 0,
130
+ lineStart: location.lineStart ?? 0,
131
+ lineEnd: location.lineEnd ?? 0,
132
+ alias: Boolean(location.alias),
74
133
  };
75
134
  if (typeof raw !== 'string' || raw.length === 0) return base;
76
135
  if (raw.startsWith('./') || raw.startsWith('../')) {
77
136
  return { ...base, kind: 'local' };
78
137
  }
138
+ if (raw.startsWith('$/')) {
139
+ return { ...base, kind: 'self' };
140
+ }
79
141
  if (raw.startsWith('docker://')) {
80
142
  return { ...base, kind: 'docker' };
81
143
  }
@@ -109,6 +171,27 @@ function lineFromRange(source, node) {
109
171
  return line;
110
172
  }
111
173
 
174
+ /**
175
+ * Return exact source offsets for a scalar node.
176
+ *
177
+ * @param {string} source
178
+ * @param {{range?: number[]}} node
179
+ */
180
+ function locationFromRange(source, node) {
181
+ const start = node?.range?.[0] ?? 0;
182
+ const end = node?.range?.[1] ?? start;
183
+ const lineStart = source.lastIndexOf('\n', Math.max(start - 1, 0)) + 1;
184
+ const newline = source.indexOf('\n', end);
185
+ const lineEnd = newline === -1 ? source.length : newline;
186
+ return {
187
+ start,
188
+ end,
189
+ lineStart,
190
+ lineEnd,
191
+ column: start - lineStart + 1,
192
+ };
193
+ }
194
+
112
195
  /**
113
196
  * Read a Pair value into a JS scalar where possible.
114
197
  *
@@ -139,53 +222,108 @@ function findPair(map, key) {
139
222
  }
140
223
 
141
224
  /**
142
- * Extract a `steps` sequence from a workflow job or composite action `runs`
143
- * mapping. Both locations use the same step-level `uses` syntax.
144
- *
145
225
  * @param {string} source
146
- * @param {object} parentNode
226
+ * @param {object} stepsNode
227
+ * @param {number} [parallelDepth]
147
228
  * @returns {StepNode[]}
148
229
  */
149
- function extractSteps(source, parentNode) {
230
+ function extractSteps(source, stepsNode, doc, parallelDepth = 0) {
231
+ if (!isSeq(stepsNode)) return [];
150
232
  const steps = [];
151
- const stepsPair = findPair(parentNode, 'steps');
152
- if (!stepsPair || !isSeq(stepsPair.value)) return steps;
153
-
154
- for (const stepNode of stepsPair.value.items) {
233
+ for (const stepNode of stepsNode.items) {
155
234
  if (!isMap(stepNode)) continue;
156
235
  const usesPair = findPair(stepNode, 'uses');
157
236
  const runPair = findPair(stepNode, 'run');
158
237
  const namePair = findPair(stepNode, 'name');
159
238
  const idPair = findPair(stepNode, 'id');
239
+ const ifPair = findPair(stepNode, 'if');
160
240
  const envPair = findPair(stepNode, 'env');
161
241
  const withPair = findPair(stepNode, 'with');
162
-
242
+ const workingDirectoryPair = findPair(stepNode, 'working-directory');
243
+ const shellPair = findPair(stepNode, 'shell');
244
+ const waitPair = findPair(stepNode, 'wait');
245
+ const waitAllPair = findPair(stepNode, 'wait-all');
246
+ const cancelPair = findPair(stepNode, 'cancel');
247
+ const parallelPair = findPair(stepNode, 'parallel');
248
+ const backgroundPair = findPair(stepNode, 'background');
249
+ const controlPairs = [
250
+ ['wait', waitPair],
251
+ ['wait-all', waitAllPair],
252
+ ['cancel', cancelPair],
253
+ ['parallel', parallelPair],
254
+ ].filter(([, pair]) => Boolean(pair));
255
+ const [control, controlPair] = controlPairs[0] ?? [null, null];
256
+ const stepLine = lineFromRange(source, stepNode);
257
+ /** @type {StepNode} */
163
258
  const step = {
164
259
  name: namePair && isScalar(namePair.value) ? String(namePair.value.value) : null,
165
260
  id: idPair && isScalar(idPair.value) ? String(idPair.value.value) : null,
261
+ ifDeclared: Boolean(ifPair),
166
262
  uses: null,
263
+ usesDeclared: Boolean(usesPair),
167
264
  run: runPair && isScalar(runPair.value) ? String(runPair.value.value) : null,
265
+ runDeclared: Boolean(runPair),
168
266
  env: envPair ? toJs(envPair.value) : null,
169
267
  with_: withPair ? toJs(withPair.value) : null,
170
- line: lineFromRange(source, stepNode),
268
+ workingDirectory: workingDirectoryPair && isScalar(workingDirectoryPair.value)
269
+ ? String(workingDirectoryPair.value.value)
270
+ : null,
271
+ shell: shellPair && isScalar(shellPair.value) ? String(shellPair.value.value) : null,
272
+ shellDeclared: Boolean(shellPair),
273
+ control,
274
+ controlValue: controlPair ? toJs(controlPair.value) : null,
275
+ primaryCount: Number(Boolean(usesPair)) + Number(Boolean(runPair)) + controlPairs.length,
276
+ background: backgroundPair ? toJs(backgroundPair.value) : null,
277
+ backgroundDeclared: Boolean(backgroundPair),
278
+ parallelValid: Boolean(
279
+ parallelPair
280
+ && isSeq(parallelPair.value)
281
+ && parallelPair.value.items.length > 0
282
+ && parallelPair.value.items.every(item => isMap(item)),
283
+ ),
284
+ parallelDepth,
285
+ line: stepLine,
286
+ runLine: runPair ? lineFromRange(source, runPair.key) : 0,
287
+ envLine: envPair ? lineFromRange(source, envPair.key) : 0,
171
288
  };
172
- if (usesPair && isScalar(usesPair.value)) {
289
+ const usesValue = usesPair ? resolveScalar(usesPair.value, doc) : null;
290
+ if (usesValue) {
291
+ const usesLine = lineFromRange(source, usesValue.location);
173
292
  step.uses = parseActionRef(
174
- String(usesPair.value.value),
175
- lineFromRange(source, usesPair.value),
293
+ String(usesValue.scalar.value),
294
+ usesLine,
295
+ {
296
+ ...locationFromRange(source, usesValue.location),
297
+ alias: usesValue.alias,
298
+ },
176
299
  );
177
300
  }
178
301
  steps.push(step);
302
+ if (parallelPair && isSeq(parallelPair.value)) {
303
+ steps.push(...extractSteps(source, parallelPair.value, doc, parallelDepth + 1));
304
+ }
179
305
  }
180
306
  return steps;
181
307
  }
182
308
 
309
+ function imageRef(source, scalarValue, context, jobName = null) {
310
+ const location = locationFromRange(source, scalarValue.location);
311
+ return {
312
+ raw: String(scalarValue.scalar.value),
313
+ line: lineFromRange(source, scalarValue.location),
314
+ start: location.start,
315
+ end: location.end,
316
+ context,
317
+ jobName,
318
+ };
319
+ }
320
+
183
321
  /**
184
322
  * @param {string} source
185
323
  * @param {object} doc - yaml Document
186
324
  * @returns {JobNode[]}
187
325
  */
188
- function extractJobs(source, doc) {
326
+ function extractJobs(source, doc, images) {
189
327
  /** @type {JobNode[]} */
190
328
  const jobs = [];
191
329
  const jobsPair = findPair(doc.contents, 'jobs');
@@ -201,28 +339,90 @@ function extractJobs(source, doc) {
201
339
  const job = {
202
340
  name: jobName,
203
341
  permissions: null,
342
+ permissionsDeclared: false,
204
343
  runsOn: null,
344
+ runsOnDeclared: false,
205
345
  env: null,
206
346
  uses: null,
347
+ usesDeclared: false,
348
+ with_: null,
349
+ secrets: null,
207
350
  steps: [],
351
+ stepsDeclared: false,
352
+ stepsValid: false,
353
+ validMapping: isMap(jobNode),
208
354
  line: jobLine,
355
+ permissionsLine: 0,
356
+ envLine: 0,
209
357
  };
210
358
 
211
359
  if (isMap(jobNode)) {
212
360
  const perms = findPair(jobNode, 'permissions');
213
- if (perms) job.permissions = toJs(perms.value);
361
+ if (perms) {
362
+ job.permissions = toJs(perms.value);
363
+ job.permissionsDeclared = true;
364
+ job.permissionsLine = lineFromRange(source, perms.key);
365
+ }
214
366
  const runsOn = findPair(jobNode, 'runs-on');
215
- if (runsOn) job.runsOn = toJs(runsOn.value);
367
+ if (runsOn) {
368
+ job.runsOn = toJs(runsOn.value);
369
+ job.runsOnDeclared = true;
370
+ }
216
371
  const env = findPair(jobNode, 'env');
217
- if (env) job.env = toJs(env.value);
218
- const uses = findPair(jobNode, 'uses');
219
- if (uses && isScalar(uses.value)) {
372
+ if (env) {
373
+ job.env = toJs(env.value);
374
+ job.envLine = lineFromRange(source, env.key);
375
+ }
376
+ const jobUses = findPair(jobNode, 'uses');
377
+ job.usesDeclared = Boolean(jobUses);
378
+ const jobUsesValue = jobUses ? resolveScalar(jobUses.value, doc) : null;
379
+ if (jobUsesValue) {
380
+ const usesLine = lineFromRange(source, jobUsesValue.location);
220
381
  job.uses = parseActionRef(
221
- String(uses.value.value),
222
- lineFromRange(source, uses.value),
382
+ String(jobUsesValue.scalar.value),
383
+ usesLine,
384
+ {
385
+ ...locationFromRange(source, jobUsesValue.location),
386
+ alias: jobUsesValue.alias,
387
+ },
223
388
  );
224
389
  }
225
- job.steps = extractSteps(source, jobNode);
390
+ const jobWith = findPair(jobNode, 'with');
391
+ if (jobWith) job.with_ = toJs(jobWith.value);
392
+ const jobSecrets = findPair(jobNode, 'secrets');
393
+ if (jobSecrets) job.secrets = toJs(jobSecrets.value);
394
+
395
+ const containerPair = findPair(jobNode, 'container');
396
+ const containerValue = containerPair ? resolveScalar(containerPair.value, doc) : null;
397
+ if (containerValue) {
398
+ images.push(imageRef(source, containerValue, 'job-container', jobName));
399
+ } else if (containerPair && isMap(containerPair.value)) {
400
+ const imagePair = findPair(containerPair.value, 'image');
401
+ const imageValue = imagePair ? resolveScalar(imagePair.value, doc) : null;
402
+ if (imageValue) {
403
+ images.push(imageRef(source, imageValue, 'job-container', jobName));
404
+ }
405
+ }
406
+
407
+ const servicesPair = findPair(jobNode, 'services');
408
+ if (servicesPair && isMap(servicesPair.value)) {
409
+ for (const servicePair of servicesPair.value.items) {
410
+ if (!isPair(servicePair) || !isMap(servicePair.value)) continue;
411
+ const imagePair = findPair(servicePair.value, 'image');
412
+ const imageValue = imagePair ? resolveScalar(imagePair.value, doc) : null;
413
+ if (imageValue) {
414
+ images.push(imageRef(source, imageValue, 'service-container', jobName));
415
+ }
416
+ }
417
+ }
418
+
419
+ const stepsPair = findPair(jobNode, 'steps');
420
+ if (stepsPair) {
421
+ job.stepsDeclared = true;
422
+ job.stepsValid = isSeq(stepsPair.value)
423
+ && stepsPair.value.items.every(item => isMap(item));
424
+ job.steps = extractSteps(source, stepsPair.value, doc);
425
+ }
226
426
  }
227
427
  jobs.push(job);
228
428
  }
@@ -249,32 +449,85 @@ export function parseWorkflowSource(source, path) {
249
449
  kind: 'unknown',
250
450
  name: null,
251
451
  on: null,
452
+ onDeclared: false,
252
453
  permissions: null,
454
+ permissionsDeclared: false,
253
455
  env: null,
254
456
  jobs: [],
255
- actionSteps: [],
457
+ images: [],
458
+ permissionsLine: 0,
459
+ envLine: 0,
256
460
  raw: doc.toJS() ?? {},
257
461
  };
258
462
  if (!doc.contents || !isMap(doc.contents)) return result;
259
463
  const namePair = findPair(doc.contents, 'name');
260
464
  if (namePair && isScalar(namePair.value)) result.name = String(namePair.value.value);
261
465
  const onPair = findPair(doc.contents, 'on');
262
- if (onPair) result.on = toJs(onPair.value);
466
+ if (onPair) {
467
+ result.on = toJs(onPair.value);
468
+ result.onDeclared = true;
469
+ }
263
470
  const permPair = findPair(doc.contents, 'permissions');
264
- if (permPair) result.permissions = toJs(permPair.value);
471
+ if (permPair) {
472
+ result.permissions = toJs(permPair.value);
473
+ result.permissionsDeclared = true;
474
+ result.permissionsLine = lineFromRange(source, permPair.key);
475
+ }
265
476
  const envPair = findPair(doc.contents, 'env');
266
- if (envPair) result.env = toJs(envPair.value);
267
- const jobsPair = findPair(doc.contents, 'jobs');
268
- if (jobsPair) result.kind = 'workflow';
269
- result.jobs = extractJobs(source, doc);
477
+ if (envPair) {
478
+ result.env = toJs(envPair.value);
479
+ result.envLine = lineFromRange(source, envPair.key);
480
+ }
270
481
  const runsPair = findPair(doc.contents, 'runs');
271
- if (runsPair && isMap(runsPair.value)) {
272
- if (!jobsPair) result.kind = 'composite-action';
273
- result.actionSteps = extractSteps(source, runsPair.value);
482
+ const usingPair = runsPair && isMap(runsPair.value) ? findPair(runsPair.value, 'using') : null;
483
+ if (usingPair?.value?.value === 'composite') {
484
+ result.kind = 'composite-action';
485
+ const stepsPair = findPair(runsPair.value, 'steps');
486
+ result.jobs = [{
487
+ name: 'composite',
488
+ permissions: null,
489
+ permissionsDeclared: false,
490
+ runsOn: null,
491
+ runsOnDeclared: false,
492
+ env: null,
493
+ uses: null,
494
+ usesDeclared: false,
495
+ with_: null,
496
+ secrets: null,
497
+ steps: stepsPair ? extractSteps(source, stepsPair.value, doc) : [],
498
+ stepsDeclared: Boolean(stepsPair),
499
+ stepsValid: Boolean(
500
+ stepsPair
501
+ && isSeq(stepsPair.value)
502
+ && stepsPair.value.items.every(item => isMap(item)),
503
+ ),
504
+ validMapping: true,
505
+ line: lineFromRange(source, runsPair.key),
506
+ permissionsLine: 0,
507
+ envLine: 0,
508
+ }];
509
+ } else {
510
+ result.kind = findPair(doc.contents, 'jobs') ? 'workflow' : 'unknown';
511
+ result.jobs = extractJobs(source, doc, result.images);
512
+ if (usingPair?.value?.value === 'docker') {
513
+ result.kind = 'unknown';
514
+ const imagePair = findPair(runsPair.value, 'image');
515
+ const imageValue = imagePair ? resolveScalar(imagePair.value, doc) : null;
516
+ if (imageValue) {
517
+ result.images.push(imageRef(source, imageValue, 'docker-action'));
518
+ }
519
+ }
274
520
  }
275
521
  return result;
276
522
  }
277
523
 
524
+ function resolveScalar(node, doc) {
525
+ if (isScalar(node)) return { scalar: node, location: node, alias: false };
526
+ if (!isAlias(node)) return null;
527
+ const scalar = node.resolve(doc);
528
+ return isScalar(scalar) ? { scalar, location: node, alias: true } : null;
529
+ }
530
+
278
531
  /**
279
532
  * @param {string} path
280
533
  * @returns {Promise<WorkflowDoc>}
@@ -288,20 +541,41 @@ export async function parseWorkflowFile(path) {
288
541
  * Iterate every action reference in a workflow.
289
542
  *
290
543
  * @param {WorkflowDoc} workflow
291
- * @returns {Array<{ref: ActionRef, jobName: string|null, stepIndex: number, location: 'job'|'step'|'action-step'}>}
544
+ * @returns {Array<{ref: ActionRef, jobName: string|null, stepIndex: number, target: 'job'|'step', location: 'job'|'step'|'action-step'}>}
292
545
  */
293
546
  export function collectUses(workflow) {
294
547
  const out = [];
295
- for (const job of workflow.jobs ?? []) {
548
+ for (const job of workflow.jobs) {
296
549
  if (job.uses) {
297
- out.push({ ref: job.uses, jobName: job.name, stepIndex: -1, location: 'job' });
550
+ out.push({
551
+ ref: job.uses,
552
+ jobName: job.name,
553
+ stepIndex: -1,
554
+ target: 'job',
555
+ location: 'job',
556
+ });
298
557
  }
299
- (job.steps ?? []).forEach((step, i) => {
300
- if (step.uses) out.push({ ref: step.uses, jobName: job.name, stepIndex: i, location: 'step' });
558
+ job.steps.forEach((step, i) => {
559
+ if (!step.uses) return;
560
+ const composite = workflow.kind === 'composite-action';
561
+ out.push({
562
+ ref: step.uses,
563
+ jobName: composite ? null : job.name,
564
+ stepIndex: i,
565
+ target: 'step',
566
+ location: composite ? 'action-step' : 'step',
567
+ });
301
568
  });
302
569
  }
303
- (workflow.actionSteps ?? []).forEach((step, i) => {
304
- if (step.uses) out.push({ ref: step.uses, jobName: null, stepIndex: i, location: 'action-step' });
305
- });
306
570
  return out;
307
571
  }
572
+
573
+ /**
574
+ * Return container images referenced by jobs, services, and Docker actions.
575
+ *
576
+ * @param {WorkflowDoc} workflow
577
+ * @returns {ImageRef[]}
578
+ */
579
+ export function collectImages(workflow) {
580
+ return [...workflow.images];
581
+ }