willfire 0.1.2 → 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 +46 -3
- package/dist/predict.d.ts +87 -3
- package/dist/predict.js +235 -50
- package/dist/verify.js +14 -8
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -20,10 +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);
|
|
24
|
-
//
|
|
23
|
+
const { entries, checkNames, skip } = await predict(getOctokit(token), "owner/repo", 123);
|
|
24
|
+
// checkNames: sorted, deduped checkName of every entry with status "run"
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`entries` is a union of two variants, both carrying `workflow` and `reason`:
|
|
28
|
+
|
|
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"` |
|
|
33
|
+
|
|
34
|
+
`"unknown"` is job-level only: every workflow-level verdict is decidable, so a
|
|
35
|
+
`WorkflowEntry` cannot express one. Narrow with the exported `isWorkflowEntry`
|
|
36
|
+
and `isJobEntry` guards rather than testing the `"*"` sentinel yourself.
|
|
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
|
+
|
|
27
53
|
Auth is any token with `contents: read`, `actions: read`, and
|
|
28
54
|
`pull-requests: read` — inside an action, the workflow's `GITHUB_TOKEN`.
|
|
29
55
|
|
|
@@ -54,6 +80,23 @@ workflow per dispatch rule, and probe PRs exercise each complication
|
|
|
54
80
|
non-default branches). The `verify` script diffs predictions against the
|
|
55
81
|
check entries GitHub actually created. All probe PRs currently pass exactly.
|
|
56
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
|
+
|
|
57
99
|
Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
|
|
58
100
|
live events, cross-repo reusable workflows, `branches-ignore`, and diffs far
|
|
59
|
-
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
|
@@ -1,23 +1,107 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Octokit } from "@octokit/rest";
|
|
3
|
-
|
|
3
|
+
interface EntryBase {
|
|
4
4
|
workflow: string;
|
|
5
|
-
job: string;
|
|
6
|
-
status: "run" | "skipped" | "unknown" | "no-dispatch";
|
|
7
5
|
reason: string;
|
|
8
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* A job's display name, e.g. `test (18)`.
|
|
9
|
+
*
|
|
10
|
+
* Nominally distinct from `string` for one reason: `Entry` is a closed union
|
|
11
|
+
* and `"*"` is the workflow-level sentinel, so a plain `string` here would
|
|
12
|
+
* structurally admit `{ job: "*", status: "unknown" }` as a `JobEntry` — the
|
|
13
|
+
* exact shape this split exists to forbid. TypeScript cannot spell "string
|
|
14
|
+
* but not `"*"`", so the job side is branded instead. Build one with
|
|
15
|
+
* {@link jobName}; reading one is just a string.
|
|
16
|
+
*/
|
|
17
|
+
export type JobName = string & {
|
|
18
|
+
readonly __jobName: true;
|
|
19
|
+
};
|
|
20
|
+
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
21
|
+
export declare const jobName: <S extends string>(name: S extends "*" ? never : S) => JobName;
|
|
22
|
+
/**
|
|
23
|
+
* A verdict about the workflow as a whole: it produces no run at all, or it
|
|
24
|
+
* produces a run that expands into no job entries.
|
|
25
|
+
*
|
|
26
|
+
* There is no `"unknown"` here, and that is the point. Every workflow-level
|
|
27
|
+
* verdict is decidable, and this type is what enforces it — the previous
|
|
28
|
+
* single-interface shape let `{ job: "*", status: "unknown" }` typecheck, which
|
|
29
|
+
* is what shipped and what pr-monitor#17 had to grow a `tolerated` bucket for.
|
|
30
|
+
*/
|
|
31
|
+
export interface WorkflowEntry extends EntryBase {
|
|
32
|
+
job: "*";
|
|
33
|
+
/** Always null: a workflow-level verdict is about the run, not a named check. */
|
|
34
|
+
checkName: null;
|
|
35
|
+
status: "run" | "skipped" | "no-dispatch";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A verdict about one job entry inside a workflow that does dispatch.
|
|
39
|
+
*
|
|
40
|
+
* These can be genuinely undecidable statically — dynamic matrix, non-local
|
|
41
|
+
* reusable workflow, unresolvable `if`, or `needs` on any of those — so
|
|
42
|
+
* `"unknown"` lives here and only here.
|
|
43
|
+
*/
|
|
44
|
+
export interface JobEntry extends EntryBase {
|
|
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;
|
|
56
|
+
status: "run" | "skipped" | "unknown" | "no-dispatch";
|
|
57
|
+
}
|
|
58
|
+
export type Entry = WorkflowEntry | JobEntry;
|
|
59
|
+
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
60
|
+
export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
|
|
61
|
+
/** Narrow to the job-level variant without inspecting the sentinel. */
|
|
62
|
+
export declare const isJobEntry: (e: Entry) => e is JobEntry;
|
|
9
63
|
export interface Prediction {
|
|
10
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[];
|
|
11
71
|
skip: string | null;
|
|
12
72
|
}
|
|
13
73
|
export declare function patternToRegex(pat: string): RegExp;
|
|
14
74
|
/** Order-sensitive match: last matching pattern wins; ! negates. */
|
|
15
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>;
|
|
16
82
|
type Combo = Record<string, any> | null;
|
|
17
83
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
18
84
|
export declare function expandMatrix(strategy: any): Combo[] | null;
|
|
19
85
|
/** Return run|skipped|unknown for a job-level if. */
|
|
20
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[]>;
|
|
21
105
|
export declare function makeOctokit(): Octokit;
|
|
22
106
|
export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
|
|
23
107
|
export {};
|
package/dist/predict.js
CHANGED
|
@@ -6,9 +6,21 @@
|
|
|
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";
|
|
14
|
+
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
15
|
+
export const jobName = (name) => name;
|
|
16
|
+
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
17
|
+
export const isWorkflowEntry = (e) => e.job === "*";
|
|
18
|
+
/** Narrow to the job-level variant without inspecting the sentinel. */
|
|
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 };
|
|
12
24
|
// ------------------------------------------------- GitHub filter pattern glob
|
|
13
25
|
// Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
|
|
14
26
|
// preceding char), + (one or more of preceding char), [ranges], leading ! negates.
|
|
@@ -112,8 +124,7 @@ function workflowDispatches(wf, ctx) {
|
|
|
112
124
|
}
|
|
113
125
|
return [true, "trigger matched"];
|
|
114
126
|
}
|
|
115
|
-
|
|
116
|
-
export function expandMatrix(strategy) {
|
|
127
|
+
function expandMatrixDetailed(strategy) {
|
|
117
128
|
const matrix = strategy?.matrix;
|
|
118
129
|
if (matrix == null)
|
|
119
130
|
return [null];
|
|
@@ -131,44 +142,134 @@ export function expandMatrix(strategy) {
|
|
|
131
142
|
return null;
|
|
132
143
|
axes[k] = v;
|
|
133
144
|
}
|
|
134
|
-
|
|
145
|
+
const axisKeys = Object.keys(axes);
|
|
146
|
+
let combos = [{ values: {}, displayKeys: axisKeys }];
|
|
135
147
|
for (const [k, vals] of Object.entries(axes)) {
|
|
136
|
-
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 })));
|
|
137
149
|
}
|
|
138
|
-
if (
|
|
150
|
+
if (axisKeys.length === 0)
|
|
139
151
|
combos = [];
|
|
140
|
-
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)));
|
|
141
153
|
const extra = [];
|
|
142
154
|
for (const inc of include) {
|
|
143
155
|
const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
|
|
144
|
-
const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c[k] === v));
|
|
145
|
-
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").
|
|
146
162
|
for (const c of targets)
|
|
147
|
-
Object.assign(c, inc);
|
|
163
|
+
Object.assign(c.values, inc);
|
|
148
164
|
}
|
|
149
165
|
else {
|
|
150
|
-
|
|
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) });
|
|
151
169
|
}
|
|
152
170
|
}
|
|
153
171
|
combos.push(...extra);
|
|
154
172
|
return combos.length > 0 ? combos : [null];
|
|
155
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
|
+
}
|
|
156
224
|
function renderName(template, combo) {
|
|
157
|
-
|
|
225
|
+
let resolved = true;
|
|
226
|
+
const text = template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
|
|
158
227
|
const expr = String(inner).trim();
|
|
159
|
-
if (expr.startsWith("matrix.")
|
|
160
|
-
|
|
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);
|
|
161
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;
|
|
162
244
|
return whole;
|
|
163
245
|
});
|
|
246
|
+
return { text, resolved };
|
|
164
247
|
}
|
|
248
|
+
/** The check name for one job/combination. */
|
|
165
249
|
function jobDisplayName(jobId, job, combo) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 };
|
|
172
273
|
}
|
|
173
274
|
/** Return run|skipped|unknown for a job-level if. */
|
|
174
275
|
export function evalIf(cond) {
|
|
@@ -188,7 +289,13 @@ export function evalIf(cond) {
|
|
|
188
289
|
}
|
|
189
290
|
return "unknown";
|
|
190
291
|
}
|
|
191
|
-
|
|
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) {
|
|
192
299
|
const entries = [];
|
|
193
300
|
const jobs = wf.jobs ?? {};
|
|
194
301
|
const statuses = {};
|
|
@@ -213,44 +320,105 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "") {
|
|
|
213
320
|
}
|
|
214
321
|
}
|
|
215
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);
|
|
216
337
|
if ("uses" in job) {
|
|
217
|
-
//
|
|
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.
|
|
218
341
|
const uses = job.uses;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
});
|
|
222
349
|
continue;
|
|
223
350
|
}
|
|
351
|
+
// Resolve the called workflow once, not once per matrix combination.
|
|
352
|
+
let subWf = null;
|
|
353
|
+
let failure = null;
|
|
224
354
|
const m = uses.match(/^\.\/(.+)$/);
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
continue;
|
|
355
|
+
if (depth + 1 > MAX_REUSABLE_DEPTH) {
|
|
356
|
+
failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
|
|
228
357
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
entries.push([baseName, "unknown", `cannot fetch ${uses}`]);
|
|
232
|
-
continue;
|
|
358
|
+
else if (!m) {
|
|
359
|
+
failure = `non-local reusable: ${uses}`;
|
|
233
360
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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)));
|
|
237
389
|
}
|
|
238
|
-
const subWf = parseYaml(content);
|
|
239
|
-
const sub = await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `);
|
|
240
|
-
entries.push(...sub);
|
|
241
390
|
continue;
|
|
242
391
|
}
|
|
243
|
-
const combos = expandMatrix(job.strategy);
|
|
244
392
|
if (combos == null) {
|
|
245
|
-
entries.push(
|
|
393
|
+
entries.push({
|
|
394
|
+
job: prefix + jobId,
|
|
395
|
+
checkName: null,
|
|
396
|
+
status: "unknown",
|
|
397
|
+
reason: "dynamic matrix",
|
|
398
|
+
});
|
|
246
399
|
continue;
|
|
247
400
|
}
|
|
248
401
|
for (const combo of combos) {
|
|
249
|
-
|
|
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
|
+
});
|
|
250
410
|
}
|
|
251
411
|
}
|
|
252
412
|
return entries;
|
|
253
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
|
+
}
|
|
254
422
|
// ------------------------------------------------------------------- pipeline
|
|
255
423
|
export function makeOctokit() {
|
|
256
424
|
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
@@ -279,7 +447,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
279
447
|
});
|
|
280
448
|
const headMsg = headCommit.commit.message;
|
|
281
449
|
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
282
|
-
return
|
|
450
|
+
return finalizePrediction([], "head commit message contains a skip instruction");
|
|
283
451
|
}
|
|
284
452
|
const fetchFile = async (path) => {
|
|
285
453
|
try {
|
|
@@ -347,11 +515,26 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
347
515
|
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
348
516
|
continue;
|
|
349
517
|
}
|
|
350
|
-
for (const
|
|
351
|
-
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
|
+
});
|
|
352
526
|
}
|
|
353
527
|
}
|
|
354
|
-
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 };
|
|
355
538
|
}
|
|
356
539
|
// ------------------------------------------------------------------------ CLI
|
|
357
540
|
function parseArgs(argv) {
|
|
@@ -370,19 +553,21 @@ function parseArgs(argv) {
|
|
|
370
553
|
const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
|
|
371
554
|
if (isMain) {
|
|
372
555
|
const args = parseArgs(process.argv.slice(2));
|
|
373
|
-
const { entries, skip } = await predict(makeOctokit(), args.repo, args.pr);
|
|
556
|
+
const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr);
|
|
374
557
|
if (args.json) {
|
|
375
|
-
console.log(JSON.stringify({ entries, skip }, null, 2));
|
|
558
|
+
console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
|
|
376
559
|
}
|
|
377
560
|
else if (skip) {
|
|
378
561
|
console.log(`# ${skip} -> nothing dispatches`);
|
|
379
562
|
}
|
|
380
563
|
else {
|
|
381
564
|
for (const e of entries) {
|
|
382
|
-
if (e
|
|
565
|
+
if (isWorkflowEntry(e))
|
|
383
566
|
console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
|
|
384
|
-
else
|
|
385
|
-
|
|
567
|
+
else {
|
|
568
|
+
const name = e.checkName ?? `${e.job} (name unresolved)`;
|
|
569
|
+
console.log(`${e.workflow} :: ${name} :: ${e.status}`);
|
|
570
|
+
}
|
|
386
571
|
}
|
|
387
572
|
}
|
|
388
573
|
}
|
package/dist/verify.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Ground truth: workflow runs for the PR head SHA with a pull_request event,
|
|
6
6
|
// and the job entries inside each run (skipped jobs included).
|
|
7
|
-
import { makeOctokit, predict } from "./predict.js";
|
|
7
|
+
import { isJobEntry, makeOctokit, predict } from "./predict.js";
|
|
8
8
|
async function actualEntries(octokit, repo, prNumber) {
|
|
9
9
|
const [owner, name] = repo.split("/");
|
|
10
10
|
const base = { owner, repo: name };
|
|
@@ -44,10 +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
|
+
// 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.
|
|
47
50
|
const predicted = new Map(predictedRaw
|
|
48
|
-
.filter(
|
|
49
|
-
.
|
|
50
|
-
|
|
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)));
|
|
51
59
|
const { entries: actual, incomplete } = await actualEntries(octokit, repo, pr);
|
|
52
60
|
if (incomplete.length > 0) {
|
|
53
61
|
console.log(`WARNING: runs still in progress: ${incomplete}`);
|
|
@@ -82,10 +90,8 @@ for (const key of keys) {
|
|
|
82
90
|
console.log(`DIFF ${key} :: predicted ${p}, actual ${a}`);
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
|
-
for (const r of
|
|
86
|
-
|
|
87
|
-
console.log(` ? ${r.workflow} :: workflow-level unknown: ${r.reason}`);
|
|
88
|
-
}
|
|
93
|
+
for (const r of unresolved) {
|
|
94
|
+
console.log(` ? ${r.workflow} :: ${r.job} :: name unresolved: ${r.reason}`);
|
|
89
95
|
}
|
|
90
96
|
console.log(ok ? "PASS" : "FAIL");
|
|
91
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
|
},
|