willfire 0.1.5 → 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 +24 -12
- package/dist/predict.d.ts +43 -5
- package/dist/predict.js +78 -20
- package/package.json +1 -1
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
|
|
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,9 +66,11 @@ 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
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
@@ -96,10 +100,10 @@ workflow per dispatch rule, and probe PRs exercise each complication
|
|
|
96
100
|
non-default branches). The `verify` script diffs predictions against the
|
|
97
101
|
check entries GitHub actually created. All probe PRs currently pass exactly.
|
|
98
102
|
|
|
99
|
-
Check-name resolution is verified the same way, on probe
|
|
100
|
-
replays those probe workflows through the resolver and asserts the
|
|
101
|
-
names the live run produced. Several of the rules it pins are ones
|
|
102
|
-
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:
|
|
103
107
|
|
|
104
108
|
- a `name:` containing *any* `${{ }}` expression suppresses the matrix
|
|
105
109
|
parenthetical, even an expression that never reads the matrix — a literal
|
|
@@ -110,9 +114,17 @@ not state:
|
|
|
110
114
|
- object matrix values flatten to their own values, so `{os: linux, arch: x64}`
|
|
111
115
|
renders as `(linux, x64)`;
|
|
112
116
|
- a skipped job is never set up, so its matrix does not expand and its `name:`
|
|
113
|
-
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.
|
|
114
124
|
|
|
115
125
|
Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
|
|
116
|
-
live events,
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
41
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
11
|
-
// are pinned in
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
359
|
-
failure = `
|
|
399
|
+
else if (target == null) {
|
|
400
|
+
failure = `unresolvable reusable reference: ${uses}`;
|
|
360
401
|
}
|
|
361
402
|
else {
|
|
362
|
-
|
|
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,
|
|
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,
|
|
420
|
-
return expandJobs(wf, ctx,
|
|
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
|
-
|
|
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
|
-
|
|
508
|
+
owner: src.owner,
|
|
509
|
+
repo: src.repo,
|
|
456
510
|
path,
|
|
457
|
-
ref:
|
|
511
|
+
ref: src.ref,
|
|
458
512
|
mediaType: { format: "raw" },
|
|
459
513
|
});
|
|
460
|
-
|
|
514
|
+
content = data;
|
|
461
515
|
}
|
|
462
516
|
catch {
|
|
463
|
-
|
|
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
|
|
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,
|
|
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),
|