willfire 0.1.8 → 0.1.10

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
@@ -20,10 +20,23 @@ 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, checkNames, skip } = await predict(getOctokit(token), "owner/repo", 123);
23
+ const { entries, checkNames, skip, sources } = await predict(
24
+ getOctokit(token),
25
+ "owner/repo",
26
+ 123,
27
+ { action: context.payload.action }, // "opened" | "synchronize" | "reopened"
28
+ );
24
29
  // checkNames: sorted, deduped checkName of every entry with status "run"
30
+ // sources: every repo read, and the commit each ref resolved to
25
31
  ```
26
32
 
33
+ `action` is optional but worth passing. Omitted, the event action is inferred
34
+ from the PR's commit count, which is wrong in both directions — a PR opened
35
+ from a branch with several commits looks like `synchronize`, a force-push down
36
+ to one commit looks like `opened` — and can never produce `reopened`. That only
37
+ matters to a workflow narrowing `types:`, where it decides whether the workflow
38
+ dispatches at all.
39
+
27
40
  `entries` is a union of two variants, both carrying `workflow` and `reason`:
28
41
 
29
42
  | variant | `job` | `checkName` | `status` |
@@ -48,6 +61,17 @@ single name is knowable ahead of the run:
48
61
  four-level limit;
49
62
  - a `name:` interpolating something we cannot evaluate statically.
50
63
 
64
+ `sources` is the provenance of the answer: the PR's own repo at the head
65
+ commit, then one entry per cross-repo `uses:` that was read, each carrying both
66
+ the `ref` the workflow wrote and the `sha` it resolved to. A ref is resolved
67
+ before the file behind it is read, so the commit named is the commit used. `v0`
68
+ is a tag someone moves; without the sha, "willfire predicted these checks" is
69
+ not a claim that can be checked against the run afterwards.
70
+
71
+ A ref that will not resolve is not a source. Its callee is never read, the jobs
72
+ behind it come back `unknown`, and nothing falls back to reading the mutable
73
+ ref.
74
+
51
75
  Duplicate names in `checkNames` are not possible (it is a set), but duplicate
52
76
  check names *are* — GitHub happily creates two identically named checks when a
53
77
  matrix job's `name:` does not vary per combination. `entries` shows them.
@@ -58,9 +82,13 @@ Auth is any token with `contents: read`, `actions: read`, and
58
82
  ## CLI
59
83
 
60
84
  ```sh
61
- GH_TOKEN=... willfire --repo owner/repo --pr 123 [--json]
85
+ GH_TOKEN=... willfire --repo owner/repo --pr 123 \
86
+ [--action opened|synchronize|reopened] [--json]
62
87
  ```
63
88
 
89
+ Plain-text output is one line per entry, then a `# read owner/repo@ref -> sha`
90
+ line per source. `--json` prints the whole `Prediction`, `sources` included.
91
+
64
92
  ## What it handles
65
93
 
66
94
  Path filters (`paths`, `paths-ignore`, order-sensitive `!` negation), branch
@@ -68,8 +96,8 @@ filters, event `types`, combined filters, `[skip ci]` and friends, disabled
68
96
  workflows, multi-job workflows, static matrix expansion (including
69
97
  `exclude`/`include`), `needs` skip-propagation, job-level `if`, and reusable
70
98
  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
99
+ `owner/repo/.github/workflows/x.yml@ref` form, whose ref is resolved to a
100
+ commit and the callee then read at that commit. Jobs whose `if` is false are
73
101
  predicted as `skipped` entries, matching how they appear in the checks UI.
74
102
 
75
103
  Things that cannot be known statically — e.g. a matrix computed at runtime
@@ -124,7 +152,10 @@ the docs do not state:
124
152
 
125
153
  Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
126
154
  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
