willfire 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -6
- package/dist/predict.d.ts +42 -0
- package/dist/predict.js +228 -49
- package/dist/verify.js +15 -2
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -20,20 +20,36 @@ pnpm add willfire
|
|
|
20
20
|
import { predict } from "willfire";
|
|
21
21
|
import { getOctokit } from "@actions/github"; // or new Octokit({ auth: token })
|
|
22
22
|
|
|
23
|
-
const { entries, skip } = await predict(getOctokit(token), "owner/repo", 123);
|
|
23
|
+
const { entries, checkNames, skip } = await predict(getOctokit(token), "owner/repo", 123);
|
|
24
|
+
// checkNames: sorted, deduped checkName of every entry with status "run"
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
`entries` is a union of two variants, both carrying `workflow` and `reason`:
|
|
27
28
|
|
|
28
|
-
| variant | `job` | `status` |
|
|
29
|
-
| --- | --- | --- |
|
|
30
|
-
| `WorkflowEntry` | `"*"` | `"run" \| "skipped" \| "no-dispatch"` |
|
|
31
|
-
| `JobEntry` | the job
|
|
29
|
+
| variant | `job` | `checkName` | `status` |
|
|
30
|
+
| --- | --- | --- | --- |
|
|
31
|
+
| `WorkflowEntry` | `"*"` | always `null` | `"run" \| "skipped" \| "no-dispatch"` |
|
|
32
|
+
| `JobEntry` | the job id | the check name, or `null` | `"run" \| "skipped" \| "unknown" \| "no-dispatch"` |
|
|
32
33
|
|
|
33
34
|
`"unknown"` is job-level only: every workflow-level verdict is decidable, so a
|
|
34
35
|
`WorkflowEntry` cannot express one. Narrow with the exported `isWorkflowEntry`
|
|
35
36
|
and `isJobEntry` guards rather than testing the `"*"` sentinel yourself.
|
|
36
37
|
|
|
38
|
+
`checkName` is the name GitHub actually puts on the check — `name:` override,
|
|
39
|
+
matrix parenthetical, and `<caller> / <callee>` prefixing for reusable
|
|
40
|
+
workflows all applied. That is the unit required status checks key on, so it
|
|
41
|
+
is the one worth comparing against. On a `JobEntry` it is `null` only where no
|
|
42
|
+
single name is knowable ahead of the run:
|
|
43
|
+
|
|
44
|
+
- a matrix computed at runtime (`fromJSON` of another job's output), reported
|
|
45
|
+
as one `unknown` entry for that job and nothing else;
|
|
46
|
+
- a non-local reusable workflow, whose callee jobs we do not fetch;
|
|
47
|
+
- a `name:` interpolating something we cannot evaluate statically.
|
|
48
|
+
|
|
49
|
+
Duplicate names in `checkNames` are not possible (it is a set), but duplicate
|
|
50
|
+
check names *are* — GitHub happily creates two identically named checks when a
|
|
51
|
+
matrix job's `name:` does not vary per combination. `entries` shows them.
|
|
52
|
+
|
|
37
53
|
Auth is any token with `contents: read`, `actions: read`, and
|
|
38
54
|
`pull-requests: read` — inside an action, the workflow's `GITHUB_TOKEN`.
|
|
39
55
|
|
|
@@ -64,6 +80,23 @@ workflow per dispatch rule, and probe PRs exercise each complication
|
|
|
64
80
|
non-default branches). The `verify` script diffs predictions against the
|
|
65
81
|
check entries GitHub actually created. All probe PRs currently pass exactly.
|
|
66
82
|
|
|
83
|
+
Check-name resolution is verified the same way, on probe PR #8. `pnpm test`
|
|
84
|
+
replays those probe workflows through the resolver and asserts the exact job
|
|
85
|
+
names the live run produced. Several of the rules it pins are ones the docs do
|
|
86
|
+
not state:
|
|
87
|
+
|
|
88
|
+
- a `name:` containing *any* `${{ }}` expression suppresses the matrix
|
|
89
|
+
parenthetical, even an expression that never reads the matrix — a literal
|
|
90
|
+
`name:` still gets one, so `name: Static Label` over `a: [x, y]` yields
|
|
91
|
+
`Static Label (x)` and `Static Label (y)`;
|
|
92
|
+
- keys an `include` entry merges into an existing combination do not appear in
|
|
93
|
+
the name, but keys of a combination the `include` created from scratch do;
|
|
94
|
+
- object matrix values flatten to their own values, so `{os: linux, arch: x64}`
|
|
95
|
+
renders as `(linux, x64)`;
|
|
96
|
+
- a skipped job is never set up, so its matrix does not expand and its `name:`
|
|
97
|
+
is not interpolated: one check, expression text and all.
|
|
98
|
+
|
|
67
99
|
Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
|
|
68
100
|
live events, cross-repo reusable workflows, `branches-ignore`, and diffs far
|
|
69
|
-
beyond 301 files are not yet probe-verified.
|
|
101
|
+
beyond 301 files are not yet probe-verified. Reusable-workflow name prefixing
|
|
102
|
+
is probe-verified to three levels; deeper nesting is inferred.
|
package/dist/predict.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export declare const jobName: <S extends string>(name: S extends "*" ? never : S
|
|
|
30
30
|
*/
|
|
31
31
|
export interface WorkflowEntry extends EntryBase {
|
|
32
32
|
job: "*";
|
|
33
|
+
/** Always null: a workflow-level verdict is about the run, not a named check. */
|
|
34
|
+
checkName: null;
|
|
33
35
|
status: "run" | "skipped" | "no-dispatch";
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
@@ -41,6 +43,16 @@ export interface WorkflowEntry extends EntryBase {
|
|
|
41
43
|
*/
|
|
42
44
|
export interface JobEntry extends EntryBase {
|
|
43
45
|
job: JobName;
|
|
46
|
+
/**
|
|
47
|
+
* The check name GitHub will create for this entry, resolved the way the
|
|
48
|
+
* Actions runner names jobs: `name:` override, matrix parenthetical, and
|
|
49
|
+
* `<caller> / <callee>` prefixing for reusable workflows.
|
|
50
|
+
*
|
|
51
|
+
* null when there is no single statically-knowable name: a matrix computed
|
|
52
|
+
* at runtime, a non-local reusable workflow, or a `name:` that interpolates
|
|
53
|
+
* something we cannot evaluate ahead of the run.
|
|
54
|
+
*/
|
|
55
|
+
checkName: string | null;
|
|
44
56
|
status: "run" | "skipped" | "unknown" | "no-dispatch";
|
|
45
57
|
}
|
|
46
58
|
export type Entry = WorkflowEntry | JobEntry;
|
|
@@ -50,16 +62,46 @@ export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
|
|
|
50
62
|
export declare const isJobEntry: (e: Entry) => e is JobEntry;
|
|
51
63
|
export interface Prediction {
|
|
52
64
|
entries: Entry[];
|
|
65
|
+
/**
|
|
66
|
+
* Convenience aggregate: the sorted, deduplicated check names of every
|
|
67
|
+
* entry with status "run" and a resolved name. Entries whose name could
|
|
68
|
+
* not be resolved are absent here — read `entries` to see them.
|
|
69
|
+
*/
|
|
70
|
+
checkNames: string[];
|
|
53
71
|
skip: string | null;
|
|
54
72
|
}
|
|
55
73
|
export declare function patternToRegex(pat: string): RegExp;
|
|
56
74
|
/** Order-sensitive match: last matching pattern wins; ! negates. */
|
|
57
75
|
export declare function matchFilters(value: string, patterns: string[]): boolean;
|
|
76
|
+
export interface Ctx {
|
|
77
|
+
action: string;
|
|
78
|
+
baseRef: string;
|
|
79
|
+
files: string[];
|
|
80
|
+
}
|
|
81
|
+
type Workflow = Record<string, any>;
|
|
58
82
|
type Combo = Record<string, any> | null;
|
|
59
83
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
60
84
|
export declare function expandMatrix(strategy: any): Combo[] | null;
|
|
61
85
|
/** Return run|skipped|unknown for a job-level if. */
|
|
62
86
|
export declare function evalIf(cond: any): "run" | "skipped" | "unknown";
|
|
87
|
+
/**
|
|
88
|
+
* One expanded job, before it is paired with its workflow. Named `ExpandedJob`
|
|
89
|
+
* because `JobEntry` is now the exported job-level variant of `Entry`.
|
|
90
|
+
*/
|
|
91
|
+
export interface ExpandedJob {
|
|
92
|
+
/** The job id this entry was built from. */
|
|
93
|
+
job: string;
|
|
94
|
+
/** The resolved check name, or null when it could not be settled statically. */
|
|
95
|
+
checkName: string | null;
|
|
96
|
+
status: "run" | "skipped" | "unknown";
|
|
97
|
+
reason: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Expand one already-parsed workflow into its job entries. Exported so check
|
|
101
|
+
* names can be tested against recorded GitHub behaviour without a network
|
|
102
|
+
* round-trip; `predict` is the API you want.
|
|
103
|
+
*/
|
|
104
|
+
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, fetchFile: (path: string) => Promise<string | null>): Promise<ExpandedJob[]>;
|
|
63
105
|
export declare function makeOctokit(): Octokit;
|
|
64
106
|
export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
|
|
65
107
|
export {};
|
package/dist/predict.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// pull-requests read). Inside an action, pass the workflow's GITHUB_TOKEN.
|
|
7
7
|
//
|
|
8
8
|
// Faithful port of predict.py, which was verified entry-for-entry against
|
|
9
|
-
// live dispatches on thekevinbot/willrun-probe (PRs 1-7).
|
|
9
|
+
// live dispatches on thekevinbot/willrun-probe (PRs 1-7). Check-name
|
|
10
|
+
// resolution was verified the same way on probe PR 8; the rules it turned up
|
|
11
|
+
// are pinned in src/names.test.ts.
|
|
10
12
|
import { Octokit } from "@octokit/rest";
|
|
11
13
|
import { parse as parseYaml } from "yaml";
|
|
12
14
|
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
@@ -15,6 +17,10 @@ export const jobName = (name) => name;
|
|
|
15
17
|
export const isWorkflowEntry = (e) => e.job === "*";
|
|
16
18
|
/** Narrow to the job-level variant without inspecting the sentinel. */
|
|
17
19
|
export const isJobEntry = (e) => e.job !== "*";
|
|
20
|
+
const isWorkflowDraft = (e) => e.job === "*";
|
|
21
|
+
const finalize = (e) => isWorkflowDraft(e)
|
|
22
|
+
? { ...e, checkName: null }
|
|
23
|
+
: { ...e, checkName: e.checkName ?? null };
|
|
18
24
|
// ------------------------------------------------- GitHub filter pattern glob
|
|
19
25
|
// Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
|
|
20
26
|
// preceding char), + (one or more of preceding char), [ranges], leading ! negates.
|
|
@@ -118,8 +124,7 @@ function workflowDispatches(wf, ctx) {
|
|
|
118
124
|
}
|
|
119
125
|
return [true, "trigger matched"];
|
|
120
126
|
}
|
|
121
|
-
|
|
122
|
-
export function expandMatrix(strategy) {
|
|
127
|
+
function expandMatrixDetailed(strategy) {
|
|
123
128
|
const matrix = strategy?.matrix;
|
|
124
129
|
if (matrix == null)
|
|
125
130
|
return [null];
|
|
@@ -137,44 +142,134 @@ export function expandMatrix(strategy) {
|
|
|
137
142
|
return null;
|
|
138
143
|
axes[k] = v;
|
|
139
144
|
}
|
|
140
|
-
|
|
145
|
+
const axisKeys = Object.keys(axes);
|
|
146
|
+
let combos = [{ values: {}, displayKeys: axisKeys }];
|
|
141
147
|
for (const [k, vals] of Object.entries(axes)) {
|
|
142
|
-
combos = combos.flatMap((c) => vals.map((v) => ({ ...c, [k]: v })));
|
|
148
|
+
combos = combos.flatMap((c) => vals.map((v) => ({ values: { ...c.values, [k]: v }, displayKeys: axisKeys })));
|
|
143
149
|
}
|
|
144
|
-
if (
|
|
150
|
+
if (axisKeys.length === 0)
|
|
145
151
|
combos = [];
|
|
146
|
-
combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c[k] === v)));
|
|
152
|
+
combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c.values[k] === v)));
|
|
147
153
|
const extra = [];
|
|
148
154
|
for (const inc of include) {
|
|
149
155
|
const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
|
|
150
|
-
const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c[k] === v));
|
|
151
|
-
if (
|
|
156
|
+
const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c.values[k] === v));
|
|
157
|
+
if (axisKeys.length > 0 && targets.length > 0) {
|
|
158
|
+
// Merge into the matching combinations. With no overlapping keys this
|
|
159
|
+
// matches every combination, per the docs ("added to each of the matrix
|
|
160
|
+
// combinations if none of the key:value pairs overwrite any of the
|
|
161
|
+
// original matrix values").
|
|
152
162
|
for (const c of targets)
|
|
153
|
-
Object.assign(c, inc);
|
|
163
|
+
Object.assign(c.values, inc);
|
|
154
164
|
}
|
|
155
165
|
else {
|
|
156
|
-
|
|
166
|
+
// No combination to attach to: the include entry becomes a combination
|
|
167
|
+
// of its own, and every one of its keys shows in the name.
|
|
168
|
+
extra.push({ values: { ...inc }, displayKeys: Object.keys(inc) });
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
171
|
combos.push(...extra);
|
|
160
172
|
return combos.length > 0 ? combos : [null];
|
|
161
173
|
}
|
|
174
|
+
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
175
|
+
export function expandMatrix(strategy) {
|
|
176
|
+
const detailed = expandMatrixDetailed(strategy);
|
|
177
|
+
return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* How a single matrix value is rendered inside a check name.
|
|
181
|
+
*
|
|
182
|
+
* Probe-verified: object values are flattened to their own values, so
|
|
183
|
+
* `cfg: {os: linux, arch: x64}` renders as `linux, x64` — the check is
|
|
184
|
+
* `m-object (linux, x64)`.
|
|
185
|
+
*/
|
|
186
|
+
function formatMatrixValue(v) {
|
|
187
|
+
if (v == null)
|
|
188
|
+
return "";
|
|
189
|
+
if (Array.isArray(v))
|
|
190
|
+
return v.map(formatMatrixValue).join(", ");
|
|
191
|
+
if (typeof v === "object")
|
|
192
|
+
return Object.values(v).map(formatMatrixValue).join(", ");
|
|
193
|
+
return String(v);
|
|
194
|
+
}
|
|
195
|
+
/** The ` (v1, v2)` suffix GitHub appends for a matrix combination. */
|
|
196
|
+
function matrixSuffix(combo) {
|
|
197
|
+
const keys = combo.displayKeys.filter((k) => k in combo.values);
|
|
198
|
+
if (keys.length === 0)
|
|
199
|
+
return "";
|
|
200
|
+
return ` (${keys.map((k) => formatMatrixValue(combo.values[k])).join(", ")})`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A `name:` that contains any `${{ }}` expression suppresses the matrix
|
|
204
|
+
* parenthetical; a literal one does not.
|
|
205
|
+
*
|
|
206
|
+
* Probe-verified three ways over `a: [x, y]`: `name: Static Label` yields
|
|
207
|
+
* `Static Label (x)` / `Static Label (y)`, `name: ev ${{ github.event_name }}`
|
|
208
|
+
* yields two checks both called `ev pull_request`, and
|
|
209
|
+
* `name: p ${{ matrix.a }}` over `a: [x], b: ["1", "2"]` yields two checks
|
|
210
|
+
* both called `p x`. So the trigger is the presence of an expression, not
|
|
211
|
+
* whether the expression happens to read the matrix — and duplicate check
|
|
212
|
+
* names are a real outcome GitHub allows.
|
|
213
|
+
*/
|
|
214
|
+
const EXPRESSION_RE = /\$\{\{/;
|
|
215
|
+
function lookupPath(obj, path) {
|
|
216
|
+
let cur = obj;
|
|
217
|
+
for (const seg of path.split(".")) {
|
|
218
|
+
if (cur == null || typeof cur !== "object" || !(seg in cur))
|
|
219
|
+
return undefined;
|
|
220
|
+
cur = cur[seg];
|
|
221
|
+
}
|
|
222
|
+
return cur;
|
|
223
|
+
}
|
|
162
224
|
function renderName(template, combo) {
|
|
163
|
-
|
|
225
|
+
let resolved = true;
|
|
226
|
+
const text = template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
|
|
164
227
|
const expr = String(inner).trim();
|
|
165
|
-
if (expr.startsWith("matrix.")
|
|
166
|
-
|
|
228
|
+
if (expr.startsWith("matrix.")) {
|
|
229
|
+
if (!combo) {
|
|
230
|
+
resolved = false;
|
|
231
|
+
return whole;
|
|
232
|
+
}
|
|
233
|
+
const val = lookupPath(combo, expr.slice("matrix.".length));
|
|
234
|
+
if (val === undefined) {
|
|
235
|
+
resolved = false;
|
|
236
|
+
return whole;
|
|
237
|
+
}
|
|
238
|
+
return formatMatrixValue(val);
|
|
167
239
|
}
|
|
240
|
+
// We only predict pull_request dispatch, so this one is knowable.
|
|
241
|
+
if (expr === "github.event_name")
|
|
242
|
+
return "pull_request";
|
|
243
|
+
resolved = false;
|
|
168
244
|
return whole;
|
|
169
245
|
});
|
|
246
|
+
return { text, resolved };
|
|
170
247
|
}
|
|
248
|
+
/** The check name for one job/combination. */
|
|
171
249
|
function jobDisplayName(jobId, job, combo) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
250
|
+
const raw = job != null && job.name != null ? String(job.name) : null;
|
|
251
|
+
if (raw === null) {
|
|
252
|
+
return { name: jobId + (combo ? matrixSuffix(combo) : ""), resolved: true };
|
|
253
|
+
}
|
|
254
|
+
const { text, resolved } = renderName(raw, combo?.values ?? null);
|
|
255
|
+
const suffix = combo && !EXPRESSION_RE.test(raw) ? matrixSuffix(combo) : "";
|
|
256
|
+
return { name: text + suffix, resolved };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The check name a job gets when it is skipped.
|
|
260
|
+
*
|
|
261
|
+
* A skipped job is never set up, so nothing about it is evaluated: the matrix
|
|
262
|
+
* does not expand and `name:` is not interpolated. Probe-verified twice over:
|
|
263
|
+
* `if: false` with `a: [x, y]` produces the single check `m-skipped`, not
|
|
264
|
+
* `m-skipped (x)` / `m-skipped (y)`; and `name: sk ${{ github.event_name }}`
|
|
265
|
+
* with `if: false` produces a check literally called
|
|
266
|
+
* `sk ${{ github.event_name }}`, expression text and all. The same collapse
|
|
267
|
+
* applies to a skipped reusable-workflow call: one check named after the
|
|
268
|
+
* caller, with no `/ <callee job>` entries.
|
|
269
|
+
*/
|
|
270
|
+
function skippedDisplayName(jobId, job) {
|
|
271
|
+
const raw = job != null && job.name != null ? String(job.name) : null;
|
|
272
|
+
return { name: raw ?? jobId, resolved: true };
|
|
178
273
|
}
|
|
179
274
|
/** Return run|skipped|unknown for a job-level if. */
|
|
180
275
|
export function evalIf(cond) {
|
|
@@ -194,7 +289,13 @@ export function evalIf(cond) {
|
|
|
194
289
|
}
|
|
195
290
|
return "unknown";
|
|
196
291
|
}
|
|
197
|
-
|
|
292
|
+
/**
|
|
293
|
+
* GitHub allows a reusable-workflow call chain four levels deep. Past that the
|
|
294
|
+
* run itself fails, so anything deeper is not a name we could predict anyway.
|
|
295
|
+
* Probe-verified to three levels: `call-nested / Mid Call / inner`.
|
|
296
|
+
*/
|
|
297
|
+
const MAX_REUSABLE_DEPTH = 4;
|
|
298
|
+
async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixResolved = true) {
|
|
198
299
|
const entries = [];
|
|
199
300
|
const jobs = wf.jobs ?? {};
|
|
200
301
|
const statuses = {};
|
|
@@ -219,44 +320,105 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "") {
|
|
|
219
320
|
}
|
|
220
321
|
}
|
|
221
322
|
statuses[jobId] = status;
|
|
323
|
+
// A skipped job never expands its matrix and never dispatches a called
|
|
324
|
+
// workflow: it collapses to a single check under the bare job name.
|
|
325
|
+
if (status === "skipped") {
|
|
326
|
+
const disp = skippedDisplayName(jobId, job);
|
|
327
|
+
const name = prefix + disp.name;
|
|
328
|
+
entries.push({
|
|
329
|
+
job: name,
|
|
330
|
+
checkName: prefixResolved && disp.resolved ? name : null,
|
|
331
|
+
status,
|
|
332
|
+
reason,
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const combos = expandMatrixDetailed(job.strategy);
|
|
222
337
|
if ("uses" in job) {
|
|
223
|
-
//
|
|
338
|
+
// Reusable workflow call. The calling job produces no check of its own;
|
|
339
|
+
// each called job becomes `<calling job name> / <called job name>`, and
|
|
340
|
+
// a matrix on the *caller* multiplies the whole callee set.
|
|
224
341
|
const uses = job.uses;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
342
|
+
if (combos == null) {
|
|
343
|
+
entries.push({
|
|
344
|
+
job: prefix + jobId,
|
|
345
|
+
checkName: null,
|
|
346
|
+
status: "unknown",
|
|
347
|
+
reason: "dynamic matrix on reusable workflow call",
|
|
348
|
+
});
|
|
228
349
|
continue;
|
|
229
350
|
}
|
|
351
|
+
// Resolve the called workflow once, not once per matrix combination.
|
|
352
|
+
let subWf = null;
|
|
353
|
+
let failure = null;
|
|
230
354
|
const m = uses.match(/^\.\/(.+)$/);
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
continue;
|
|
355
|
+
if (depth + 1 > MAX_REUSABLE_DEPTH) {
|
|
356
|
+
failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
|
|
234
357
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
entries.push([baseName, "unknown", `cannot fetch ${uses}`]);
|
|
238
|
-
continue;
|
|
358
|
+
else if (!m) {
|
|
359
|
+
failure = `non-local reusable: ${uses}`;
|
|
239
360
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
361
|
+
else {
|
|
362
|
+
const content = await fetchFile(m[1]);
|
|
363
|
+
if (content == null) {
|
|
364
|
+
failure = `cannot fetch ${uses}`;
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
try {
|
|
368
|
+
subWf = parseYaml(content);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
failure = `YAML parse error in ${uses}: ${e}`;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const combo of combos) {
|
|
376
|
+
const disp = jobDisplayName(jobId, job, combo);
|
|
377
|
+
const baseName = prefix + disp.name;
|
|
378
|
+
const nameResolved = prefixResolved && disp.resolved;
|
|
379
|
+
if (failure != null || subWf == null) {
|
|
380
|
+
entries.push({
|
|
381
|
+
job: baseName,
|
|
382
|
+
checkName: null,
|
|
383
|
+
status: "unknown",
|
|
384
|
+
reason: failure ?? `cannot resolve ${uses}`,
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
entries.push(...(await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `, nameResolved)));
|
|
243
389
|
}
|
|
244
|
-
const subWf = parseYaml(content);
|
|
245
|
-
const sub = await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `);
|
|
246
|
-
entries.push(...sub);
|
|
247
390
|
continue;
|
|
248
391
|
}
|
|
249
|
-
const combos = expandMatrix(job.strategy);
|
|
250
392
|
if (combos == null) {
|
|
251
|
-
entries.push(
|
|
393
|
+
entries.push({
|
|
394
|
+
job: prefix + jobId,
|
|
395
|
+
checkName: null,
|
|
396
|
+
status: "unknown",
|
|
397
|
+
reason: "dynamic matrix",
|
|
398
|
+
});
|
|
252
399
|
continue;
|
|
253
400
|
}
|
|
254
401
|
for (const combo of combos) {
|
|
255
|
-
|
|
402
|
+
const disp = jobDisplayName(jobId, job, combo);
|
|
403
|
+
const name = prefix + disp.name;
|
|
404
|
+
entries.push({
|
|
405
|
+
job: name,
|
|
406
|
+
checkName: prefixResolved && disp.resolved ? name : null,
|
|
407
|
+
status,
|
|
408
|
+
reason,
|
|
409
|
+
});
|
|
256
410
|
}
|
|
257
411
|
}
|
|
258
412
|
return entries;
|
|
259
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Expand one already-parsed workflow into its job entries. Exported so check
|
|
416
|
+
* names can be tested against recorded GitHub behaviour without a network
|
|
417
|
+
* round-trip; `predict` is the API you want.
|
|
418
|
+
*/
|
|
419
|
+
export function expandWorkflowJobs(wf, ctx, fetchFile) {
|
|
420
|
+
return expandJobs(wf, ctx, fetchFile);
|
|
421
|
+
}
|
|
260
422
|
// ------------------------------------------------------------------- pipeline
|
|
261
423
|
export function makeOctokit() {
|
|
262
424
|
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
@@ -285,7 +447,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
285
447
|
});
|
|
286
448
|
const headMsg = headCommit.commit.message;
|
|
287
449
|
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
288
|
-
return
|
|
450
|
+
return finalizePrediction([], "head commit message contains a skip instruction");
|
|
289
451
|
}
|
|
290
452
|
const fetchFile = async (path) => {
|
|
291
453
|
try {
|
|
@@ -353,11 +515,26 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
353
515
|
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
354
516
|
continue;
|
|
355
517
|
}
|
|
356
|
-
for (const
|
|
357
|
-
entries.push({
|
|
518
|
+
for (const j of await expandJobs(wf, ctx, fetchFile)) {
|
|
519
|
+
entries.push({
|
|
520
|
+
workflow: path,
|
|
521
|
+
job: jobName(j.job),
|
|
522
|
+
checkName: j.checkName,
|
|
523
|
+
status: j.status,
|
|
524
|
+
reason: j.reason || reason,
|
|
525
|
+
});
|
|
358
526
|
}
|
|
359
527
|
}
|
|
360
|
-
return
|
|
528
|
+
return finalizePrediction(entries, null);
|
|
529
|
+
}
|
|
530
|
+
function finalizePrediction(entries, skip) {
|
|
531
|
+
const final = entries.map(finalize);
|
|
532
|
+
const names = new Set();
|
|
533
|
+
for (const e of final) {
|
|
534
|
+
if (e.status === "run" && e.checkName != null)
|
|
535
|
+
names.add(e.checkName);
|
|
536
|
+
}
|
|
537
|
+
return { entries: final, checkNames: [...names].sort(), skip };
|
|
361
538
|
}
|
|
362
539
|
// ------------------------------------------------------------------------ CLI
|
|
363
540
|
function parseArgs(argv) {
|
|
@@ -376,9 +553,9 @@ function parseArgs(argv) {
|
|
|
376
553
|
const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
|
|
377
554
|
if (isMain) {
|
|
378
555
|
const args = parseArgs(process.argv.slice(2));
|
|
379
|
-
const { entries, skip } = await predict(makeOctokit(), args.repo, args.pr);
|
|
556
|
+
const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr);
|
|
380
557
|
if (args.json) {
|
|
381
|
-
console.log(JSON.stringify({ entries, skip }, null, 2));
|
|
558
|
+
console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
|
|
382
559
|
}
|
|
383
560
|
else if (skip) {
|
|
384
561
|
console.log(`# ${skip} -> nothing dispatches`);
|
|
@@ -387,8 +564,10 @@ if (isMain) {
|
|
|
387
564
|
for (const e of entries) {
|
|
388
565
|
if (isWorkflowEntry(e))
|
|
389
566
|
console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
|
|
390
|
-
else
|
|
391
|
-
|
|
567
|
+
else {
|
|
568
|
+
const name = e.checkName ?? `${e.job} (name unresolved)`;
|
|
569
|
+
console.log(`${e.workflow} :: ${name} :: ${e.status}`);
|
|
570
|
+
}
|
|
392
571
|
}
|
|
393
572
|
}
|
|
394
573
|
}
|
package/dist/verify.js
CHANGED
|
@@ -44,8 +44,18 @@ if (!repo || !prArg) {
|
|
|
44
44
|
const pr = Number(prArg);
|
|
45
45
|
const octokit = makeOctokit();
|
|
46
46
|
const { entries: predictedRaw } = await predict(octokit, repo, pr);
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Compare on the resolved check name — that is the string GitHub actually
|
|
48
|
+
// puts on the job. Entries whose name could not be resolved statically have
|
|
49
|
+
// no key to compare and are reported separately below.
|
|
50
|
+
const predicted = new Map(predictedRaw
|
|
51
|
+
.filter(isJobEntry)
|
|
52
|
+
.filter((r) => r.checkName != null)
|
|
53
|
+
.map((r) => [`${r.workflow} :: ${r.checkName}`, r.status]));
|
|
54
|
+
const unresolved = predictedRaw.filter(isJobEntry).filter((r) => r.checkName == null);
|
|
55
|
+
const unknownWfs = new Set(predictedRaw
|
|
56
|
+
.filter((r) => r.status === "unknown")
|
|
57
|
+
.map((r) => r.workflow)
|
|
58
|
+
.concat(unresolved.map((r) => r.workflow)));
|
|
49
59
|
const { entries: actual, incomplete } = await actualEntries(octokit, repo, pr);
|
|
50
60
|
if (incomplete.length > 0) {
|
|
51
61
|
console.log(`WARNING: runs still in progress: ${incomplete}`);
|
|
@@ -80,5 +90,8 @@ for (const key of keys) {
|
|
|
80
90
|
console.log(`DIFF ${key} :: predicted ${p}, actual ${a}`);
|
|
81
91
|
}
|
|
82
92
|
}
|
|
93
|
+
for (const r of unresolved) {
|
|
94
|
+
console.log(` ? ${r.workflow} :: ${r.job} :: name unresolved: ${r.reason}`);
|
|
95
|
+
}
|
|
83
96
|
console.log(ok ? "PASS" : "FAIL");
|
|
84
97
|
process.exit(ok ? 0 : 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "willfire",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kevin Scott <me@thekevinscott.com>",
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -p tsconfig.build.json",
|
|
38
38
|
"prepublishOnly": "pnpm build",
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
40
|
+
"test": "node --import tsx --test src/*.test.ts",
|
|
39
41
|
"predict": "tsx src/predict.ts",
|
|
40
42
|
"verify": "tsx src/verify.ts"
|
|
41
43
|
},
|