willfire 0.1.9 → 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,14 @@ 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, {
24
- action: context.payload.action, // "opened" | "synchronize" | "reopened"
25
- });
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
+ );
26
29
  // checkNames: sorted, deduped checkName of every entry with status "run"
30
+ // sources: every repo read, and the commit each ref resolved to
27
31
  ```
28
32
 
29
33
  `action` is optional but worth passing. Omitted, the event action is inferred
@@ -57,6 +61,17 @@ single name is knowable ahead of the run:
57
61
  four-level limit;
58
62
  - a `name:` interpolating something we cannot evaluate statically.
59
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
+
60
75
  Duplicate names in `checkNames` are not possible (it is a set), but duplicate
61
76
  check names *are* — GitHub happily creates two identically named checks when a
62
77
  matrix job's `name:` does not vary per combination. `entries` shows them.
@@ -71,6 +86,9 @@ GH_TOKEN=... willfire --repo owner/repo --pr 123 \
71
86
  [--action opened|synchronize|reopened] [--json]
72
87
  ```
73
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
+
74
92
  ## What it handles
75
93
 
76
94
  Path filters (`paths`, `paths-ignore`, order-sensitive `!` negation), branch
@@ -78,8 +96,8 @@ filters, event `types`, combined filters, `[skip ci]` and friends, disabled
78
96
  workflows, multi-job workflows, static matrix expansion (including
79
97
  `exclude`/`include`), `needs` skip-propagation, job-level `if`, and reusable
80
98
  workflows — both the local `./.github/workflows/x.yml` form and the cross-repo
81
- `owner/repo/.github/workflows/x.yml@ref` form, whose callee is fetched from its
82
- 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
83
101
  predicted as `skipped` entries, matching how they appear in the checks UI.
84
102
 
85
103
  Things that cannot be known statically — e.g. a matrix computed at runtime
package/dist/predict.d.ts CHANGED
@@ -75,6 +75,15 @@ export interface Prediction {
75
75
  */
76
76
  checkNames: string[];
77
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[];
78
87
  }
79
88
  export declare function patternToRegex(pat: string): RegExp;
80
89
  /** Order-sensitive match: last matching pattern wins; ! negates. */
@@ -141,20 +150,54 @@ export interface ExpandedJob {
141
150
  * `owner/repo/path@ref` resolves against *that* repo at *that* ref — probe
142
151
  * verified, see `src/names.test.ts`.
143
152
  */
144
- export interface WorkflowSource {
153
+ export interface SourceRef {
145
154
  owner: string;
146
155
  repo: string;
147
156
  /** Tag, branch, or SHA — whatever `@` was pinned to. */
148
157
  ref: string;
149
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
+ }
150
172
  /** Read one workflow file, or null if it is not reachable. Must not throw. */
151
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
+ }
152
191
  /** A `uses:` that named a workflow file we know how to go and get. */
153
192
  export interface UsesTarget {
154
193
  /** Path inside the target repo, e.g. `.github/workflows/x.yml`. */
155
194
  path: string;
156
- /** null for a local `./` call: the caller's own repo and ref. */
157
- 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;
158
201
  }
159
202
  /**
160
203
  * Split a job-level `uses:` into the file it names and the repo it lives in.
@@ -175,7 +218,7 @@ export declare function parseUses(uses: string): UsesTarget | null;
175
218
  * names can be tested against recorded GitHub behaviour without a network
176
219
  * round-trip; `predict` is the API you want.
177
220
  */
178
- 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[]>;
179
222
  export declare function makeOctokit(): Octokit;
180
223
  export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
181
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() {
@@ -552,22 +573,60 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
552
573
  files: files.map((f) => f.filename),
553
574
  };
554
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]]);
555
585
  const { data: headCommit } = await octokit.rest.repos.getCommit({
556
586
  ...base,
557
587
  ref: headSha,
558
588
  });
559
589
  const headMsg = headCommit.commit.message;
560
590
  if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
561
- return finalizePrediction([], "head commit message contains a skip instruction");
591
+ return finalizePrediction([], "head commit message contains a skip instruction", sources);
562
592
  }
563
- /** The PR's own repo at the head commit where expansion starts. */
564
- 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
+ };
565
621
  // One callee is commonly reached from several callers — a fleet repo calls
566
622
  // the same `testing-conventions@v0` from eight workflows — so remember what
567
- // each `owner/repo/path@ref` resolved to, misses included.
623
+ // each `owner/repo/path@sha` resolved to, misses included.
568
624
  const cache = new Map();
569
625
  const fetchWorkflow = async (path, src) => {
570
- 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}`;
571
630
  const hit = cache.get(key);
572
631
  if (hit !== undefined)
573
632
  return hit;
@@ -577,7 +636,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
577
636
  owner: src.owner,
578
637
  repo: src.repo,
579
638
  path,
580
- ref: src.ref,
639
+ ref: src.sha,
581
640
  mediaType: { format: "raw" },
582
641
  });
583
642
  content = data;
@@ -590,6 +649,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
590
649
  cache.set(key, content);
591
650
  return content;
592
651
  };
652
+ const reader = { fetchWorkflow, resolveRef };
593
653
  const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
594
654
  ...base,
595
655
  per_page: 100,
@@ -642,7 +702,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
642
702
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
643
703
  continue;
644
704
  }
645
- for (const j of await expandJobs(wf, ctx, fetchWorkflow, headSource)) {
705
+ for (const j of await expandJobs(wf, ctx, reader, headSource)) {
646
706
  entries.push({
647
707
  workflow: path,
648
708
  job: jobName(j.job),
@@ -652,16 +712,21 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
652
712
  });
653
713
  }
654
714
  }
655
- return finalizePrediction(entries, null);
715
+ return finalizePrediction(entries, null, sources);
656
716
  }
657
- function finalizePrediction(entries, skip) {
717
+ function finalizePrediction(entries, skip, sources) {
658
718
  const final = entries.map(finalize);
659
719
  const names = new Set();
660
720
  for (const e of final) {
661
721
  if (e.status === "run" && e.checkName != null)
662
722
  names.add(e.checkName);
663
723
  }
664
- 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
+ };
665
730
  }
666
731
  // ------------------------------------------------------------------------ CLI
667
732
  const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened] [--json]";
@@ -691,23 +756,30 @@ function parseArgs(argv) {
691
756
  const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
692
757
  if (isMain) {
693
758
  const args = parseArgs(process.argv.slice(2));
694
- const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr, {
759
+ const prediction = await predict(makeOctokit(), args.repo, args.pr, {
695
760
  action: args.action,
696
761
  });
762
+ const { entries, skip, sources } = prediction;
697
763
  if (args.json) {
698
- console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
699
- }
700
- else if (skip) {
701
- console.log(`# ${skip} -> nothing dispatches`);
764
+ console.log(JSON.stringify(prediction, null, 2));
702
765
  }
703
766
  else {
704
- for (const e of entries) {
705
- if (isWorkflowEntry(e))
706
- console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
707
- else {
708
- const name = e.checkName ?? `${e.job} (name unresolved)`;
709
- 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
+ }
710
778
  }
711
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}`);
712
784
  }
713
785
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.9",
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",