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 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
- export interface Entry {
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 ["no-dispatch", "no pull_request trigger"];
93
+ return [false, "no pull_request trigger"];
84
94
  const types = trig["types"] ?? DEFAULT_TYPES;
85
95
  if (!types.includes(ctx.action)) {
86
- return ["no-dispatch", `action '${ctx.action}' not in types [${types}]`];
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 ["unknown", "both branches and branches-ignore set"];
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 ["no-dispatch", `base branch '${ctx.baseRef}' not in branches`];
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 ["no-dispatch", "base branch in branches-ignore"];
108
+ return [false, "base branch in branches-ignore"];
96
109
  }
97
110
  if ("paths" in trig && "paths-ignore" in trig) {
98
- return ["unknown", "both paths and paths-ignore set"];
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 ["no-dispatch", "no changed file matches paths"];
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 ["no-dispatch", "all changed files match paths-ignore"];
117
+ return [false, "all changed files match paths-ignore"];
105
118
  }
106
- return ["dispatch", "trigger matched"];
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: "unknown",
315
- reason: "cannot fetch workflow file at head",
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: "unknown",
346
+ status: "run",
328
347
  reason: `YAML parse error: ${e}`,
329
348
  });
330
349
  continue;
331
350
  }
332
- const [verdict, reason] = workflowDispatches(wf, ctx);
333
- if (verdict !== "dispatch") {
334
- entries.push({ workflow: path, job: "*", status: verdict, reason });
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 [jobName, status, jreason] of await expandJobs(wf, ctx, fetchFile)) {
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.job === "*")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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>",