willfire 0.1.4 → 0.1.6

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
@@ -43,7 +43,9 @@ single name is knowable ahead of the run:
43
43
 
44
44
  - a matrix computed at runtime (`fromJSON` of another job's output), reported
45
45
  as one `unknown` entry for that job and nothing else;
46
- - a non-local reusable workflow, whose callee jobs we do not fetch;
46
+ - a reusable workflow we cannot read — private, deleted, a ref that does not
47
+ exist, a `uses:` built from an expression, or one nested past GitHub's
48
+ four-level limit;
47
49
  - a `name:` interpolating something we cannot evaluate statically.
48
50
 
49
51
  Duplicate names in `checkNames` are not possible (it is a set), but duplicate
@@ -64,26 +66,44 @@ GH_TOKEN=... willfire --repo owner/repo --pr 123 [--json]
64
66
  Path filters (`paths`, `paths-ignore`, order-sensitive `!` negation), branch
65
67
  filters, event `types`, combined filters, `[skip ci]` and friends, disabled
66
68
  workflows, multi-job workflows, static matrix expansion (including
67
- `exclude`/`include`), `needs` skip-propagation, job-level `if`, and local
68
- reusable workflows. Jobs whose `if` is false are predicted as `skipped`
69
- entries, matching how they appear in the checks UI.
69
+ `exclude`/`include`), `needs` skip-propagation, job-level `if`, and reusable
70
+ workflows both the local `./.github/workflows/x.yml` form and the cross-repo
71
+ `owner/repo/.github/workflows/x.yml@ref` form, whose callee is fetched from its
72
+ own repo at the pinned tag, branch, or SHA. Jobs whose `if` is false are
73
+ predicted as `skipped` entries, matching how they appear in the checks UI.
70
74
 
71
75
  Things that cannot be known statically — e.g. a matrix computed at runtime
72
76
  from another job's output — are reported as `unknown` rather than guessed.
73
77
 