155
+ probe-verified passing `action` explicitly is what makes probing the other
156
+ two possible. `action` accepts the three default `types:` only; `pull_request`
157
+ fires on around twenty actions, and a workflow narrowing to `ready_for_review`
158
+ or `edited` is out of scope either way. Reusable-workflow name prefixing is probe-verified to three
128
159
  levels; deeper nesting is inferred. The cross-repo probe calls back into the
129
160
  probe repo itself by full `owner/repo@ref` reference, so it pins ref
130
161
  resolution but not the owner/repo half of the address.
package/dist/predict.d.ts CHANGED
@@ -41,6 +41,11 @@ export interface WorkflowEntry extends EntryBase {
41
41
  * These can be genuinely undecidable statically — dynamic matrix, a reusable
42
42
  * workflow we cannot read, unresolvable `if`, or `needs` on any of those — so
43
43
  * `"unknown"` lives here and only here.
44
+ *
45
+ * `"no-dispatch"` is the mirror image, and lives on {@link WorkflowEntry} and
46
+ * only there. It is a verdict about whether the run happens at all, which is
47
+ * settled before any job is looked at — so by the time there is a job entry to
48
+ * label, the answer is already yes.
44
49
  */
45
50
  export interface JobEntry extends EntryBase {
46
51
  job: JobName;
@@ -54,7 +59,7 @@ export interface JobEntry extends EntryBase {
54
59
  * interpolates something we cannot evaluate ahead of the run.
55
60
  */
56
61
  checkName: string | null;
57
- status: "run" | "skipped" | "unknown" | "no-dispatch";
62
+ status: "run" | "skipped" | "unknown";
58
63
  }
59
64
  export type Entry = WorkflowEntry | JobEntry;
60
65
  /** Narrow to the workflow-level variant without inspecting the sentinel. */
@@ -70,10 +75,43 @@ export interface Prediction {
70
75
  */
71
76
  checkNames: string[];
72
77
  skip: string | null;
78
+ /**
79
+ * Every repo this prediction read, and the commit each ref resolved to —
80
+ * the PR's own head first, then any cross-repo `uses:` reached from it.
81
+ *
82
+ * Provenance, not input. `v0` is a moving tag, so "willfire said these
83
+ * checks" is only reconcilable against a run if it also says which commits
84
+ * it read to say it. Sorted by `owner/repo@ref`.
85
+ */
86
+ sources: WorkflowSource[];
73
87
  }
74
88
  export declare function patternToRegex(pat: string): RegExp;
75
89
  /** Order-sensitive match: last matching pattern wins; ! negates. */
76
90
  export declare function matchFilters(value: string, patterns: string[]): boolean;
91
+ /**
92
+ * The `pull_request` actions a caller can name.
93
+ *
94
+ * Deliberately the three default types and no more. `pull_request` fires on
95
+ * around twenty actions — `ready_for_review`, `edited`, `labeled` — and a
96
+ * workflow that narrows `types:` to one of those is not predictable today
97
+ * regardless of what is passed here. Widening this union later is a
98
+ * non-breaking change; narrowing it would not be, so it starts narrow.
99
+ */
100
+ export type PrEventAction = "opened" | "synchronize" | "reopened";
101
+ /** Options every caller may omit. */
102
+ export interface PredictOptions {
103
+ /**
104
+ * The literal event action the run was triggered by — `github.event.action`
105
+ * inside an Action, or `--action` on the CLI.
106
+ *
107
+ * Omitting it falls back to inferring from the commit count, which is a
108
+ * guess and is wrong in both directions: a PR opened from a branch with
109
+ * several commits looks like `synchronize`, a force-push down to one commit
110
+ * looks like `opened`, and `reopened` is never produced. That only matters
111
+ * to a workflow narrowing `types:`, where it matters completely.
112
+ */
113
+ action?: PrEventAction;
114
+ }
77
115
  export interface Ctx {
78
116
  action: string;
79
117
  baseRef: string;
@@ -112,20 +150,54 @@ export interface ExpandedJob {
112
150
  * `owner/repo/path@ref` resolves against *that* repo at *that* ref — probe
113
151
  * verified, see `src/names.test.ts`.
114
152
  */
115
- export interface WorkflowSource {
153
+ export interface SourceRef {
116
154
  owner: string;
117
155
  repo: string;
118
156
  /** Tag, branch, or SHA — whatever `@` was pinned to. */
119
157
  ref: string;
120
158
  }
159
+ /**
160
+ * A source whose ref has been resolved to the commit it names.
161
+ *
162
+ * Expansion walks these and never a bare {@link SourceRef}: `v0` is a moving
163
+ * tag, so two reads an hour apart can be two different programs, and a
164
+ * prediction that cannot name the commit it read cannot be reconciled against
165
+ * the run afterwards. Making the SHA required is what stops an unresolved ref
166
+ * being expanded against by accident.
167
+ */
168
+ export interface WorkflowSource extends SourceRef {
169
+ /** The commit `ref` names. Equal to `ref` when it was already a SHA. */
170
+ sha: string;
171
+ }
121
172
  /** Read one workflow file, or null if it is not reachable. Must not throw. */
122
173
  export type FetchWorkflow = (path: string, source: WorkflowSource) => Promise<string | null>;
174
+ /**
175
+ * Resolve a tag, branch, or SHA to the commit it names, or null when it cannot
176
+ * be resolved. Must not throw.
177
+ *
178
+ * Null is not a cue to fall back to the mutable ref. It leaves every entry
179
+ * behind that source unresolved, which turns the gate red — reading a ref we
180
+ * cannot name is the thing this exists to stop.
181
+ */
182
+ export type ResolveRef = (source: SourceRef) => Promise<string | null>;
183
+ /**
184
+ * The two reads expansion needs from the outside world, bundled so the recursion
185
+ * carries one parameter instead of two.
186
+ */
187
+ export interface WorkflowReader {
188
+ fetchWorkflow: FetchWorkflow;
189
+ resolveRef: ResolveRef;
190
+ }
123
191
  /** A `uses:` that named a workflow file we know how to go and get. */
124
192
  export interface UsesTarget {
125
193
  /** Path inside the target repo, e.g. `.github/workflows/x.yml`. */
126
194
  path: string;
127
- /** null for a local `./` call: the caller's own repo and ref. */
128
- source: WorkflowSource | null;
195
+ /**
196
+ * null for a local `./` call: the caller's own repo and ref, already
197
+ * resolved. Non-null sources arrive unresolved — the ref is whatever the
198
+ * `uses:` string spelled.
199
+ */
200
+ source: SourceRef | null;
129
201
  }
130
202
  /**
131
203
  * Split a job-level `uses:` into the file it names and the repo it lives in.
@@ -146,7 +218,7 @@ export declare function parseUses(uses: string): UsesTarget | null;
146
218
  * names can be tested against recorded GitHub behaviour without a network
147
219
  * round-trip; `predict` is the API you want.
148
220
  */
149
- export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, fetchWorkflow: FetchWorkflow, source: WorkflowSource): Promise<ExpandedJob[]>;
221
+ export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource): Promise<ExpandedJob[]>;
150
222
  export declare function makeOctokit(): Octokit;
151
- export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
223
+ export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
152
224
  export {};
package/dist/predict.js CHANGED
@@ -363,6 +363,11 @@ function calleeInputs(withBlock, subWf) {
363
363
  * mixes the two is counted the same way.
364
364
  */
365
365
  const MAX_REUSABLE_DEPTH = 4;
366
+ /** `ref` is already a commit id, so resolving it is a no-op. */
367
+ const SHA_RE = /^[0-9a-f]{40}$/i;
368
+ const isSha = (ref) => SHA_RE.test(ref);
369
+ /** Identity of a source as written, before resolution. */
370
+ const sourceKey = (s) => `${s.owner}/${s.repo}@${s.ref}`;
366
371
  /**
367
372
  * Split a job-level `uses:` into the file it names and the repo it lives in.
368
373
  *
@@ -395,7 +400,7 @@ export function parseUses(uses) {
395
400
  return null;
396
401
  return { path, source: { owner, repo, ref } };
397
402
  }
398
- async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
403
+ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
399
404
  const entries = [];
400
405
  const jobs = wf.jobs ?? {};
401
406
  const statuses = {};
@@ -466,18 +471,34 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
466
471
  failure = `unresolvable reusable reference: ${uses}`;
467
472
  }
468
473
  else {
469
- subSource = target.source ?? source;
470
- const content = await fetchWorkflow(target.path, subSource);
471
- if (content == null) {
472
- failure = `cannot fetch ${uses}`;
474
+ // A local `./` call stays on the caller's source, which is already
475
+ // pinned to a commit. A cross-repo one arrives as whatever the `uses:`
476
+ // string spelled `@v0` — and has to be resolved before anything is
477
+ // read from it, so the file that gets read and the commit the
478
+ // prediction names are the same one.
479
+ let resolved = source;
480
+ if (target.source != null) {
481
+ const { ref } = target.source;
482
+ const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
483
+ resolved = sha == null ? null : { ...target.source, sha };
484
+ }
485
+ if (resolved == null) {
486
+ failure = `cannot resolve ref for ${uses}`;
473
487
  }
474
488
  else {
475
- try {
476
- subWf = parseYaml(content);
477
- subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
489
+ subSource = resolved;
490
+ const content = await reader.fetchWorkflow(target.path, subSource);
491
+ if (content == null) {
492
+ failure = `cannot fetch ${uses}`;
478
493
  }
479
- catch (e) {
480
- failure = `YAML parse error in ${uses}: ${e}`;
494
+ else {
495
+ try {
496
+ subWf = parseYaml(content);
497
+ subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
498
+ }
499
+ catch (e) {
500
+ failure = `YAML parse error in ${uses}: ${e}`;
501
+ }
481
502
  }
482
503
  }
483
504
  }
@@ -494,7 +515,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
494
515
  });
495
516
  continue;
496
517
  }
497
- entries.push(...(await expandJobs(subWf, ctx, fetchWorkflow, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
518
+ entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
498
519
  }
499
520
  continue;
500
521
  }
@@ -525,8 +546,8 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
525
546
  * names can be tested against recorded GitHub behaviour without a network
526
547
  * round-trip; `predict` is the API you want.
527
548
  */
528
- export function expandWorkflowJobs(wf, ctx, fetchWorkflow, source) {
529
- return expandJobs(wf, ctx, fetchWorkflow, source);
549
+ export function expandWorkflowJobs(wf, ctx, reader, source) {
550
+ return expandJobs(wf, ctx, reader, source);
530
551
  }
531
552
  // ------------------------------------------------------------------- pipeline
532
553
  export function makeOctokit() {
@@ -535,7 +556,7 @@ export function makeOctokit() {
535
556
  throw new Error("GH_TOKEN or GITHUB_TOKEN must be set");
536
557
  return new Octokit({ auth: token });
537
558
  }
538
- export async function predict(octokit, repo, prNumber) {
559
+ export async function predict(octokit, repo, prNumber, opts = {}) {
539
560
  const [owner, name] = repo.split("/");
540
561
  const base = { owner, repo: name };
541
562
  const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
@@ -545,27 +566,67 @@ export async function predict(octokit, repo, prNumber) {
545
566
  per_page: 100,
546
567
  });
547
568
  const ctx = {
548
- action: pr.commits > 1 ? "synchronize" : "opened",
569
+ // The caller's answer wins whenever it has one. The commit-count fallback
570
+ // is a guess kept only so existing callers keep working.
571
+ action: opts.action ?? (pr.commits > 1 ? "synchronize" : "opened"),
549
572
  baseRef: pr.base.ref,
550
573
  files: files.map((f) => f.filename),
551
574
  };
552
575
  const headSha = pr.head.sha;
576
+ /**
577
+ * The PR's own repo at the head commit — where expansion starts, and already
578
+ * a commit id, so its `ref` and `sha` are the same string.
579
+ */
580
+ const headSource = { owner, repo: name, ref: headSha, sha: headSha };
581
+ // Provenance for the answer, filled as expansion reaches each source. The head
582
+ // is in from the start: it is read even on the skip path, where the commit
583
+ // message is what decides the verdict.
584
+ const sources = new Map([[sourceKey(headSource), headSource]]);
553
585
  const { data: headCommit } = await octokit.rest.repos.getCommit({
554
586
  ...base,
555
587
  ref: headSha,
556
588
  });
557
589
  const headMsg = headCommit.commit.message;
558
590
  if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
559
- return finalizePrediction([], "head commit message contains a skip instruction");
591
+ return finalizePrediction([], "head commit message contains a skip instruction", sources);
560
592
  }
561
- /** The PR's own repo at the head commit where expansion starts. */
562
- const headSource = { owner, repo: name, ref: headSha };
593
+ // A `uses:` naming a tag is the same lookup from every caller that writes it,
594
+ // so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
595
+ // cannot be resolved will not start resolving on the second ask.
596
+ const refCache = new Map();
597
+ const resolveRef = async (src) => {
598
+ const key = sourceKey(src);
599
+ const hit = refCache.get(key);
600
+ if (hit !== undefined)
601
+ return hit;
602
+ let sha;
603
+ try {
604
+ const { data } = await octokit.rest.repos.getCommit({
605
+ owner: src.owner,
606
+ repo: src.repo,
607
+ ref: src.ref,
608
+ });
609
+ sha = data.sha;
610
+ }
611
+ catch {
612
+ // Deleted tag, private repo, rate limit, network: all one answer here.
613
+ // The caller turns it into an `unknown` entry rather than throwing.
614
+ sha = null;
615
+ }
616
+ refCache.set(key, sha);
617
+ if (sha != null)
618
+ sources.set(key, { ...src, sha });
619
+ return sha;
620
+ };
563
621
  // One callee is commonly reached from several callers — a fleet repo calls
564
622
  // the same `testing-conventions@v0` from eight workflows — so remember what
565
- // each `owner/repo/path@ref` resolved to, misses included.
623
+ // each `owner/repo/path@sha` resolved to, misses included.
566
624
  const cache = new Map();
567
625
  const fetchWorkflow = async (path, src) => {
568
- const key = `${src.owner}/${src.repo}/${path}@${src.ref}`;
626
+ // Keyed and fetched on the commit, never the ref that named it. Two callers
627
+ // writing `@v0` and `@abc123` for the same commit are one read, and a tag
628
+ // that moves mid-prediction cannot hand back two different files.
629
+ const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
569
630
  const hit = cache.get(key);
570
631
  if (hit !== undefined)
571
632
  return hit;
@@ -575,7 +636,7 @@ export async function predict(octokit, repo, prNumber) {
575
636
  owner: src.owner,
576
637
  repo: src.repo,
577
638
  path,
578
- ref: src.ref,
639
+ ref: src.sha,
579
640
  mediaType: { format: "raw" },
580
641
  });
581
642
  content = data;
@@ -588,6 +649,7 @@ export async function predict(octokit, repo, prNumber) {
588
649
  cache.set(key, content);
589
650
  return content;
590
651
  };
652
+ const reader = { fetchWorkflow, resolveRef };
591
653
  const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
592
654
  ...base,
593
655
  per_page: 100,
@@ -640,7 +702,7 @@ export async function predict(octokit, repo, prNumber) {
640
702
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
641
703
  continue;
642
704
  }
643
- for (const j of await expandJobs(wf, ctx, fetchWorkflow, headSource)) {
705
+ for (const j of await expandJobs(wf, ctx, reader, headSource)) {
644
706
  entries.push({
645
707
  workflow: path,
646
708
  job: jobName(j.job),
@@ -650,18 +712,25 @@ export async function predict(octokit, repo, prNumber) {
650
712
  });
651
713
  }
652
714
  }
653
- return finalizePrediction(entries, null);
715
+ return finalizePrediction(entries, null, sources);
654
716
  }
655
- function finalizePrediction(entries, skip) {
717
+ function finalizePrediction(entries, skip, sources) {
656
718
  const final = entries.map(finalize);
657
719
  const names = new Set();
658
720
  for (const e of final) {
659
721
  if (e.status === "run" && e.checkName != null)
660
722
  names.add(e.checkName);
661
723
  }
662
- return { entries: final, checkNames: [...names].sort(), skip };
724
+ return {
725
+ entries: final,
726
+ checkNames: [...names].sort(),
727
+ skip,
728
+ sources: [...sources.values()].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))),
729
+ };
663
730
  }
664
731
  // ------------------------------------------------------------------------ CLI
732
+ const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened] [--json]";
733
+ const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
665
734
  function parseArgs(argv) {
666
735
  const get = (flag) => {
667
736
  const i = argv.indexOf(flag);
@@ -670,29 +739,47 @@ function parseArgs(argv) {
670
739
  const repo = get("--repo");
671
740
  const pr = get("--pr");
672
741
  if (!repo || !pr) {
673
- console.error("usage: predict --repo owner/name --pr N [--json]");
742
+ console.error(USAGE);
674
743
  process.exit(2);
675
744
  }
676
- return { repo, pr: Number(pr), json: argv.includes("--json") };
745
+ // An unrecognised action is refused rather than ignored. Silently falling
746
+ // back to the guess would turn a typo into a wrong prediction, which is the
747
+ // failure this flag exists to remove.
748
+ const action = get("--action");
749
+ if (action !== undefined && !isPrEventAction(action)) {
750
+ console.error(`unknown --action: ${action}`);
751
+ console.error(USAGE);
752
+ process.exit(2);
753
+ }
754
+ return { repo, pr: Number(pr), json: argv.includes("--json"), action };
677
755
  }
678
756
  const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
679
757
  if (isMain) {
680
758
  const args = parseArgs(process.argv.slice(2));
681
- const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr);
759
+ const prediction = await predict(makeOctokit(), args.repo, args.pr, {
760
+ action: args.action,
761
+ });
762
+ const { entries, skip, sources } = prediction;
682
763
  if (args.json) {
683
- console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
684
- }
685
- else if (skip) {
686
- console.log(`# ${skip} -> nothing dispatches`);
764
+ console.log(JSON.stringify(prediction, null, 2));
687
765
  }
688
766
  else {
689
- for (const e of entries) {
690
- if (isWorkflowEntry(e))
691
- console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
692
- else {
693
- const name = e.checkName ?? `${e.job} (name unresolved)`;
694
- console.log(`${e.workflow} :: ${name} :: ${e.status}`);
767
+ if (skip) {
768
+ console.log(`# ${skip} -> nothing dispatches`);
769
+ }
770
+ else {
771
+ for (const e of entries) {
772
+ if (isWorkflowEntry(e))
773
+ console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
774
+ else {
775
+ const name = e.checkName ?? `${e.job} (name unresolved)`;
776
+ console.log(`${e.workflow} :: ${name} :: ${e.status}`);
777
+ }
695
778
  }
696
779
  }
780
+ // Last, and on the skip path too, so a red gate's first question — which
781
+ // commits was this read from? — is answered wherever the reader lands.
782
+ for (const s of sources)
783
+ console.log(`# read ${s.owner}/${s.repo}@${s.ref} -> ${s.sha}`);
697
784
  }
698
785
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@10.33.0",