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 +23 -5
- package/dist/predict.d.ts +47 -4
- package/dist/predict.js +106 -34
- package/package.json +1 -1
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(
|
|
24
|
-
|
|
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
|
|
82
|
-
|
|
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
|
|
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
|
-
/**
|
|
157
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
489
|
+
subSource = resolved;
|
|
490
|
+
const content = await reader.fetchWorkflow(target.path, subSource);
|
|
491
|
+
if (content == null) {
|
|
492
|
+
failure = `cannot fetch ${uses}`;
|
|
478
493
|
}
|
|
479
|
-
|
|
480
|
-
|
|
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,
|
|
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,
|
|
529
|
-
return expandJobs(wf, ctx,
|
|
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
|
-
|
|
564
|
-
|
|
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@
|
|
623
|
+
// each `owner/repo/path@sha` resolved to, misses included.
|
|
568
624
|
const cache = new Map();
|
|
569
625
|
const fetchWorkflow = async (path, src) => {
|
|
570
|
-
|
|
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.
|
|
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,
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
}
|