78
+ ## Development
79
+
80
+ ```sh
81
+ pnpm install
82
+ pnpm typecheck # tsc over src/, tests included
83
+ pnpm test # vitest, once
84
+ pnpm test:coverage # the 100% floor CI enforces
85
+ ```
86
+
87
+ Unit tests are colocated with their source (`foo.ts` ↔ `foo.test.ts`) at 100%
88
+ coverage, per the
89
+ [testing-conventions](https://github.com/thekevinscott/testing-conventions)
90
+ standard. `.github/workflows/conventions.yml` enforces that in CI;
91
+ `testing-conventions.toml` holds the thresholds.
92
+
74
93
  ## Verification
75
94
 
76
- Predictions are verified against real GitHub behavior, not just the docs:
95
+ The unit suite fixes the behavior. What fixes the *expectations* is the probe:
96
+ predictions are verified against real GitHub behavior, not just the docs.
77
97
  [willrun-probe](https://github.com/thekevinbot/willrun-probe) holds one
78
98
  workflow per dispatch rule, and probe PRs exercise each complication
79
99
  (docs-only diffs, negation edges, `[skip ci]`, a 301-file diff, PRs into
80
100
  non-default branches). The `verify` script diffs predictions against the
81
101
  check entries GitHub actually created. All probe PRs currently pass exactly.
82
102
 
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:
103
+ Check-name resolution is verified the same way, on probe PRs #8 and #9.
104
+ `pnpm test` replays those probe workflows through the resolver and asserts the
105
+ exact job names the live run produced. Several of the rules it pins are ones
106
+ the docs do not state:
87
107
 
88
108
  - a `name:` containing *any* `${{ }}` expression suppresses the matrix
89
109
  parenthetical, even an expression that never reads the matrix — a literal
@@ -94,9 +114,17 @@ not state:
94
114
  - object matrix values flatten to their own values, so `{os: linux, arch: x64}`
95
115
  renders as `(linux, x64)`;
96
116
  - 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.
117
+ is not interpolated: one check, expression text and all;
118
+ - a cross-repo `uses:` names the same `<caller> / <callee>` checks a local one
119
+ does, at whichever tag, branch, or SHA the `@ref` pinned — and a relative
120
+ `uses: ./...` *inside* that callee resolves against the callee's repo and
121
+ ref, not the caller's. Probe PR #9 settles the second one: the caller's copy
122
+ of the callee's file was present at head, under a different job name, and
123
+ GitHub did not use it.
98
124
 
99
125
  Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
100
- live events, cross-repo reusable workflows, `branches-ignore`, and diffs far
101
- beyond 301 files are not yet probe-verified. Reusable-workflow name prefixing
102
- is probe-verified to three levels; deeper nesting is inferred.
126
+ live events, `branches-ignore`, and diffs far beyond 301 files are not yet
127
+ probe-verified. Reusable-workflow name prefixing is probe-verified to three
128
+ levels; deeper nesting is inferred. The cross-repo probe calls back into the
129
+ probe repo itself by full `owner/repo@ref` reference, so it pins ref
130
+ resolution but not the owner/repo half of the address.
package/dist/predict.d.ts CHANGED
@@ -37,8 +37,8 @@ export interface WorkflowEntry extends EntryBase {
37
37
  /**
38
38
  * A verdict about one job entry inside a workflow that does dispatch.
39
39
  *
40
- * These can be genuinely undecidable statically — dynamic matrix, non-local
41
- * reusable workflow, unresolvable `if`, or `needs` on any of those — so
40
+ * These can be genuinely undecidable statically — dynamic matrix, a reusable
41
+ * workflow we cannot read, unresolvable `if`, or `needs` on any of those — so
42
42
  * `"unknown"` lives here and only here.
43
43
  */
44
44
  export interface JobEntry extends EntryBase {
@@ -49,8 +49,8 @@ export interface JobEntry extends EntryBase {
49
49
  * `<caller> / <callee>` prefixing for reusable workflows.
50
50
  *
51
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.
52
+ * at runtime, a reusable workflow we could not fetch, or a `name:` that
53
+ * interpolates something we cannot evaluate ahead of the run.
54
54
  */
55
55
  checkName: string | null;
56
56
  status: "run" | "skipped" | "unknown" | "no-dispatch";
@@ -96,12 +96,50 @@ export interface ExpandedJob {
96
96
  status: "run" | "skipped" | "unknown";
97
97
  reason: string;
98
98
  }
99
+ /**
100
+ * Which repo and ref a workflow file is read from.
101
+ *
102
+ * Expansion carries one of these rather than a bare path because `uses:` can
103
+ * cross repositories. A local `uses: ./...` keeps the current source, so a
104
+ * relative call inside a workflow that was itself reached by
105
+ * `owner/repo/path@ref` resolves against *that* repo at *that* ref — probe
106
+ * verified, see `src/names.test.ts`.
107
+ */
108
+ export interface WorkflowSource {
109
+ owner: string;
110
+ repo: string;
111
+ /** Tag, branch, or SHA — whatever `@` was pinned to. */
112
+ ref: string;
113
+ }
114
+ /** Read one workflow file, or null if it is not reachable. Must not throw. */
115
+ export type FetchWorkflow = (path: string, source: WorkflowSource) => Promise<string | null>;
116
+ /** A `uses:` that named a workflow file we know how to go and get. */
117
+ export interface UsesTarget {
118
+ /** Path inside the target repo, e.g. `.github/workflows/x.yml`. */
119
+ path: string;
120
+ /** null for a local `./` call: the caller's own repo and ref. */
121
+ source: WorkflowSource | null;
122
+ }
123
+ /**
124
+ * Split a job-level `uses:` into the file it names and the repo it lives in.
125
+ *
126
+ * Two spellings are legal:
127
+ *
128
+ * `./.github/workflows/x.yml` -> the caller's repo, same commit
129
+ * `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
130
+ *
131
+ * The ref is taken from the last `@` so a branch containing a slash
132
+ * (`@feature/foo`) survives. Returns null for anything else — including a
133
+ * reference built from an expression, which we cannot evaluate and so must not
134
+ * guess a fetch target for.
135
+ */
136
+ export declare function parseUses(uses: string): UsesTarget | null;
99
137
  /**
100
138
  * Expand one already-parsed workflow into its job entries. Exported so check
101
139
  * names can be tested against recorded GitHub behaviour without a network
102
140
  * round-trip; `predict` is the API you want.
103
141
  */
104
- export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, fetchFile: (path: string) => Promise<string | null>): Promise<ExpandedJob[]>;
142
+ export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, fetchWorkflow: FetchWorkflow, source: WorkflowSource): Promise<ExpandedJob[]>;
105
143
  export declare function makeOctokit(): Octokit;
106
144
  export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
107
145
  export {};
package/dist/predict.js CHANGED
@@ -7,8 +7,9 @@
7
7
  //
8
8
  // Faithful port of predict.py, which was verified entry-for-entry against
9
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
+ // 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.
12
13
  import { Octokit } from "@octokit/rest";
13
14
  import { parse as parseYaml } from "yaml";
14
15
  /** Tag a job display name. Rejects the workflow-level sentinel. */
@@ -91,8 +92,8 @@ function getPrTrigger(wf) {
91
92
  }
92
93
  // A predicate: does this workflow produce a run for the PR? Every workflow-level
93
94
  // verdict is decidable, so there is no third answer to express. Only job
94
- // expansion can be genuinely undecidable (dynamic matrix, non-local reusable
95
- // workflow, unresolvable `if`), and that is a per-entry status.
95
+ // expansion can be genuinely undecidable (dynamic matrix, an unreadable
96
+ // reusable workflow, unresolvable `if`), and that is a per-entry status.
96
97
  function workflowDispatches(wf, ctx) {
97
98
  const trig = getPrTrigger(wf);
98
99
  if (trig === MISSING)
@@ -293,9 +294,44 @@ export function evalIf(cond) {
293
294
  * GitHub allows a reusable-workflow call chain four levels deep. Past that the
294
295
  * run itself fails, so anything deeper is not a name we could predict anyway.
295
296
  * Probe-verified to three levels: `call-nested / Mid Call / inner`.
297
+ *
298
+ * A cross-repo hop costs the same one level as a local one, so a chain that
299
+ * mixes the two is counted the same way.
296
300
  */
297
301
  const MAX_REUSABLE_DEPTH = 4;
298
- async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixResolved = true) {
302
+ /**
303
+ * Split a job-level `uses:` into the file it names and the repo it lives in.
304
+ *
305
+ * Two spellings are legal:
306
+ *
307
+ * `./.github/workflows/x.yml` -> the caller's repo, same commit
308
+ * `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
309
+ *
310
+ * The ref is taken from the last `@` so a branch containing a slash
311
+ * (`@feature/foo`) survives. Returns null for anything else — including a
312
+ * reference built from an expression, which we cannot evaluate and so must not
313
+ * guess a fetch target for.
314
+ */
315
+ export function parseUses(uses) {
316
+ if (EXPRESSION_RE.test(uses))
317
+ return null;
318
+ if (uses.startsWith("./")) {
319
+ const path = uses.slice(2);
320
+ return path === "" ? null : { path, source: null };
321
+ }
322
+ const at = uses.lastIndexOf("@");
323
+ if (at <= 0)
324
+ return null;
325
+ const ref = uses.slice(at + 1);
326
+ if (ref === "")
327
+ return null;
328
+ const [owner, repo, ...rest] = uses.slice(0, at).split("/");
329
+ const path = rest.join("/");
330
+ if (!owner || !repo || path === "")
331
+ return null;
332
+ return { path, source: { owner, repo, ref } };
333
+ }
334
+ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = "", prefixResolved = true) {
299
335
  const entries = [];
300
336
  const jobs = wf.jobs ?? {};
301
337
  const statuses = {};
@@ -337,7 +373,9 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
337
373
  if ("uses" in job) {
338
374
  // Reusable workflow call. The calling job produces no check of its own;
339
375
  // each called job becomes `<calling job name> / <called job name>`, and
340
- // a matrix on the *caller* multiplies the whole callee set.
376
+ // a matrix on the *caller* multiplies the whole callee set. A cross-repo
377
+ // call names its checks exactly the same way a local one does — probe
378
+ // PR #9, `call-remote-tag / r-inner` alongside `call-plain / inner`.
341
379
  const uses = job.uses;
342
380
  if (combos == null) {
343
381
  entries.push({
@@ -351,15 +389,19 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
351
389
  // Resolve the called workflow once, not once per matrix combination.
352
390
  let subWf = null;
353
391
  let failure = null;
354
- const m = uses.match(/^\.\/(.+)$/);
392
+ // Where the callee's own `./` calls will resolve. A remote `uses:` moves
393
+ // this to the callee's repo and pinned ref; a local one leaves it alone.
394
+ let subSource = source;
395
+ const target = parseUses(uses);
355
396
  if (depth + 1 > MAX_REUSABLE_DEPTH) {
356
397
  failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
357
398
  }
358
- else if (!m) {
359
- failure = `non-local reusable: ${uses}`;
399
+ else if (target == null) {
400
+ failure = `unresolvable reusable reference: ${uses}`;
360
401
  }
361
402
  else {
362
- const content = await fetchFile(m[1]);
403
+ subSource = target.source ?? source;
404
+ const content = await fetchWorkflow(target.path, subSource);
363
405
  if (content == null) {
364
406
  failure = `cannot fetch ${uses}`;
365
407
  }
@@ -385,7 +427,7 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
385
427
  });
386
428
  continue;
387
429
  }
388
- entries.push(...(await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `, nameResolved)));
430
+ entries.push(...(await expandJobs(subWf, ctx, fetchWorkflow, subSource, depth + 1, `${baseName} / `, nameResolved)));
389
431
  }
390
432
  continue;
391
433
  }
@@ -416,8 +458,8 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
416
458
  * names can be tested against recorded GitHub behaviour without a network
417
459
  * round-trip; `predict` is the API you want.
418
460
  */
419
- export function expandWorkflowJobs(wf, ctx, fetchFile) {
420
- return expandJobs(wf, ctx, fetchFile);
461
+ export function expandWorkflowJobs(wf, ctx, fetchWorkflow, source) {
462
+ return expandJobs(wf, ctx, fetchWorkflow, source);
421
463
  }
422
464
  // ------------------------------------------------------------------- pipeline
423
465
  export function makeOctokit() {
@@ -449,19 +491,35 @@ export async function predict(octokit, repo, prNumber) {
449
491
  if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
450
492
  return finalizePrediction([], "head commit message contains a skip instruction");
451
493
  }
452
- const fetchFile = async (path) => {
494
+ /** The PR's own repo at the head commit — where expansion starts. */
495
+ const headSource = { owner, repo: name, ref: headSha };
496
+ // One callee is commonly reached from several callers — a fleet repo calls
497
+ // the same `testing-conventions@v0` from eight workflows — so remember what
498
+ // each `owner/repo/path@ref` resolved to, misses included.
499
+ const cache = new Map();
500
+ const fetchWorkflow = async (path, src) => {
501
+ const key = `${src.owner}/${src.repo}/${path}@${src.ref}`;
502
+ const hit = cache.get(key);
503
+ if (hit !== undefined)
504
+ return hit;
505
+ let content;
453
506
  try {
454
507
  const { data } = await octokit.rest.repos.getContent({
455
- ...base,
508
+ owner: src.owner,
509
+ repo: src.repo,
456
510
  path,
457
- ref: headSha,
511
+ ref: src.ref,
458
512
  mediaType: { format: "raw" },
459
513
  });
460
- return data;
514
+ content = data;
461
515
  }
462
516
  catch {
463
- return null;
517
+ // Private, deleted, bad ref, rate limit, network: all one answer here.
518
+ // The caller turns it into an `unknown` entry rather than throwing.
519
+ content = null;
464
520
  }
521
+ cache.set(key, content);
522
+ return content;
465
523
  };
466
524
  const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
467
525
  ...base,
@@ -481,7 +539,7 @@ export async function predict(octokit, repo, prNumber) {
481
539
  });
482
540
  continue;
483
541
  }
484
- const content = await fetchFile(path);
542
+ const content = await fetchWorkflow(path, headSource);
485
543
  if (content == null) {
486
544
  // The Actions API keeps listing a workflow as `active` after its file is
487
545
  // deleted. There is no file at head, so there is nothing to dispatch —
@@ -515,7 +573,7 @@ export async function predict(octokit, repo, prNumber) {
515
573
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
516
574
  continue;
517
575
  }
518
- for (const j of await expandJobs(wf, ctx, fetchFile)) {
576
+ for (const j of await expandJobs(wf, ctx, fetchWorkflow, headSource)) {
519
577
  entries.push({
520
578
  workflow: path,
521
579
  job: jobName(j.job),
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
+ "packageManager": "pnpm@10.33.0",
6
7
  "author": "Kevin Scott <me@thekevinscott.com>",
7
8
  "repository": {
8
9
  "type": "git",
@@ -35,10 +36,11 @@
35
36
  },
36
37
  "scripts": {
37
38
  "build": "tsc -p tsconfig.build.json",
38
- "prepublishOnly": "pnpm build",
39
- "typecheck": "tsc -p tsconfig.json",
40
- "test": "node --import tsx --test src/*.test.ts",
41
39
  "predict": "tsx src/predict.ts",
40
+ "prepare": "pnpm build",
41
+ "test": "vitest run",
42
+ "test:coverage": "vitest run --coverage",
43
+ "typecheck": "tsc -p tsconfig.json",
42
44
  "verify": "tsx src/verify.ts"
43
45
  },
44
46
  "dependencies": {
@@ -47,7 +49,10 @@
47
49
  },
48
50
  "devDependencies": {
49
51
  "@types/node": "^22.0.0",
52
+ "@vitest/coverage-v8": "^4.1.10",
53
+ "testing-conventions": "^0.0.91",
50
54
  "tsx": "^4.19.0",
51
- "typescript": "^5.6.0"
55
+ "typescript": "^5.6.0",
56
+ "vitest": "^4.1.10"
52
57
  }
53
58
  }