willfire 0.1.11 → 0.1.13

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 (129) hide show
  1. package/dist/cli/index.d.ts +1 -0
  2. package/dist/cli/index.js +1 -0
  3. package/dist/cli/parseArgs.d.ts +9 -0
  4. package/dist/cli/parseArgs.js +42 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +41 -0
  7. package/dist/entries/index.d.ts +3 -0
  8. package/dist/entries/index.js +3 -0
  9. package/dist/entries/isJobEntry.d.ts +3 -0
  10. package/dist/entries/isJobEntry.js +2 -0
  11. package/dist/entries/isWorkflowEntry.d.ts +3 -0
  12. package/dist/entries/isWorkflowEntry.js +2 -0
  13. package/dist/entries/jobName.d.ts +3 -0
  14. package/dist/entries/jobName.js +2 -0
  15. package/dist/execute.d.ts +2 -2
  16. package/dist/execute.js +3 -1
  17. package/dist/expr/applyFunction.d.ts +11 -0
  18. package/dist/expr/applyFunction.js +41 -0
  19. package/dist/expr/asBool.d.ts +10 -0
  20. package/dist/expr/asBool.js +12 -0
  21. package/dist/expr/coalesceAnd.d.ts +7 -0
  22. package/dist/expr/coalesceAnd.js +17 -0
  23. package/dist/expr/coalesceOr.d.ts +6 -0
  24. package/dist/expr/coalesceOr.js +16 -0
  25. package/dist/expr/compare.d.ts +12 -0
  26. package/dist/expr/compare.js +54 -0
  27. package/dist/expr/cursor.d.ts +11 -0
  28. package/dist/expr/cursor.js +25 -0
  29. package/dist/expr/evaluate.d.ts +3 -0
  30. package/dist/expr/evaluate.js +6 -0
  31. package/dist/expr/evaluateValue.d.ts +14 -0
  32. package/dist/expr/evaluateValue.js +37 -0
  33. package/dist/expr/fromJson.d.ts +11 -0
  34. package/dist/expr/fromJson.js +29 -0
  35. package/dist/expr/index.d.ts +35 -0
  36. package/dist/expr/index.js +34 -0
  37. package/dist/expr/indexVal.d.ts +7 -0
  38. package/dist/expr/indexVal.js +34 -0
  39. package/dist/expr/lookup.d.ts +2 -0
  40. package/dist/expr/lookup.js +51 -0
  41. package/dist/expr/negate.d.ts +1 -0
  42. package/dist/expr/negate.js +3 -0
  43. package/dist/expr/parseAnd.d.ts +3 -0
  44. package/dist/expr/parseAnd.js +10 -0
  45. package/dist/expr/parseAtom.d.ts +3 -0
  46. package/dist/expr/parseAtom.js +43 -0
  47. package/dist/expr/parseCall.d.ts +9 -0
  48. package/dist/expr/parseCall.js +25 -0
  49. package/dist/expr/parseCmp.d.ts +3 -0
  50. package/dist/expr/parseCmp.js +12 -0
  51. package/dist/expr/parseOr.d.ts +11 -0
  52. package/dist/expr/parseOr.js +18 -0
  53. package/dist/expr/parsePrimary.d.ts +4 -0
  54. package/dist/expr/parsePrimary.js +16 -0
  55. package/dist/expr/parseUnary.d.ts +3 -0
  56. package/dist/expr/parseUnary.js +11 -0
  57. package/dist/expr/tokenize.d.ts +24 -0
  58. package/dist/expr/tokenize.js +74 -0
  59. package/dist/expr/truthy.d.ts +7 -0
  60. package/dist/expr/truthy.js +29 -0
  61. package/dist/expr/val.d.ts +72 -0
  62. package/dist/expr/val.js +1 -0
  63. package/dist/filters/index.d.ts +2 -0
  64. package/dist/filters/index.js +2 -0
  65. package/dist/filters/matchFilters.d.ts +2 -0
  66. package/dist/filters/matchFilters.js +12 -0
  67. package/dist/filters/patternToRegex.d.ts +1 -0
  68. package/dist/filters/patternToRegex.js +35 -0
  69. package/dist/index.d.ts +7 -0
  70. package/dist/index.js +6 -0
  71. package/dist/jobs/evalIf.d.ts +10 -0
  72. package/dist/jobs/evalIf.js +18 -0
  73. package/dist/jobs/expandJobs.d.ts +4 -0
  74. package/dist/jobs/expandJobs.js +247 -0
  75. package/dist/jobs/expandWorkflowJobs.d.ts +14 -0
  76. package/dist/jobs/expandWorkflowJobs.js +14 -0
  77. package/dist/jobs/index.d.ts +4 -0
  78. package/dist/jobs/index.js +4 -0
  79. package/dist/jobs/prScope.d.ts +10 -0
  80. package/dist/jobs/prScope.js +19 -0
  81. package/dist/matrix/expandMatrix.d.ts +4 -0
  82. package/dist/matrix/expandMatrix.js +6 -0
  83. package/dist/matrix/expandMatrixDetailed.d.ts +3 -0
  84. package/dist/matrix/expandMatrixDetailed.js +74 -0
  85. package/dist/matrix/formatMatrixValue.d.ts +8 -0
  86. package/dist/matrix/formatMatrixValue.js +16 -0
  87. package/dist/matrix/index.d.ts +4 -0
  88. package/dist/matrix/index.js +4 -0
  89. package/dist/matrix/matrixSuffix.d.ts +3 -0
  90. package/dist/matrix/matrixSuffix.js +8 -0
  91. package/dist/names/index.d.ts +4 -0
  92. package/dist/names/index.js +4 -0
  93. package/dist/names/jobDisplayName.d.ts +16 -0
  94. package/dist/names/jobDisplayName.js +25 -0
  95. package/dist/names/lookupPath.d.ts +1 -0
  96. package/dist/names/lookupPath.js +9 -0
  97. package/dist/names/renderName.d.ts +2 -0
  98. package/dist/names/renderName.js +26 -0
  99. package/dist/names/skippedDisplayName.d.ts +14 -0
  100. package/dist/names/skippedDisplayName.js +16 -0
  101. package/dist/predict/finalizePrediction.d.ts +2 -0
  102. package/dist/predict/finalizePrediction.js +19 -0
  103. package/dist/predict/index.d.ts +5 -0
  104. package/dist/predict/index.js +5 -0
  105. package/dist/predict/makeOctokit.d.ts +2 -0
  106. package/dist/predict/makeOctokit.js +7 -0
  107. package/dist/predict/predict.d.ts +3 -0
  108. package/dist/predict/predict.js +214 -0
  109. package/dist/predict/sourceKey.d.ts +3 -0
  110. package/dist/predict/sourceKey.js +2 -0
  111. package/dist/predict/stackTargetRef.d.ts +14 -0
  112. package/dist/predict/stackTargetRef.js +58 -0
  113. package/dist/triggers/getPrTrigger.d.ts +3 -0
  114. package/dist/triggers/getPrTrigger.js +18 -0
  115. package/dist/triggers/index.d.ts +2 -0
  116. package/dist/triggers/index.js +2 -0
  117. package/dist/triggers/workflowDispatches.d.ts +2 -0
  118. package/dist/triggers/workflowDispatches.js +45 -0
  119. package/dist/{predict.d.ts → types.d.ts} +64 -53
  120. package/dist/types.js +1 -0
  121. package/dist/uses/index.d.ts +1 -0
  122. package/dist/uses/index.js +1 -0
  123. package/dist/uses/parseUses.d.ts +15 -0
  124. package/dist/uses/parseUses.js +33 -0
  125. package/dist/verify.js +1 -1
  126. package/package.json +7 -7
  127. package/dist/expr.d.ts +0 -117
  128. package/dist/expr.js +0 -455
  129. package/dist/predict.js +0 -912
