willfire 0.1.1 → 0.1.3
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 +11 -1
- package/dist/predict.d.ts +45 -3
- package/dist/predict.js +37 -18
- package/dist/verify.js +2 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,9 +21,19 @@ import { predict } from "willfire";
|
|
|
21
21
|
import { getOctokit } from "@actions/github"; // or new Octokit({ auth: token })
|
|
22
22
|
|
|
23
23
|
const { entries, skip } = await predict(getOctokit(token), "owner/repo", 123);
|
|
24
|
-
// entries: [{ workflow, job, status: "run" | "skipped" | "unknown" | "no-dispatch", reason }]
|
|
25
24
|
```
|
|
26
25
|
|
|
26
|
+
`entries` is a union of two variants, both carrying `workflow` and `reason`:
|
|
27
|
+
|
|
28
|
+
| variant | `job` | `status` |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `WorkflowEntry` | `"*"` | `"run" \| "skipped" \| "no-dispatch"` |
|
|
31
|
+
| `JobEntry` | the job's display name | `"run" \| "skipped" \| "unknown" \| "no-dispatch"` |
|
|
32
|
+
|
|
33
|
+
`"unknown"` is job-level only: every workflow-level verdict is decidable, so a
|
|
34
|
+
`WorkflowEntry` cannot express one. Narrow with the exported `isWorkflowEntry`
|
|
35
|
+
and `isJobEntry` guards rather than testing the `"*"` sentinel yourself.
|
|
36
|
+
|
|
27
37
|
Auth is any token with `contents: read`, `actions: read`, and
|
|
28
38
|
`pull-requests: read` — inside an action, the workflow's `GITHUB_TOKEN`.
|
|
29
39
|
|
package/dist/predict.d.ts
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
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
|
+
status: "run" | "skipped" | "no-dispatch";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A verdict about one job entry inside a workflow that does dispatch.
|
|
37
|
+
*
|
|
38
|
+
* These can be genuinely undecidable statically — dynamic matrix, non-local
|
|
39
|
+
* reusable workflow, unresolvable `if`, or `needs` on any of those — so
|
|
40
|
+
* `"unknown"` lives here and only here.
|
|
41
|
+
*/
|
|
42
|
+
export interface JobEntry extends EntryBase {
|
|
43
|
+
job: JobName;
|
|
44
|
+
status: "run" | "skipped" | "unknown" | "no-dispatch";
|
|
45
|
+
}
|
|
46
|
+
export type Entry = WorkflowEntry | JobEntry;
|
|
47
|
+
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
48
|
+
export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
|
|
49
|
+
/** Narrow to the job-level variant without inspecting the sentinel. */
|
|
50
|
+
export declare const isJobEntry: (e: Entry) => e is JobEntry;
|
|
9
51
|
export interface Prediction {
|
|
10
52
|
entries: Entry[];
|
|
11
53
|
skip: string | null;
|
package/dist/predict.js
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// live dispatches on thekevinbot/willrun-probe (PRs 1-7).
|
|
10
10
|
import { Octokit } from "@octokit/rest";
|
|
11
11
|
import { parse as parseYaml } from "yaml";
|
|
12
|
+
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
13
|
+
export const jobName = (name) => name;
|
|
14
|
+
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
15
|
+
export const isWorkflowEntry = (e) => e.job === "*";
|
|
16
|
+
/** Narrow to the job-level variant without inspecting the sentinel. */
|
|
17
|
+
export const isJobEntry = (e) => e.job !== "*";
|
|
12
18
|
// ------------------------------------------------- GitHub filter pattern glob
|
|
13
19
|
// Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
|
|
14
20
|
// preceding char), + (one or more of preceding char), [ranges], leading ! negates.
|
|
@@ -77,33 +83,40 @@ function getPrTrigger(wf) {
|
|
|
77
83
|
}
|
|
78
84
|
return MISSING;
|
|
79
85
|
}
|
|
86
|
+
// A predicate: does this workflow produce a run for the PR? Every workflow-level
|
|
87
|
+
// verdict is decidable, so there is no third answer to express. Only job
|
|
88
|
+
// expansion can be genuinely undecidable (dynamic matrix, non-local reusable
|
|
89
|
+
// workflow, unresolvable `if`), and that is a per-entry status.
|
|
80
90
|
function workflowDispatches(wf, ctx) {
|
|
81
91
|
const trig = getPrTrigger(wf);
|
|
82
92
|
if (trig === MISSING)
|
|
83
|
-
return [
|
|
93
|
+
return [false, "no pull_request trigger"];
|
|
84
94
|
const types = trig["types"] ?? DEFAULT_TYPES;
|
|
85
95
|
if (!types.includes(ctx.action)) {
|
|
86
|
-
return [
|
|
96
|
+
return [false, `action '${ctx.action}' not in types [${types}]`];
|
|
87
97
|
}
|
|
98
|
+
// Setting a filter and its -ignore twin on one trigger is invalid config.
|
|
99
|
+
// GitHub does not fall back to "no filter" or skip the workflow: it creates
|
|
100
|
+
// the run and concludes `startup_failure`. The run exists, so it dispatches.
|
|
88
101
|
if ("branches" in trig && "branches-ignore" in trig) {
|
|
89
|
-
return [
|
|
102
|
+
return [true, "both branches and branches-ignore set: startup failure"];
|
|
90
103
|
}
|
|
91
104
|
if ("branches" in trig && !matchFilters(ctx.baseRef, trig["branches"])) {
|
|
92
|
-
return [
|
|
105
|
+
return [false, `base branch '${ctx.baseRef}' not in branches`];
|
|
93
106
|
}
|
|
94
107
|
if ("branches-ignore" in trig && matchFilters(ctx.baseRef, trig["branches-ignore"])) {
|
|
95
|
-
return [
|
|
108
|
+
return [false, "base branch in branches-ignore"];
|
|
96
109
|
}
|
|
97
110
|
if ("paths" in trig && "paths-ignore" in trig) {
|
|
98
|
-
return [
|
|
111
|
+
return [true, "both paths and paths-ignore set: startup failure"];
|
|
99
112
|
}
|
|
100
113
|
if ("paths" in trig && !ctx.files.some((f) => matchFilters(f, trig["paths"]))) {
|
|
101
|
-
return [
|
|
114
|
+
return [false, "no changed file matches paths"];
|
|
102
115
|
}
|
|
103
116
|
if ("paths-ignore" in trig && ctx.files.every((f) => matchFilters(f, trig["paths-ignore"]))) {
|
|
104
|
-
return [
|
|
117
|
+
return [false, "all changed files match paths-ignore"];
|
|
105
118
|
}
|
|
106
|
-
return [
|
|
119
|
+
return [true, "trigger matched"];
|
|
107
120
|
}
|
|
108
121
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
109
122
|
export function expandMatrix(strategy) {
|
|
@@ -308,11 +321,14 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
308
321
|
}
|
|
309
322
|
const content = await fetchFile(path);
|
|
310
323
|
if (content == null) {
|
|
324
|
+
// The Actions API keeps listing a workflow as `active` after its file is
|
|
325
|
+
// deleted. There is no file at head, so there is nothing to dispatch —
|
|
326
|
+
// the same verdict as the disabled case above, reached a different way.
|
|
311
327
|
entries.push({
|
|
312
328
|
workflow: path,
|
|
313
329
|
job: "*",
|
|
314
|
-
status: "
|
|
315
|
-
reason: "
|
|
330
|
+
status: "no-dispatch",
|
|
331
|
+
reason: "no workflow file at head",
|
|
316
332
|
});
|
|
317
333
|
continue;
|
|
318
334
|
}
|
|
@@ -321,21 +337,24 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
321
337
|
wf = parseYaml(content);
|
|
322
338
|
}
|
|
323
339
|
catch (e) {
|
|
340
|
+
// GitHub creates a run for an unparseable workflow file and concludes it
|
|
341
|
+
// `startup_failure`. The run exists but has no jobs, so this is a
|
|
342
|
+
// workflow-level "it dispatches" with nothing to expand.
|
|
324
343
|
entries.push({
|
|
325
344
|
workflow: path,
|
|
326
345
|
job: "*",
|
|
327
|
-
status: "
|
|
346
|
+
status: "run",
|
|
328
347
|
reason: `YAML parse error: ${e}`,
|
|
329
348
|
});
|
|
330
349
|
continue;
|
|
331
350
|
}
|
|
332
|
-
const [
|
|
333
|
-
if (
|
|
334
|
-
entries.push({ workflow: path, job: "*", status:
|
|
351
|
+
const [dispatches, reason] = workflowDispatches(wf, ctx);
|
|
352
|
+
if (!dispatches) {
|
|
353
|
+
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
335
354
|
continue;
|
|
336
355
|
}
|
|
337
|
-
for (const [
|
|
338
|
-
entries.push({ workflow: path, job: jobName, status, reason: jreason || reason });
|
|
356
|
+
for (const [name, status, jreason] of await expandJobs(wf, ctx, fetchFile)) {
|
|
357
|
+
entries.push({ workflow: path, job: jobName(name), status, reason: jreason || reason });
|
|
339
358
|
}
|
|
340
359
|
}
|
|
341
360
|
return { entries, skip: null };
|
|
@@ -366,7 +385,7 @@ if (isMain) {
|
|
|
366
385
|
}
|
|
367
386
|
else {
|
|
368
387
|
for (const e of entries) {
|
|
369
|
-
if (e
|
|
388
|
+
if (isWorkflowEntry(e))
|
|
370
389
|
console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
|
|
371
390
|
else
|
|
372
391
|
console.log(`${e.workflow} :: ${e.job} :: ${e.status}`);
|
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,9 +44,7 @@ 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
|
-
const predicted = new Map(predictedRaw
|
|
48
|
-
.filter((r) => r.job !== "*")
|
|
49
|
-
.map((r) => [`${r.workflow} :: ${r.job}`, r.status]));
|
|
47
|
+
const predicted = new Map(predictedRaw.filter(isJobEntry).map((r) => [`${r.workflow} :: ${r.job}`, r.status]));
|
|
50
48
|
const unknownWfs = new Set(predictedRaw.filter((r) => r.status === "unknown").map((r) => r.workflow));
|
|
51
49
|
const { entries: actual, incomplete } = await actualEntries(octokit, repo, pr);
|
|
52
50
|
if (incomplete.length > 0) {
|
|
@@ -82,10 +80,5 @@ for (const key of keys) {
|
|
|
82
80
|
console.log(`DIFF ${key} :: predicted ${p}, actual ${a}`);
|
|
83
81
|
}
|
|
84
82
|
}
|
|
85
|
-
for (const r of predictedRaw) {
|
|
86
|
-
if (r.job === "*" && r.status === "unknown") {
|
|
87
|
-
console.log(` ? ${r.workflow} :: workflow-level unknown: ${r.reason}`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
83
|
console.log(ok ? "PASS" : "FAIL");
|
|
91
84
|
process.exit(ok ? 0 : 1);
|
package/package.json
CHANGED