package/dist/predict.js DELETED
@@ -1,912 +0,0 @@
1
- #!/usr/bin/env node
2
- // Predict the set of CI check entries GitHub Actions will create for a PR.
3
- //
4
- // Usage: pnpm predict --repo owner/name --pr N [--json]
5
- // Auth: GH_TOKEN or GITHUB_TOKEN env var (any token with contents/actions/
6
- // pull-requests read). Inside an action, pass the workflow's GITHUB_TOKEN.
7
- //
8
- // Faithful port of predict.py, which was verified entry-for-entry against
9
- // live dispatches on thekevinbot/willrun-probe (PRs 1-7). Check-name
10
- // resolution was verified the same way on probe PR 8, and cross-repo reusable
11
- // workflow calls on probe PR 9; the rules they turned up are pinned in
12
- // src/names.test.ts.
13
- import { Octokit } from "@octokit/rest";
14
- import { parse as parseYaml } from "yaml";
15
- import { makeExecutor, makeTreeProvider, parseGrant, runShell, } from "./execute.js";
16
- import { evaluate, evaluateValue, UNKNOWN } from "./expr.js";
17
- /** Tag a job display name. Rejects the workflow-level sentinel. */
18
- export const jobName = (name) => name;
19
- /** Narrow to the workflow-level variant without inspecting the sentinel. */
20
- export const isWorkflowEntry = (e) => e.job === "*";
21
- /** Narrow to the job-level variant without inspecting the sentinel. */
22
- export const isJobEntry = (e) => e.job !== "*";
23
- const isWorkflowDraft = (e) => e.job === "*";
24
- const finalize = (e) => isWorkflowDraft(e)
25
- ? { ...e, checkName: null }
26
- : { ...e, checkName: e.checkName ?? null };
27
- // ------------------------------------------------- GitHub filter pattern glob
28
- // Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
29
- // preceding char), + (one or more of preceding char), [ranges], leading ! negates.
30
- const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
- export function patternToRegex(pat) {
32
- let out = "";
33
- for (let i = 0; i < pat.length; i++) {
34
- const c = pat[i];
35
- if (c === "*") {
36
- if (pat[i + 1] === "*") {
37
- out += ".*";
38
- i++;
39
- }
40
- else {
41
- out += "[^/]*";
42
- }
43
- }
44
- else if (c === "?" || c === "+") {
45
- out += c;
46
- }
47
- else if (c === "[") {
48
- const j = pat.indexOf("]", i + 1);
49
- out += pat.slice(i, j + 1);
50
- i = j;
51
- }
52
- else if (c === "\\") {
53
- i++;
54
- out += escapeRegex(pat[i]);
55
- }
56
- else {
57
- out += escapeRegex(c);
58
- }
59
- }
60
- return new RegExp(`^${out}$`);
61
- }
62
- /** Order-sensitive match: last matching pattern wins; ! negates. */
63
- export function matchFilters(value, patterns) {
64
- let matched = false;
65
- for (const pat of patterns) {
66
- const neg = pat.startsWith("!");
67
- const p = neg ? pat.slice(1) : pat;
68
- if (patternToRegex(p).test(value))
69
- matched = !neg;
70
- }
71
- return matched;
72
- }
73
- // ------------------------------------------------------------ trigger checks
74
- const SKIP_RE = /\[(skip ci|ci skip|no ci|skip actions|actions skip)\]/i;
75
- const SKIP_TRAILER_RE = /^skip-checks:\s*true/im;
76
- const DEFAULT_TYPES = ["opened", "synchronize", "reopened"];
77
- const MISSING = Symbol("missing");
78
- function getPrTrigger(wf) {
79
- // YAML 1.1 parsers read `on` as boolean true; the `yaml` package (1.2)
80
- // keeps it a string key. Handle both.
81
- const on = wf["on"] ?? wf["true"];
82
- if (on == null)
83
- return MISSING;
84
- if (typeof on === "string")
85
- return on === "pull_request" ? {} : MISSING;
86
- if (Array.isArray(on))
87
- return on.includes("pull_request") ? {} : MISSING;
88
- if (typeof on === "object") {
89
- if ("pull_request" in on)
90
- return on["pull_request"] ?? {};
91
- return MISSING;
92
- }
93
- return MISSING;
94
- }
95
- // A predicate: does this workflow produce a run for the PR? Every workflow-level
96
- // verdict is decidable, so there is no third answer to express. Only job
97
- // expansion can be genuinely undecidable (dynamic matrix, an unreadable
98
- // reusable workflow, unresolvable `if`), and that is a per-entry status.
99
- function workflowDispatches(wf, ctx) {
100
- const trig = getPrTrigger(wf);
101
- if (trig === MISSING)
102
- return [false, "no pull_request trigger"];
103
- const types = trig["types"] ?? DEFAULT_TYPES;
104
- if (!types.includes(ctx.action)) {
105
- return [false, `action '${ctx.action}' not in types [${types}]`];
106
- }
107
- // Setting a filter and its -ignore twin on one trigger is invalid config.
108
- // GitHub does not fall back to "no filter" or skip the workflow: it creates
109
- // the run and concludes `startup_failure`. The run exists, so it dispatches.
110
- if ("branches" in trig && "branches-ignore" in trig) {
111
- return [true, "both branches and branches-ignore set: startup failure"];
112
- }
113
- if ("branches" in trig && !matchFilters(ctx.baseRef, trig["branches"])) {
114
- return [false, `base branch '${ctx.baseRef}' not in branches`];
115
- }
116
- if ("branches-ignore" in trig && matchFilters(ctx.baseRef, trig["branches-ignore"])) {
117
- return [false, "base branch in branches-ignore"];
118
- }
119
- if ("paths" in trig && "paths-ignore" in trig) {
120
- return [true, "both paths and paths-ignore set: startup failure"];
121
- }
122
- if ("paths" in trig && !ctx.files.some((f) => matchFilters(f, trig["paths"]))) {
123
- return [false, "no changed file matches paths"];
124
- }
125
- if ("paths-ignore" in trig && ctx.files.every((f) => matchFilters(f, trig["paths-ignore"]))) {
126
- return [false, "all changed files match paths-ignore"];
127
- }
128
- return [true, "trigger matched"];
129
- }
130
- /**
131
- * The values of one matrix axis, or null when they cannot be known.
132
- *
133
- * A plain list is itself. An axis written as an expression —
134
- * `language: ${{ fromJSON(needs.detect.outputs.coverage_languages) }}` — is
135
- * the values another job computed, and is knowable exactly when the scope
136
- * carries that job's outputs. Anything else stays null, which is what makes
137
- * the whole job `unknown` rather than a guess at how many checks it creates.
138
- */
139
- function axisValues(v, scope) {
140
- if (Array.isArray(v))
141
- return v;
142
- if (typeof v !== "string")
143
- return null;
144
- const val = evaluateValue(v, scope);
145
- if (val.kind !== "json" || !Array.isArray(val.v))
146
- return null;
147
- return val.v;
148
- }
149
- function expandMatrixDetailed(strategy, scope = {}) {
150
- const matrix = strategy?.matrix;
151
- if (matrix == null)
152
- return [null];
153
- // `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
154
- // per-axis form below. It yields include-style entries, not axes, so it is a
155
- // separate expansion and is not modelled.
156
- if (typeof matrix === "string")
157
- return null;
158
- const include = matrix.include ?? [];
159
- const exclude = matrix.exclude ?? [];
160
- if (typeof include === "string" || typeof exclude === "string")
161
- return null;
162
- const axes = {};
163
- for (const [k, v] of Object.entries(matrix)) {
164
- if (k === "include" || k === "exclude")
165
- continue;
166
- const vals = axisValues(v, scope);
167
- if (vals == null)
168
- return null;
169
- axes[k] = vals;
170
- }
171
- const axisKeys = Object.keys(axes);
172
- let combos = [{ values: {}, displayKeys: axisKeys }];
173
- for (const [k, vals] of Object.entries(axes)) {
174
- combos = combos.flatMap((c) => vals.map((v) => ({ values: { ...c.values, [k]: v }, displayKeys: axisKeys })));
175
- }
176
- if (axisKeys.length === 0)
177
- combos = [];
178
- combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c.values[k] === v)));
179
- const extra = [];
180
- for (const inc of include) {
181
- const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
182
- const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c.values[k] === v));
183
- if (axisKeys.length > 0 && targets.length > 0) {
184
- // Merge into the matching combinations. With no overlapping keys this
185
- // matches every combination, per the docs ("added to each of the matrix
186
- // combinations if none of the key:value pairs overwrite any of the
187
- // original matrix values").
188
- for (const c of targets)
189
- Object.assign(c.values, inc);
190
- }
191
- else {
192
- // No combination to attach to: the include entry becomes a combination
193
- // of its own, and every one of its keys shows in the name.
194
- extra.push({ values: { ...inc }, displayKeys: Object.keys(inc) });
195
- }
196
- }
197
- combos.push(...extra);
198
- // Zero combinations is a real answer, not a missing one: an empty axis, or an
199
- // `exclude` that removes everything, schedules no jobs at all. Only an absent
200
- // `matrix:` key means "one unsuffixed job", and that returned above.
201
- return combos;
202
- }
203
- /** Return list of matrix combination dicts, or null if dynamic. */
204
- export function expandMatrix(strategy, scope = {}) {
205
- const detailed = expandMatrixDetailed(strategy, scope);
206
- return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
207
- }
208
- /**
209
- * How a single matrix value is rendered inside a check name.
210
- *
211
- * Probe-verified: object values are flattened to their own values, so
212
- * `cfg: {os: linux, arch: x64}` renders as `linux, x64` — the check is
213
- * `m-object (linux, x64)`.
214
- */
215
- function formatMatrixValue(v) {
216
- if (v == null)
217
- return "";
218
- if (Array.isArray(v))
219
- return v.map(formatMatrixValue).join(", ");
220
- if (typeof v === "object")
221
- return Object.values(v).map(formatMatrixValue).join(", ");
222
- return String(v);
223
- }
224
- /** The ` (v1, v2)` suffix GitHub appends for a matrix combination. */
225
- function matrixSuffix(combo) {
226
- const keys = combo.displayKeys.filter((k) => k in combo.values);
227
- if (keys.length === 0)
228
- return "";
229
- return ` (${keys.map((k) => formatMatrixValue(combo.values[k])).join(", ")})`;
230
- }
231
- /**
232
- * A `name:` that contains any `${{ }}` expression suppresses the matrix
233
- * parenthetical; a literal one does not.
234
- *
235
- * Probe-verified three ways over `a: [x, y]`: `name: Static Label` yields
236
- * `Static Label (x)` / `Static Label (y)`, `name: ev ${{ github.event_name }}`
237
- * yields two checks both called `ev pull_request`, and
238
- * `name: p ${{ matrix.a }}` over `a: [x], b: ["1", "2"]` yields two checks
239
- * both called `p x`. So the trigger is the presence of an expression, not
240
- * whether the expression happens to read the matrix — and duplicate check
241
- * names are a real outcome GitHub allows.
242
- */
243
- const EXPRESSION_RE = /\$\{\{/;
244
- function lookupPath(obj, path) {
245
- let cur = obj;
246
- for (const seg of path.split(".")) {
247
- if (cur == null || typeof cur !== "object" || !(seg in cur))
248
- return undefined;
249
- cur = cur[seg];
250
- }
251
- return cur;
252
- }
253
- function renderName(template, combo) {
254
- let resolved = true;
255
- const text = template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
256
- const expr = String(inner).trim();
257
- if (expr.startsWith("matrix.")) {
258
- if (!combo) {
259
- resolved = false;
260
- return whole;
261
- }
262
- const val = lookupPath(combo, expr.slice("matrix.".length));
263
- if (val === undefined) {
264
- resolved = false;
265
- return whole;
266
- }
267
- return formatMatrixValue(val);
268
- }
269
- // We only predict pull_request dispatch, so this one is knowable.
270
- if (expr === "github.event_name")
271
- return "pull_request";
272
- resolved = false;
273
- return whole;
274
- });
275
- return { text, resolved };
276
- }
277
- /** The check name for one job/combination. */
278
- function jobDisplayName(jobId, job, combo) {
279
- const raw = job != null && job.name != null ? String(job.name) : null;
280
- if (raw === null) {
281
- return { name: jobId + (combo ? matrixSuffix(combo) : ""), resolved: true };
282
- }
283
- const { text, resolved } = renderName(raw, combo?.values ?? null);
284
- const suffix = combo && !EXPRESSION_RE.test(raw) ? matrixSuffix(combo) : "";
285
- return { name: text + suffix, resolved };
286
- }
287
- /**
288
- * The check name a job gets when it is skipped.
289
- *
290
- * A skipped job is never set up, so nothing about it is evaluated: the matrix
291
- * does not expand and `name:` is not interpolated. Probe-verified twice over:
292
- * `if: false` with `a: [x, y]` produces the single check `m-skipped`, not
293
- * `m-skipped (x)` / `m-skipped (y)`; and `name: sk ${{ github.event_name }}`
294
- * with `if: false` produces a check literally called
295
- * `sk ${{ github.event_name }}`, expression text and all. The same collapse
296
- * applies to a skipped reusable-workflow call: one check named after the
297
- * caller, with no `/ <callee job>` entries.
298
- */
299
- function skippedDisplayName(jobId, job) {
300
- const raw = job != null && job.name != null ? String(job.name) : null;
301
- return { name: raw ?? jobId, resolved: true };
302
- }
303
- /**
304
- * The `github.*` values that are fixed for everything this module predicts.
305
- * `predict` only ever answers for a pull request, so `event_name` is not a
306
- * variable — which is what lets a `github.event_name == 'pull_request'` guard
307
- * resolve instead of hanging the job on an unknown.
308
- */
309
- const PR_GITHUB_CONTEXT = { event_name: "pull_request" };
310
- /**
311
- * A scope with the fixed pull-request facts filled in.
312
- *
313
- * Every `${{ }}` this module evaluates — a job `if:`, a matrix axis — is
314
- * evaluated for the same event, so they all get the same `github.*`. Anything
315
- * the caller states wins; there is nothing here worth overriding, but a scope
316
- * that silently ignored what it was handed would be the wrong shape.
317
- */
318
- const prScope = (scope) => ({
319
- ...scope,
320
- github: { ...PR_GITHUB_CONTEXT, ...scope.github },
321
- });
322
- /**
323
- * Return run|skipped|unknown for a job-level `if:`.
324
- *
325
- * `scope` carries the inputs the calling workflow passed down, and any job
326
- * outputs the caller knows. Without it a reusable workflow's guards are all
327
- * unknown, because every one of them is written against `inputs.*` or
328
- * `needs.*`.
329
- */
330
- export function evalIf(cond, scope = {}) {
331
- if (cond == null)
332
- return "run";
333
- const verdict = evaluate(String(cond), prScope(scope));
334
- if (verdict === null)
335
- return "unknown";
336
- return verdict ? "run" : "skipped";
337
- }
338
- /**
339
- * A `with:` value as the callee will see it.
340
- *
341
- * A value carrying `${{ }}` is left unknown rather than evaluated. Resolving
342
- * it would mean evaluating the caller's own expression context, and every
343
- * caller in practice passes plain literals — so the reach that would buy is
344
- * not worth the surface. Unknown here is the same unknown as before this
345
- * existed; nothing regresses.
346
- */
347
- function inputLiteral(raw) {
348
- if (raw == null)
349
- return { kind: "value", v: "" };
350
- if (typeof raw === "boolean" || typeof raw === "number")
351
- return { kind: "value", v: raw };
352
- if (typeof raw === "string")
353
- return raw.includes("${{") ? UNKNOWN : { kind: "value", v: raw };
354
- return UNKNOWN;
355
- }
356
- /** The `on.workflow_call.inputs` block, tolerating the YAML 1.1 `on` -> true key. */
357
- function workflowCallInputs(wf) {
358
- const on = wf?.["on"] ?? wf?.["true"];
359
- if (on == null || typeof on !== "object")
360
- return {};
361
- const call = on["workflow_call"];
362
- if (call == null || typeof call !== "object")
363
- return {};
364
- const inputs = call["inputs"];
365
- return inputs != null && typeof inputs === "object" ? inputs : {};
366
- }
367
- /**
368
- * What `inputs.*` resolves to inside a called workflow: what the caller passed,
369
- * over the defaults the callee declares.
370
- *
371
- * A declared input the caller omits falls back to its `default`. A declared
372
- * input with no default and no caller value is unknown rather than empty — the
373
- * workflow would be invalid if it were required, and guessing empty would
374
- * silently decide guards that are not decided.
375
- */
376
- function calleeInputs(withBlock, subWf) {
377
- const out = {};
378
- for (const [name, decl] of Object.entries(workflowCallInputs(subWf))) {
379
- out[name] =
380
- decl != null && typeof decl === "object" && "default" in decl
381
- ? inputLiteral(decl["default"])
382
- : UNKNOWN;
383
- }
384
- if (withBlock != null && typeof withBlock === "object") {
385
- for (const [name, raw] of Object.entries(withBlock)) {
386
- out[name] = inputLiteral(raw);
387
- }
388
- }
389
- return out;
390
- }
391
- /**
392
- * GitHub allows a reusable-workflow call chain four levels deep. Past that the
393
- * run itself fails, so anything deeper is not a name we could predict anyway.
394
- * Probe-verified to three levels: `call-nested / Mid Call / inner`.
395
- *
396
- * A cross-repo hop costs the same one level as a local one, so a chain that
397
- * mixes the two is counted the same way.
398
- */
399
- const MAX_REUSABLE_DEPTH = 4;
400
- /** `ref` is already a commit id, so resolving it is a no-op. */
401
- const SHA_RE = /^[0-9a-f]{40}$/i;
402
- const isSha = (ref) => SHA_RE.test(ref);
403
- /** Identity of a source as written, before resolution. */
404
- const sourceKey = (s) => `${s.owner}/${s.repo}@${s.ref}`;
405
- /**
406
- * Split a job-level `uses:` into the file it names and the repo it lives in.
407
- *
408
- * Two spellings are legal:
409
- *
410
- * `./.github/workflows/x.yml` -> the caller's repo, same commit
411
- * `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
412
- *
413
- * The ref is taken from the last `@` so a branch containing a slash
414
- * (`@feature/foo`) survives. Returns null for anything else — including a
415
- * reference built from an expression, which we cannot evaluate and so must not
416
- * guess a fetch target for.
417
- */
418
- export function parseUses(uses) {
419
- if (EXPRESSION_RE.test(uses))
420
- return null;
421
- if (uses.startsWith("./")) {
422
- const path = uses.slice(2);
423
- return path === "" ? null : { path, source: null };
424
- }
425
- const at = uses.lastIndexOf("@");
426
- if (at <= 0)
427
- return null;
428
- const ref = uses.slice(at + 1);
429
- if (ref === "")
430
- return null;
431
- const [owner, repo, ...rest] = uses.slice(0, at).split("/");
432
- const path = rest.join("/");
433
- if (!owner || !repo || path === "")
434
- return null;
435
- return { path, source: { owner, repo, ref } };
436
- }
437
- async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}, executor) {
438
- const entries = [];
439
- const jobs = wf.jobs ?? {};
440
- const statuses = {};
441
- // Execute what the caller granted, before anything reads `needs`. The
442
- // grant names the repo the workflow *file* lives in, so a granted callee
443
- // job fires here in the recursion, where `source` is that repo. The guard
444
- // is the same `evalIf` the main loop applies — same scope, same verdict —
445
- // so a job is executed exactly when it is predicted to run. A job that
446
- // would not run is not executed; a job that fails to execute contributes
447
- // nothing but its reason, which `execNote` threads into the entries that
448
- // needed it.
449
- let scoped = scope;
450
- const execFailures = {};
451
- if (executor != null) {
452
- for (const [jobId, jobRaw] of Object.entries(jobs)) {
453
- if (!executor.granted(source, jobId))
454
- continue;
455
- const job = jobRaw ?? {};
456
- if (evalIf(job.if, scoped) !== "run")
457
- continue;
458
- const res = await executor.executeJob(jobId, job, wf, scoped);
459
- if (res.ok) {
460
- scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
461
- }
462
- else {
463
- execFailures[jobId] = res.reason;
464
- }
465
- }
466
- }
467
- const execNote = (needs) => {
468
- const failed = needs.find((n) => n in execFailures);
469
- return failed == null ? "" : `; executing '${failed}' failed: ${execFailures[failed]}`;
470
- };
471
- for (const [jobId, jobRaw] of Object.entries(jobs)) {
472
- const job = jobRaw ?? {};
473
- let status = evalIf(job.if, scoped);
474
- let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
475
- let needs = job.needs ?? [];
476
- if (typeof needs === "string")
477
- needs = [needs];
478
- const cond = String(job.if ?? "");
479
- if (status !== "skipped" && !cond.includes("always()")) {
480
- for (const n of needs) {
481
- if (statuses[n] === "skipped") {
482
- status = "skipped";
483
- reason = `needs '${n}' which is skipped`;
484
- }
485
- else if (statuses[n] === "unknown" && status === "run") {
486
- status = "unknown";
487
- reason = `needs '${n}' whose status is unknown`;
488
- }
489
- }
490
- }
491
- statuses[jobId] = status;
492
- // A skipped job never expands its matrix and never dispatches a called
493
- // workflow: it collapses to a single check under the bare job name.
494
- if (status === "skipped") {
495
- const disp = skippedDisplayName(jobId, job);
496
- const name = prefix + disp.name;
497
- entries.push({
498
- job: name,
499
- checkName: prefixResolved && disp.resolved ? name : null,
500
- status,
501
- reason,
502
- });
503
- continue;
504
- }
505
- const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
506
- if ("uses" in job) {
507
- // Reusable workflow call. The calling job produces no check of its own;
508
- // each called job becomes `<calling job name> / <called job name>`, and
509
- // a matrix on the *caller* multiplies the whole callee set. A cross-repo
510
- // call names its checks exactly the same way a local one does — probe
511
- // PR #9, `call-remote-tag / r-inner` alongside `call-plain / inner`.
512
- const uses = job.uses;
513
- if (combos == null) {
514
- entries.push({
515
- job: prefix + jobId,
516
- checkName: null,
517
- status: "unknown",
518
- reason: "dynamic matrix on reusable workflow call" + execNote(needs),
519
- });
520
- continue;
521
- }
522
- // Resolve the called workflow once, not once per matrix combination.
523
- let subWf = null;
524
- let failure = null;
525
- // Where the callee's own `./` calls will resolve. A remote `uses:` moves
526
- // this to the callee's repo and pinned ref; a local one leaves it alone.
527
- let subSource = source;
528
- // What `inputs.*` means on the other side of the call.
529
- let subScope = {};
530
- const target = parseUses(uses);
531
- if (depth + 1 > MAX_REUSABLE_DEPTH) {
532
- failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
533
- }
534
- else if (target == null) {
535
- failure = `unresolvable reusable reference: ${uses}`;
536
- }
537
- else {
538
- // A local `./` call stays on the caller's source, which is already
539
- // pinned to a commit. A cross-repo one arrives as whatever the `uses:`
540
- // string spelled — `@v0` — and has to be resolved before anything is
541
- // read from it, so the file that gets read and the commit the
542
- // prediction names are the same one.
543
- let resolved = source;
544
- if (target.source != null) {
545
- const { ref } = target.source;
546
- const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
547
- resolved = sha == null ? null : { ...target.source, sha };
548
- }
549
- if (resolved == null) {
550
- failure = `cannot resolve ref for ${uses}`;
551
- }
552
- else {
553
- subSource = resolved;
554
- const content = await reader.fetchWorkflow(target.path, subSource);
555
- if (content == null) {
556
- failure = `cannot fetch ${uses}`;
557
- }
558
- else {
559
- try {
560
- subWf = parseYaml(content);
561
- // `inputs.*` changes at the call boundary; `github.*` does not.
562
- // A callee's jobs run in the caller's repo, so the facts seeded
563
- // at the top of the prediction stay true all the way down.
564
- subScope = { inputs: calleeInputs(job.with, subWf ?? {}), github: scoped.github };
565
- }
566
- catch (e) {
567
- failure = `YAML parse error in ${uses}: ${e}`;
568
- }
569
- }
570
- }
571
- }
572
- for (const combo of combos) {
573
- const disp = jobDisplayName(jobId, job, combo);
574
- const baseName = prefix + disp.name;
575
- const nameResolved = prefixResolved && disp.resolved;
576
- if (failure != null || subWf == null) {
577
- entries.push({
578
- job: baseName,
579
- checkName: null,
580
- status: "unknown",
581
- reason: failure ?? `cannot resolve ${uses}`,
582
- });
583
- continue;
584
- }
585
- entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope, executor)));
586
- }
587
- continue;
588
- }
589
- if (combos == null) {
590
- entries.push({
591
- job: prefix + jobId,
592
- checkName: null,
593
- status: "unknown",
594
- reason: "dynamic matrix" + execNote(needs),
595
- });
596
- continue;
597
- }
598
- for (const combo of combos) {
599
- const disp = jobDisplayName(jobId, job, combo);
600
- const name = prefix + disp.name;
601
- entries.push({
602
- job: name,
603
- checkName: prefixResolved && disp.resolved ? name : null,
604
- status,
605
- reason,
606
- });
607
- }
608
- }
609
- return entries;
610
- }
611
- /**
612
- * Expand one already-parsed workflow into its job entries. Exported so check
613
- * names can be tested against recorded GitHub behaviour without a network
614
- * round-trip; `predict` is the API you want.
615
- *
616
- * `scope` seeds what this workflow's own `${{ }}` resolve against — notably
617
- * `needs`, the outputs of jobs that have not run. Nothing here works out what
618
- * those are; a caller that knows hands them in, and a caller that does not
619
- * leaves them out and gets `unknown` where they would have been used.
620
- */
621
- export function expandWorkflowJobs(wf, ctx, reader, source, scope = {}, executor) {
622
- return expandJobs(wf, ctx, reader, source, 0, "", true, scope, executor);
623
- }
624
- // ------------------------------------------------------------------- pipeline
625
- export function makeOctokit() {
626
- const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
627
- if (!token)
628
- throw new Error("GH_TOKEN or GITHUB_TOKEN must be set");
629
- return new Octokit({ auth: token });
630
- }
631
- export async function predict(octokit, repo, prNumber, opts = {}) {
632
- const [owner, name] = repo.split("/");
633
- const base = { owner, repo: name };
634
- const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
635
- const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
636
- ...base,
637
- pull_number: prNumber,
638
- per_page: 100,
639
- });
640
- const ctx = {
641
- // The caller's answer wins whenever it has one. The commit-count fallback
642
- // is a guess kept only so existing callers keep working.
643
- action: opts.action ?? (pr.commits > 1 ? "synchronize" : "opened"),
644
- baseRef: pr.base.ref,
645
- files: files.map((f) => f.filename),
646
- };
647
- const headSha = pr.head.sha;
648
- /**
649
- * The PR's own repo at the head commit — where expansion starts, and already
650
- * a commit id, so its `ref` and `sha` are the same string.
651
- */
652
- const headSource = { owner, repo: name, ref: headSha, sha: headSha };
653
- // Provenance for the answer, filled as expansion reaches each source. The head
654
- // is in from the start: it is read even on the skip path, where the commit
655
- // message is what decides the verdict.
656
- const sources = new Map([[sourceKey(headSource), headSource]]);
657
- const { data: headCommit } = await octokit.rest.repos.getCommit({
658
- ...base,
659
- ref: headSha,
660
- });
661
- const headMsg = headCommit.commit.message;
662
- if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
663
- return finalizePrediction([], "head commit message contains a skip instruction", sources);
664
- }
665
- // A `uses:` naming a tag is the same lookup from every caller that writes it,
666
- // so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
667
- // cannot be resolved will not start resolving on the second ask.
668
- const refCache = new Map();
669
- const resolveRef = async (src) => {
670
- const key = sourceKey(src);
671
- const hit = refCache.get(key);
672
- if (hit !== undefined)
673
- return hit;
674
- let sha;
675
- try {
676
- const { data } = await octokit.rest.repos.getCommit({
677
- owner: src.owner,
678
- repo: src.repo,
679
- ref: src.ref,
680
- });
681
- sha = data.sha;
682
- }
683
- catch {
684
- // Deleted tag, private repo, rate limit, network: all one answer here.
685
- // The caller turns it into an `unknown` entry rather than throwing.
686
- sha = null;
687
- }
688
- refCache.set(key, sha);
689
- if (sha != null)
690
- sources.set(key, { ...src, sha });
691
- return sha;
692
- };
693
- // One callee is commonly reached from several callers — a fleet repo calls
694
- // the same `testing-conventions@v0` from eight workflows — so remember what
695
- // each `owner/repo/path@sha` resolved to, misses included.
696
- const cache = new Map();
697
- const fetchWorkflow = async (path, src) => {
698
- // Keyed and fetched on the commit, never the ref that named it. Two callers
699
- // writing `@v0` and `@abc123` for the same commit are one read, and a tag
700
- // that moves mid-prediction cannot hand back two different files.
701
- const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
702
- const hit = cache.get(key);
703
- if (hit !== undefined)
704
- return hit;
705
- let content;
706
- try {
707
- const { data } = await octokit.rest.repos.getContent({
708
- owner: src.owner,
709
- repo: src.repo,
710
- path,
711
- ref: src.sha,
712
- mediaType: { format: "raw" },
713
- });
714
- content = data;
715
- }
716
- catch {
717
- // Private, deleted, bad ref, rate limit, network: all one answer here.
718
- // The caller turns it into an `unknown` entry rather than throwing.
719
- content = null;
720
- }
721
- cache.set(key, content);
722
- return content;
723
- };
724
- const reader = { fetchWorkflow, resolveRef };
725
- // The executor exists only when the caller granted something. Trees come
726
- // from the tarball endpoint at the resolved commit, and every subprocess —
727
- // `tar` included — goes through the one `runShell` seam.
728
- let executor;
729
- if (opts.execute != null && opts.execute.length > 0) {
730
- const download = async (src) => {
731
- try {
732
- const { data } = await octokit.rest.repos.downloadTarballArchive({
733
- owner: src.owner,
734
- repo: src.repo,
735
- ref: src.sha,
736
- });
737
- return new Uint8Array(data);
738
- }
739
- catch {
740
- // Private, deleted, rate limit, network: one answer, and the entries
741
- // behind it stay unresolved with the failure named.
742
- return null;
743
- }
744
- };
745
- executor = makeExecutor({
746
- grants: opts.execute,
747
- workspace: headSource,
748
- deps: {
749
- provideTree: makeTreeProvider(download, runShell),
750
- runCommand: runShell,
751
- resolveRef,
752
- },
753
- });
754
- }
755
- const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
756
- ...base,
757
- per_page: 100,
758
- });
759
- // `github.repository` is fixed for everything predicted here: reusable
760
- // workflows and composite actions all run in the repo the PR is against.
761
- // Seeding it once makes guards like the fleet's hermetic-vs-published
762
- // `github.repository ==` checks decidable everywhere, granted or not.
763
- const prFacts = {
764
- github: { repository: `${headSource.owner}/${headSource.repo}` },
765
- };
766
- const entries = [];
767
- for (const w of workflows) {
768
- const path = w.path;
769
- if (!path.startsWith(".github/workflows/"))
770
- continue;
771
- if (w.state !== "active") {
772
- entries.push({
773
- workflow: path,
774
- job: "*",
775
- status: "no-dispatch",
776
- reason: `workflow state: ${w.state}`,
777
- });
778
- continue;
779
- }
780
- const content = await fetchWorkflow(path, headSource);
781
- if (content == null) {
782
- // The Actions API keeps listing a workflow as `active` after its file is
783
- // deleted. There is no file at head, so there is nothing to dispatch —
784
- // the same verdict as the disabled case above, reached a different way.
785
- entries.push({
786
- workflow: path,
787
- job: "*",
788
- status: "no-dispatch",
789
- reason: "no workflow file at head",
790
- });
791
- continue;
792
- }
793
- let wf;
794
- try {
795
- wf = parseYaml(content);
796
- }
797
- catch (e) {
798
- // GitHub creates a run for an unparseable workflow file and concludes it
799
- // `startup_failure`. The run exists but has no jobs, so this is a
800
- // workflow-level "it dispatches" with nothing to expand.
801
- entries.push({
802
- workflow: path,
803
- job: "*",
804
- status: "run",
805
- reason: `YAML parse error: ${e}`,
806
- });
807
- continue;
808
- }
809
- const [dispatches, reason] = workflowDispatches(wf, ctx);
810
- if (!dispatches) {
811
- entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
812
- continue;
813
- }
814
- for (const j of await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor)) {
815
- entries.push({
816
- workflow: path,
817
- job: jobName(j.job),
818
- checkName: j.checkName,
819
- status: j.status,
820
- reason: j.reason || reason,
821
- });
822
- }
823
- }
824
- return finalizePrediction(entries, null, sources);
825
- }
826
- function finalizePrediction(entries, skip, sources) {
827
- const final = entries.map(finalize);
828
- const names = new Set();
829
- for (const e of final) {
830
- if (e.status === "run" && e.checkName != null)
831
- names.add(e.checkName);
832
- }
833
- return {
834
- entries: final,
835
- checkNames: [...names].sort(),
836
- skip,
837
- sources: [...sources.values()].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))),
838
- };
839
- }
840
- // ------------------------------------------------------------------------ CLI
841
- const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened]" +
842
- " [--execute owner/repo:job1,job2]... [--json]";
843
- const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
844
- function parseArgs(argv) {
845
- const get = (flag) => {
846
- const i = argv.indexOf(flag);
847
- return i >= 0 ? argv[i + 1] : undefined;
848
- };
849
- const repo = get("--repo");
850
- const pr = get("--pr");
851
- if (!repo || !pr) {
852
- console.error(USAGE);
853
- process.exit(2);
854
- }
855
- // An unrecognised action is refused rather than ignored. Silently falling
856
- // back to the guess would turn a typo into a wrong prediction, which is the
857
- // failure this flag exists to remove.
858
- const action = get("--action");
859
- if (action !== undefined && !isPrEventAction(action)) {
860
- console.error(`unknown --action: ${action}`);
861
- console.error(USAGE);
862
- process.exit(2);
863
- }
864
- // Repeatable, one grant per flag. A malformed grant is refused for the same
865
- // reason a bad --action is: silently dropping it would predict without the
866
- // execution the caller thought they asked for.
867
- const execute = [];
868
- for (let i = 0; i < argv.length; i++) {
869
- if (argv[i] !== "--execute")
870
- continue;
871
- const spec = argv[i + 1];
872
- const grant = spec == null ? null : parseGrant(spec);
873
- if (grant == null) {
874
- console.error(`bad --execute: ${spec}`);
875
- console.error(USAGE);
876
- process.exit(2);
877
- }
878
- execute.push(grant);
879
- }
880
- return { repo, pr: Number(pr), json: argv.includes("--json"), action, execute };
881
- }
882
- const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
883
- if (isMain) {
884
- const args = parseArgs(process.argv.slice(2));
885
- const prediction = await predict(makeOctokit(), args.repo, args.pr, {
886
- action: args.action,
887
- execute: args.execute,
888
- });
889
- const { entries, skip, sources } = prediction;
890
- if (args.json) {
891
- console.log(JSON.stringify(prediction, null, 2));
892
- }
893
- else {
894
- if (skip) {
895
- console.log(`# ${skip} -> nothing dispatches`);
896
- }
897
- else {
898
- for (const e of entries) {
899
- if (isWorkflowEntry(e))
900
- console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
901
- else {
902
- const name = e.checkName ?? `${e.job} (name unresolved)`;
903
- console.log(`${e.workflow} :: ${name} :: ${e.status}`);
904
- }
905
- }
906
- }
907
- // Last, and on the skip path too, so a red gate's first question — which
908
- // commits was this read from? — is answered wherever the reader lands.
909
- for (const s of sources)
910
- console.log(`# read ${s.owner}/${s.repo}@${s.ref} -> ${s.sha}`);
911
- }
912
- }