willfire 0.1.5 → 0.1.7
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/expr.d.ts +69 -0
- package/dist/expr.js +388 -0
- package/dist/predict.d.ts +52 -7
- package/dist/predict.js +157 -35
- 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/expr.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tri-state evaluator for the slice of GitHub expressions that job `if:`
|
|
3
|
+
* conditions actually use.
|
|
4
|
+
*
|
|
5
|
+
* The point is not to reimplement the expression language. It is to stop
|
|
6
|
+
* returning `unknown` for conditions that are already decided. A reusable
|
|
7
|
+
* workflow's jobs commonly guard on the caller's own literals:
|
|
8
|
+
*
|
|
9
|
+
* if: ${{ github.event_name == 'pull_request'
|
|
10
|
+
* && (inputs.gates == '' || contains(inputs.gates, '"mutation"'))
|
|
11
|
+
* && (needs.detect.outputs.mutation_languages || ...) != '[]' }}
|
|
12
|
+
*
|
|
13
|
+
* The last clause needs a job that has not run yet. The middle one does not:
|
|
14
|
+
* the caller passed `gates` as a literal string, and it does not contain
|
|
15
|
+
* `"mutation"`, so the clause is false, so the whole `&&` is false whatever
|
|
16
|
+
* `detect` reports. Deciding that is the difference between a predictable
|
|
17
|
+
* check name and a hole in the prediction.
|
|
18
|
+
*
|
|
19
|
+
* Two ideas carry the whole file:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Truthiness can be known when the value is not.** `A && B` with an
|
|
22
|
+
* unknown `A` and a false `B` is false either way. So the lattice has four
|
|
23
|
+
* points, not three: a concrete value, known-truthy, known-falsy, and
|
|
24
|
+
* nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Unrecognized is unknown, never a guess.** An unparseable condition, an
|
|
27
|
+
* unsupported function, a comparison between types we do not model — all
|
|
28
|
+
* collapse to `unknown` and leave the caller exactly where it was. This
|
|
29
|
+
* adds no tolerance and no third outcome; it converts conditions that are
|
|
30
|
+
* already decidable into the answer they already have.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* A partially-known value.
|
|
34
|
+
*
|
|
35
|
+
* `truthy` and `falsy` exist because short-circuiting yields truthiness
|
|
36
|
+
* without a value: `unknown && false` is falsy, but there is no string to
|
|
37
|
+
* hand back for it. Collapsing those into `unknown` would throw away the
|
|
38
|
+
* only fact worth having.
|
|
39
|
+
*/
|
|
40
|
+
export type Val = {
|
|
41
|
+
kind: "value";
|
|
42
|
+
v: string | number | boolean;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "truthy";
|
|
45
|
+
} | {
|
|
46
|
+
kind: "falsy";
|
|
47
|
+
} | {
|
|
48
|
+
kind: "unknown";
|
|
49
|
+
};
|
|
50
|
+
export declare const UNKNOWN: Val;
|
|
51
|
+
/** What a bare path resolves against. Anything absent is unknown, not empty. */
|
|
52
|
+
export interface Scope {
|
|
53
|
+
/**
|
|
54
|
+
* The inputs the caller passed, plus declared defaults for the ones it did
|
|
55
|
+
* not. A key mapped to `unknown` is deliberate: the caller supplied it, but
|
|
56
|
+
* as a `${{ }}` template we cannot evaluate. That is different from absent.
|
|
57
|
+
*/
|
|
58
|
+
inputs?: Record<string, Val>;
|
|
59
|
+
/** `github.*` values that are fixed for the run being predicted. */
|
|
60
|
+
github?: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Evaluate a condition to a truthiness, or null when it cannot be settled.
|
|
64
|
+
*
|
|
65
|
+
* The `${{ }}` wrapper is optional in `if:` and stripped when present. A
|
|
66
|
+
* condition that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
67
|
+
* interpolation rather than an expression, and is not modelled.
|
|
68
|
+
*/
|
|
69
|
+
export declare function evaluate(cond: string, scope?: Scope): boolean | null;
|
package/dist/expr.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tri-state evaluator for the slice of GitHub expressions that job `if:`
|
|
3
|
+
* conditions actually use.
|
|
4
|
+
*
|
|
5
|
+
* The point is not to reimplement the expression language. It is to stop
|
|
6
|
+
* returning `unknown` for conditions that are already decided. A reusable
|
|
7
|
+
* workflow's jobs commonly guard on the caller's own literals:
|
|
8
|
+
*
|
|
9
|
+
* if: ${{ github.event_name == 'pull_request'
|
|
10
|
+
* && (inputs.gates == '' || contains(inputs.gates, '"mutation"'))
|
|
11
|
+
* && (needs.detect.outputs.mutation_languages || ...) != '[]' }}
|
|
12
|
+
*
|
|
13
|
+
* The last clause needs a job that has not run yet. The middle one does not:
|
|
14
|
+
* the caller passed `gates` as a literal string, and it does not contain
|
|
15
|
+
* `"mutation"`, so the clause is false, so the whole `&&` is false whatever
|
|
16
|
+
* `detect` reports. Deciding that is the difference between a predictable
|
|
17
|
+
* check name and a hole in the prediction.
|
|
18
|
+
*
|
|
19
|
+
* Two ideas carry the whole file:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Truthiness can be known when the value is not.** `A && B` with an
|
|
22
|
+
* unknown `A` and a false `B` is false either way. So the lattice has four
|
|
23
|
+
* points, not three: a concrete value, known-truthy, known-falsy, and
|
|
24
|
+
* nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Unrecognized is unknown, never a guess.** An unparseable condition, an
|
|
27
|
+
* unsupported function, a comparison between types we do not model — all
|
|
28
|
+
* collapse to `unknown` and leave the caller exactly where it was. This
|
|
29
|
+
* adds no tolerance and no third outcome; it converts conditions that are
|
|
30
|
+
* already decidable into the answer they already have.
|
|
31
|
+
*/
|
|
32
|
+
export const UNKNOWN = { kind: "unknown" };
|
|
33
|
+
/**
|
|
34
|
+
* GitHub's truthiness: empty string, zero, `false` and null are false, and
|
|
35
|
+
* every other value is true. `'0'` and `'false'` are non-empty strings, so
|
|
36
|
+
* both are true — the same trap as JavaScript, kept deliberately identical.
|
|
37
|
+
*/
|
|
38
|
+
function truthy(val) {
|
|
39
|
+
switch (val.kind) {
|
|
40
|
+
case "truthy":
|
|
41
|
+
return true;
|
|
42
|
+
case "falsy":
|
|
43
|
+
return false;
|
|
44
|
+
case "unknown":
|
|
45
|
+
return null;
|
|
46
|
+
case "value": {
|
|
47
|
+
const v = val.v;
|
|
48
|
+
if (typeof v === "boolean")
|
|
49
|
+
return v;
|
|
50
|
+
if (typeof v === "number")
|
|
51
|
+
return v !== 0;
|
|
52
|
+
return v !== "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Wrap a decided answer as a concrete boolean value.
|
|
58
|
+
*
|
|
59
|
+
* Comparison, negation and the string functions all yield a real boolean, so
|
|
60
|
+
* they produce a `value` — which keeps `!x == true` comparable. Only
|
|
61
|
+
* short-circuiting produces the bare `truthy`/`falsy` points, because that is
|
|
62
|
+
* the one case where truthiness is known and the value is not.
|
|
63
|
+
*/
|
|
64
|
+
function asBool(b) {
|
|
65
|
+
return b === null ? UNKNOWN : { kind: "value", v: b };
|
|
66
|
+
}
|
|
67
|
+
const OPS = ["&&", "||", "==", "!=", "<=", ">=", "!", "<", ">", "(", ")", ","];
|
|
68
|
+
/**
|
|
69
|
+
* Split a condition into tokens, or return null if it contains something this
|
|
70
|
+
* evaluator has no token for. Returning null rather than throwing keeps the
|
|
71
|
+
* "unrecognized is unknown" rule in one place at the top of `evaluate`.
|
|
72
|
+
*/
|
|
73
|
+
function tokenize(src) {
|
|
74
|
+
const out = [];
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < src.length) {
|
|
77
|
+
const c = src[i];
|
|
78
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Single-quoted string. GitHub escapes an inner quote by doubling it.
|
|
83
|
+
if (c === "'") {
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
let s = "";
|
|
86
|
+
for (;;) {
|
|
87
|
+
if (j >= src.length)
|
|
88
|
+
return null; // unterminated
|
|
89
|
+
if (src[j] === "'") {
|
|
90
|
+
if (src[j + 1] === "'") {
|
|
91
|
+
s += "'";
|
|
92
|
+
j += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
j++;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
s += src[j];
|
|
99
|
+
j++;
|
|
100
|
+
}
|
|
101
|
+
out.push({ t: "str", v: s });
|
|
102
|
+
i = j;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const op = OPS.find((o) => src.startsWith(o, i));
|
|
106
|
+
if (op != null) {
|
|
107
|
+
out.push({ t: "op", v: op });
|
|
108
|
+
i += op.length;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const word = /^[A-Za-z_][A-Za-z0-9_.\-]*/.exec(src.slice(i));
|
|
112
|
+
if (word != null) {
|
|
113
|
+
const w = word[0];
|
|
114
|
+
i += w.length;
|
|
115
|
+
const lower = w.toLowerCase();
|
|
116
|
+
if (lower === "true")
|
|
117
|
+
out.push({ t: "bool", v: true });
|
|
118
|
+
else if (lower === "false")
|
|
119
|
+
out.push({ t: "bool", v: false });
|
|
120
|
+
else if (lower === "null")
|
|
121
|
+
out.push({ t: "null" });
|
|
122
|
+
else
|
|
123
|
+
out.push({ t: "path", v: w });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
|
|
127
|
+
if (num != null) {
|
|
128
|
+
out.push({ t: "num", v: Number(num[0]) });
|
|
129
|
+
i += num[0].length;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
return null; // a character we have no token for
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
// --------------------------------------------------------------------- parser
|
|
137
|
+
/**
|
|
138
|
+
* Recursive descent over GitHub's precedence order, loosest first:
|
|
139
|
+
* `||`, then `&&`, then comparison, then `!`, then a primary.
|
|
140
|
+
*
|
|
141
|
+
* `&&` and `||` are value operators, not boolean ones — `a || b` yields the
|
|
142
|
+
* first truthy operand, which is why `(x || y) != '[]'` parses as a
|
|
143
|
+
* comparison against a coalesced value rather than a boolean.
|
|
144
|
+
*/
|
|
145
|
+
class Parser {
|
|
146
|
+
toks;
|
|
147
|
+
scope;
|
|
148
|
+
pos = 0;
|
|
149
|
+
constructor(toks, scope) {
|
|
150
|
+
this.toks = toks;
|
|
151
|
+
this.scope = scope;
|
|
152
|
+
}
|
|
153
|
+
peek() {
|
|
154
|
+
return this.toks[this.pos];
|
|
155
|
+
}
|
|
156
|
+
eatOp(v) {
|
|
157
|
+
const t = this.peek();
|
|
158
|
+
if (t != null && t.t === "op" && t.v === v) {
|
|
159
|
+
this.pos++;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
done() {
|
|
165
|
+
return this.pos >= this.toks.length;
|
|
166
|
+
}
|
|
167
|
+
or() {
|
|
168
|
+
let left = this.and();
|
|
169
|
+
while (this.eatOp("||")) {
|
|
170
|
+
const right = this.and();
|
|
171
|
+
left = coalesceOr(left, right);
|
|
172
|
+
}
|
|
173
|
+
return left;
|
|
174
|
+
}
|
|
175
|
+
and() {
|
|
176
|
+
let left = this.cmp();
|
|
177
|
+
while (this.eatOp("&&")) {
|
|
178
|
+
const right = this.cmp();
|
|
179
|
+
left = coalesceAnd(left, right);
|
|
180
|
+
}
|
|
181
|
+
return left;
|
|
182
|
+
}
|
|
183
|
+
cmp() {
|
|
184
|
+
const left = this.unary();
|
|
185
|
+
for (const op of ["==", "!=", "<=", ">=", "<", ">"]) {
|
|
186
|
+
if (this.eatOp(op)) {
|
|
187
|
+
const right = this.unary();
|
|
188
|
+
return compare(op, left, right);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return left;
|
|
192
|
+
}
|
|
193
|
+
unary() {
|
|
194
|
+
if (this.eatOp("!")) {
|
|
195
|
+
const v = this.unary();
|
|
196
|
+
return asBool(negate(truthy(v)));
|
|
197
|
+
}
|
|
198
|
+
return this.primary();
|
|
199
|
+
}
|
|
200
|
+
primary() {
|
|
201
|
+
const t = this.peek();
|
|
202
|
+
if (t == null)
|
|
203
|
+
return UNKNOWN;
|
|
204
|
+
if (t.t === "op" && t.v === "(") {
|
|
205
|
+
this.pos++;
|
|
206
|
+
const v = this.or();
|
|
207
|
+
if (!this.eatOp(")"))
|
|
208
|
+
return UNKNOWN;
|
|
209
|
+
return v;
|
|
210
|
+
}
|
|
211
|
+
if (t.t === "str") {
|
|
212
|
+
this.pos++;
|
|
213
|
+
return { kind: "value", v: t.v };
|
|
214
|
+
}
|
|
215
|
+
if (t.t === "num") {
|
|
216
|
+
this.pos++;
|
|
217
|
+
return { kind: "value", v: t.v };
|
|
218
|
+
}
|
|
219
|
+
if (t.t === "bool") {
|
|
220
|
+
this.pos++;
|
|
221
|
+
return { kind: "value", v: t.v };
|
|
222
|
+
}
|
|
223
|
+
if (t.t === "null") {
|
|
224
|
+
this.pos++;
|
|
225
|
+
return { kind: "value", v: "" };
|
|
226
|
+
}
|
|
227
|
+
if (t.t === "path") {
|
|
228
|
+
this.pos++;
|
|
229
|
+
// A `(` right after a name makes it a call, not a path.
|
|
230
|
+
if (this.eatOp("("))
|
|
231
|
+
return this.call(t.v);
|
|
232
|
+
return this.lookup(t.v);
|
|
233
|
+
}
|
|
234
|
+
return UNKNOWN;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A function call. Arguments are always parsed, even for functions we cannot
|
|
238
|
+
* evaluate — the tokens have to be consumed either way or the rest of the
|
|
239
|
+
* expression parses against the wrong position.
|
|
240
|
+
*/
|
|
241
|
+
call(name) {
|
|
242
|
+
const args = [];
|
|
243
|
+
if (!this.eatOp(")")) {
|
|
244
|
+
for (;;) {
|
|
245
|
+
args.push(this.or());
|
|
246
|
+
if (this.eatOp(","))
|
|
247
|
+
continue;
|
|
248
|
+
if (this.eatOp(")"))
|
|
249
|
+
break;
|
|
250
|
+
return UNKNOWN; // malformed argument list
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return applyFunction(name.toLowerCase(), args);
|
|
254
|
+
}
|
|
255
|
+
lookup(path) {
|
|
256
|
+
const dot = path.indexOf(".");
|
|
257
|
+
if (dot < 0)
|
|
258
|
+
return UNKNOWN;
|
|
259
|
+
const head = path.slice(0, dot);
|
|
260
|
+
const rest = path.slice(dot + 1);
|
|
261
|
+
if (head === "inputs")
|
|
262
|
+
return this.scope.inputs?.[rest] ?? UNKNOWN;
|
|
263
|
+
if (head === "github") {
|
|
264
|
+
const v = this.scope.github?.[rest];
|
|
265
|
+
return v === undefined ? UNKNOWN : { kind: "value", v };
|
|
266
|
+
}
|
|
267
|
+
// `needs.*`, `steps.*`, `matrix.*`, `env.*`, `vars.*`, `secrets.*`: all
|
|
268
|
+
// require something that has not happened yet at prediction time. This is
|
|
269
|
+
// the seam where a `needs` context would attach if willfire ever computes
|
|
270
|
+
// a called workflow's outputs ahead of the run.
|
|
271
|
+
return UNKNOWN;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function negate(b) {
|
|
275
|
+
return b === null ? null : !b;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `A && B`. Short-circuits from either side: a falsy left decides it, and so
|
|
279
|
+
* does a falsy right, because a truthy left would then yield the falsy right.
|
|
280
|
+
* Only "left unknown, right not falsy" is genuinely undecided.
|
|
281
|
+
*/
|
|
282
|
+
function coalesceAnd(left, right) {
|
|
283
|
+
const l = truthy(left);
|
|
284
|
+
if (l === false)
|
|
285
|
+
return left;
|
|
286
|
+
if (l === true)
|
|
287
|
+
return right;
|
|
288
|
+
return truthy(right) === false ? { kind: "falsy" } : UNKNOWN;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* `A || B`. The mirror image: a truthy left decides it, and so does a truthy
|
|
292
|
+
* right, since a falsy left would then yield the truthy right.
|
|
293
|
+
*/
|
|
294
|
+
function coalesceOr(left, right) {
|
|
295
|
+
const l = truthy(left);
|
|
296
|
+
if (l === true)
|
|
297
|
+
return left;
|
|
298
|
+
if (l === false)
|
|
299
|
+
return right;
|
|
300
|
+
return truthy(right) === true ? { kind: "truthy" } : UNKNOWN;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Comparison, deliberately narrow: both sides must be concrete and of the same
|
|
304
|
+
* primitive type.
|
|
305
|
+
*
|
|
306
|
+
* GitHub coerces across types when it compares, and the corner cases are
|
|
307
|
+
* genuinely surprising (`'' == 0` is true). Every comparison that matters in
|
|
308
|
+
* practice is string-to-string — `inputs.gates == ''`,
|
|
309
|
+
* `github.event_name == 'pull_request'` — so modelling the coercion table
|
|
310
|
+
* would add risk without adding reach. Mixed types return unknown.
|
|
311
|
+
*/
|
|
312
|
+
function compare(op, left, right) {
|
|
313
|
+
if (left.kind !== "value" || right.kind !== "value")
|
|
314
|
+
return UNKNOWN;
|
|
315
|
+
const a = left.v;
|
|
316
|
+
const b = right.v;
|
|
317
|
+
if (typeof a !== typeof b)
|
|
318
|
+
return UNKNOWN;
|
|
319
|
+
if (op === "==")
|
|
320
|
+
return asBool(a === b);
|
|
321
|
+
if (op === "!=")
|
|
322
|
+
return asBool(a !== b);
|
|
323
|
+
// Ordering on booleans is not modelled. GitHub coerces them to numbers, and
|
|
324
|
+
// the answer is never one a workflow author meant to ask for.
|
|
325
|
+
if (typeof a === "boolean" || typeof b === "boolean")
|
|
326
|
+
return UNKNOWN;
|
|
327
|
+
if (op === "<")
|
|
328
|
+
return asBool(a < b);
|
|
329
|
+
if (op === "<=")
|
|
330
|
+
return asBool(a <= b);
|
|
331
|
+
if (op === ">")
|
|
332
|
+
return asBool(a > b);
|
|
333
|
+
return asBool(a >= b);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The functions worth modelling.
|
|
337
|
+
*
|
|
338
|
+
* `always()` is true by definition. `contains` on two known strings is the one
|
|
339
|
+
* that unlocks the fleet's `gates` pattern. `success()`, `failure()` and
|
|
340
|
+
* `cancelled()` depend on jobs that have not run, and everything else is
|
|
341
|
+
* simply not modelled — all unknown.
|
|
342
|
+
*/
|
|
343
|
+
function applyFunction(name, args) {
|
|
344
|
+
if (name === "always")
|
|
345
|
+
return { kind: "value", v: true };
|
|
346
|
+
if (name === "contains" && args.length === 2) {
|
|
347
|
+
const [hay, needle] = args;
|
|
348
|
+
if (hay.kind !== "value" || needle.kind !== "value")
|
|
349
|
+
return UNKNOWN;
|
|
350
|
+
if (typeof hay.v !== "string" || typeof needle.v !== "string")
|
|
351
|
+
return UNKNOWN;
|
|
352
|
+
return asBool(hay.v.includes(needle.v));
|
|
353
|
+
}
|
|
354
|
+
if ((name === "startswith" || name === "endswith") && args.length === 2) {
|
|
355
|
+
const [s, part] = args;
|
|
356
|
+
if (s.kind !== "value" || part.kind !== "value")
|
|
357
|
+
return UNKNOWN;
|
|
358
|
+
if (typeof s.v !== "string" || typeof part.v !== "string")
|
|
359
|
+
return UNKNOWN;
|
|
360
|
+
return asBool(name === "startswith" ? s.v.startsWith(part.v) : s.v.endsWith(part.v));
|
|
361
|
+
}
|
|
362
|
+
return UNKNOWN;
|
|
363
|
+
}
|
|
364
|
+
// ---------------------------------------------------------------------- entry
|
|
365
|
+
/**
|
|
366
|
+
* Evaluate a condition to a truthiness, or null when it cannot be settled.
|
|
367
|
+
*
|
|
368
|
+
* The `${{ }}` wrapper is optional in `if:` and stripped when present. A
|
|
369
|
+
* condition that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
370
|
+
* interpolation rather than an expression, and is not modelled.
|
|
371
|
+
*/
|
|
372
|
+
export function evaluate(cond, scope = {}) {
|
|
373
|
+
const stripped = cond.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
374
|
+
if (stripped === "")
|
|
375
|
+
return null;
|
|
376
|
+
if (stripped.includes("${{"))
|
|
377
|
+
return null;
|
|
378
|
+
const toks = tokenize(stripped);
|
|
379
|
+
if (toks == null || toks.length === 0)
|
|
380
|
+
return null;
|
|
381
|
+
const p = new Parser(toks, scope);
|
|
382
|
+
const val = p.or();
|
|
383
|
+
// Trailing tokens mean the grammar did not cover this condition; whatever
|
|
384
|
+
// was parsed describes only a prefix of it, so it decides nothing.
|
|
385
|
+
if (!p.done())
|
|
386
|
+
return null;
|
|
387
|
+
return truthy(val);
|
|
388
|
+
}
|
package/dist/predict.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Octokit } from "@octokit/rest";
|
|
3
|
+
import { type Scope } from "./expr.js";
|
|
3
4
|
interface EntryBase {
|
|
4
5
|
workflow: string;
|
|
5
6
|
reason: string;
|
|
@@ -37,8 +38,8 @@ export interface WorkflowEntry extends EntryBase {
|
|
|
37
38
|
/**
|
|
38
39
|
* A verdict about one job entry inside a workflow that does dispatch.
|
|
39
40
|
*
|
|
40
|
-
* These can be genuinely undecidable statically — dynamic matrix,
|
|
41
|
-
*
|
|
41
|
+
* These can be genuinely undecidable statically — dynamic matrix, a reusable
|
|
42
|
+
* workflow we cannot read, unresolvable `if`, or `needs` on any of those — so
|
|
42
43
|
* `"unknown"` lives here and only here.
|
|
43
44
|
*/
|
|
44
45
|
export interface JobEntry extends EntryBase {
|
|
@@ -49,8 +50,8 @@ export interface JobEntry extends EntryBase {
|
|
|
49
50
|
* `<caller> / <callee>` prefixing for reusable workflows.
|
|
50
51
|
*
|
|
51
52
|
* 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.
|
|
53
|
+
* at runtime, a reusable workflow we could not fetch, or a `name:` that
|
|
54
|
+
* interpolates something we cannot evaluate ahead of the run.
|
|
54
55
|
*/
|
|
55
56
|
checkName: string | null;
|
|
56
57
|
status: "run" | "skipped" | "unknown" | "no-dispatch";
|
|
@@ -82,8 +83,14 @@ type Workflow = Record<string, any>;
|
|
|
82
83
|
type Combo = Record<string, any> | null;
|
|
83
84
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
84
85
|
export declare function expandMatrix(strategy: any): Combo[] | null;
|
|
85
|
-
/**
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Return run|skipped|unknown for a job-level `if:`.
|
|
88
|
+
*
|
|
89
|
+
* `scope` carries the inputs the calling workflow passed down. Without it a
|
|
90
|
+
* reusable workflow's guards are all unknown, because every one of them is
|
|
91
|
+
* written against `inputs.*`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function evalIf(cond: any, scope?: Scope): "run" | "skipped" | "unknown";
|
|
87
94
|
/**
|
|
88
95
|
* One expanded job, before it is paired with its workflow. Named `ExpandedJob`
|
|
89
96
|
* because `JobEntry` is now the exported job-level variant of `Entry`.
|
|
@@ -96,12 +103,50 @@ export interface ExpandedJob {
|
|
|
96
103
|
status: "run" | "skipped" | "unknown";
|
|
97
104
|
reason: string;
|
|
98
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Which repo and ref a workflow file is read from.
|
|
108
|
+
*
|
|
109
|
+
* Expansion carries one of these rather than a bare path because `uses:` can
|
|
110
|
+
* cross repositories. A local `uses: ./...` keeps the current source, so a
|
|
111
|
+
* relative call inside a workflow that was itself reached by
|
|
112
|
+
* `owner/repo/path@ref` resolves against *that* repo at *that* ref — probe
|
|
113
|
+
* verified, see `src/names.test.ts`.
|
|
114
|
+
*/
|
|
115
|
+
export interface WorkflowSource {
|
|
116
|
+
owner: string;
|
|
117
|
+
repo: string;
|
|
118
|
+
/** Tag, branch, or SHA — whatever `@` was pinned to. */
|
|
119
|
+
ref: string;
|
|
120
|
+
}
|
|
121
|
+
/** Read one workflow file, or null if it is not reachable. Must not throw. */
|
|
122
|
+
export type FetchWorkflow = (path: string, source: WorkflowSource) => Promise<string | null>;
|
|
123
|
+
/** A `uses:` that named a workflow file we know how to go and get. */
|
|
124
|
+
export interface UsesTarget {
|
|
125
|
+
/** Path inside the target repo, e.g. `.github/workflows/x.yml`. */
|
|
126
|
+
path: string;
|
|
127
|
+
/** null for a local `./` call: the caller's own repo and ref. */
|
|
128
|
+
source: WorkflowSource | null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
132
|
+
*
|
|
133
|
+
* Two spellings are legal:
|
|
134
|
+
*
|
|
135
|
+
* `./.github/workflows/x.yml` -> the caller's repo, same commit
|
|
136
|
+
* `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
|
|
137
|
+
*
|
|
138
|
+
* The ref is taken from the last `@` so a branch containing a slash
|
|
139
|
+
* (`@feature/foo`) survives. Returns null for anything else — including a
|
|
140
|
+
* reference built from an expression, which we cannot evaluate and so must not
|
|
141
|
+
* guess a fetch target for.
|
|
142
|
+
*/
|
|
143
|
+
export declare function parseUses(uses: string): UsesTarget | null;
|
|
99
144
|
/**
|
|
100
145
|
* Expand one already-parsed workflow into its job entries. Exported so check
|
|
101
146
|
* names can be tested against recorded GitHub behaviour without a network
|
|
102
147
|
* round-trip; `predict` is the API you want.
|
|
103
148
|
*/
|
|
104
|
-
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx,
|
|
149
|
+
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, fetchWorkflow: FetchWorkflow, source: WorkflowSource): Promise<ExpandedJob[]>;
|
|
105
150
|
export declare function makeOctokit(): Octokit;
|
|
106
151
|
export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
|
|
107
152
|
export {};
|
package/dist/predict.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
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";
|
|
15
|
+
import { evaluate, UNKNOWN } from "./expr.js";
|
|
14
16
|
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
15
17
|
export const jobName = (name) => name;
|
|
16
18
|
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
@@ -91,8 +93,8 @@ function getPrTrigger(wf) {
|
|
|
91
93
|
}
|
|
92
94
|
// A predicate: does this workflow produce a run for the PR? Every workflow-level
|
|
93
95
|
// 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.
|
|
96
|
+
// expansion can be genuinely undecidable (dynamic matrix, an unreadable
|
|
97
|
+
// reusable workflow, unresolvable `if`), and that is a per-entry status.
|
|
96
98
|
function workflowDispatches(wf, ctx) {
|
|
97
99
|
const trig = getPrTrigger(wf);
|
|
98
100
|
if (trig === MISSING)
|
|
@@ -271,37 +273,132 @@ function skippedDisplayName(jobId, job) {
|
|
|
271
273
|
const raw = job != null && job.name != null ? String(job.name) : null;
|
|
272
274
|
return { name: raw ?? jobId, resolved: true };
|
|
273
275
|
}
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
+
/**
|
|
277
|
+
* The `github.*` values that are fixed for everything this module predicts.
|
|
278
|
+
* `predict` only ever answers for a pull request, so `event_name` is not a
|
|
279
|
+
* variable — which is what lets a `github.event_name == 'pull_request'` guard
|
|
280
|
+
* resolve instead of hanging the job on an unknown.
|
|
281
|
+
*/
|
|
282
|
+
const PR_GITHUB_CONTEXT = { event_name: "pull_request" };
|
|
283
|
+
/**
|
|
284
|
+
* Return run|skipped|unknown for a job-level `if:`.
|
|
285
|
+
*
|
|
286
|
+
* `scope` carries the inputs the calling workflow passed down. Without it a
|
|
287
|
+
* reusable workflow's guards are all unknown, because every one of them is
|
|
288
|
+
* written against `inputs.*`.
|
|
289
|
+
*/
|
|
290
|
+
export function evalIf(cond, scope = {}) {
|
|
276
291
|
if (cond == null)
|
|
277
292
|
return "run";
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
return "
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
const verdict = evaluate(String(cond), {
|
|
294
|
+
inputs: scope.inputs,
|
|
295
|
+
github: { ...PR_GITHUB_CONTEXT, ...scope.github },
|
|
296
|
+
});
|
|
297
|
+
if (verdict === null)
|
|
298
|
+
return "unknown";
|
|
299
|
+
return verdict ? "run" : "skipped";
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* A `with:` value as the callee will see it.
|
|
303
|
+
*
|
|
304
|
+
* A value carrying `${{ }}` is left unknown rather than evaluated. Resolving
|
|
305
|
+
* it would mean evaluating the caller's own expression context, and every
|
|
306
|
+
* caller in practice passes plain literals — so the reach that would buy is
|
|
307
|
+
* not worth the surface. Unknown here is the same unknown as before this
|
|
308
|
+
* existed; nothing regresses.
|
|
309
|
+
*/
|
|
310
|
+
function inputLiteral(raw) {
|
|
311
|
+
if (raw == null)
|
|
312
|
+
return { kind: "value", v: "" };
|
|
313
|
+
if (typeof raw === "boolean" || typeof raw === "number")
|
|
314
|
+
return { kind: "value", v: raw };
|
|
315
|
+
if (typeof raw === "string")
|
|
316
|
+
return raw.includes("${{") ? UNKNOWN : { kind: "value", v: raw };
|
|
317
|
+
return UNKNOWN;
|
|
318
|
+
}
|
|
319
|
+
/** The `on.workflow_call.inputs` block, tolerating the YAML 1.1 `on` -> true key. */
|
|
320
|
+
function workflowCallInputs(wf) {
|
|
321
|
+
const on = wf?.["on"] ?? wf?.["true"];
|
|
322
|
+
if (on == null || typeof on !== "object")
|
|
323
|
+
return {};
|
|
324
|
+
const call = on["workflow_call"];
|
|
325
|
+
if (call == null || typeof call !== "object")
|
|
326
|
+
return {};
|
|
327
|
+
const inputs = call["inputs"];
|
|
328
|
+
return inputs != null && typeof inputs === "object" ? inputs : {};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* What `inputs.*` resolves to inside a called workflow: what the caller passed,
|
|
332
|
+
* over the defaults the callee declares.
|
|
333
|
+
*
|
|
334
|
+
* A declared input the caller omits falls back to its `default`. A declared
|
|
335
|
+
* input with no default and no caller value is unknown rather than empty — the
|
|
336
|
+
* workflow would be invalid if it were required, and guessing empty would
|
|
337
|
+
* silently decide guards that are not decided.
|
|
338
|
+
*/
|
|
339
|
+
function calleeInputs(withBlock, subWf) {
|
|
340
|
+
const out = {};
|
|
341
|
+
for (const [name, decl] of Object.entries(workflowCallInputs(subWf))) {
|
|
342
|
+
out[name] =
|
|
343
|
+
decl != null && typeof decl === "object" && "default" in decl
|
|
344
|
+
? inputLiteral(decl["default"])
|
|
345
|
+
: UNKNOWN;
|
|
289
346
|
}
|
|
290
|
-
|
|
347
|
+
if (withBlock != null && typeof withBlock === "object") {
|
|
348
|
+
for (const [name, raw] of Object.entries(withBlock)) {
|
|
349
|
+
out[name] = inputLiteral(raw);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
291
353
|
}
|
|
292
354
|
/**
|
|
293
355
|
* GitHub allows a reusable-workflow call chain four levels deep. Past that the
|
|
294
356
|
* run itself fails, so anything deeper is not a name we could predict anyway.
|
|
295
357
|
* Probe-verified to three levels: `call-nested / Mid Call / inner`.
|
|
358
|
+
*
|
|
359
|
+
* A cross-repo hop costs the same one level as a local one, so a chain that
|
|
360
|
+
* mixes the two is counted the same way.
|
|
296
361
|
*/
|
|
297
362
|
const MAX_REUSABLE_DEPTH = 4;
|
|
298
|
-
|
|
363
|
+
/**
|
|
364
|
+
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
365
|
+
*
|
|
366
|
+
* Two spellings are legal:
|
|
367
|
+
*
|
|
368
|
+
* `./.github/workflows/x.yml` -> the caller's repo, same commit
|
|
369
|
+
* `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
|
|
370
|
+
*
|
|
371
|
+
* The ref is taken from the last `@` so a branch containing a slash
|
|
372
|
+
* (`@feature/foo`) survives. Returns null for anything else — including a
|
|
373
|
+
* reference built from an expression, which we cannot evaluate and so must not
|
|
374
|
+
* guess a fetch target for.
|
|
375
|
+
*/
|
|
376
|
+
export function parseUses(uses) {
|
|
377
|
+
if (EXPRESSION_RE.test(uses))
|
|
378
|
+
return null;
|
|
379
|
+
if (uses.startsWith("./")) {
|
|
380
|
+
const path = uses.slice(2);
|
|
381
|
+
return path === "" ? null : { path, source: null };
|
|
382
|
+
}
|
|
383
|
+
const at = uses.lastIndexOf("@");
|
|
384
|
+
if (at <= 0)
|
|
385
|
+
return null;
|
|
386
|
+
const ref = uses.slice(at + 1);
|
|
387
|
+
if (ref === "")
|
|
388
|
+
return null;
|
|
389
|
+
const [owner, repo, ...rest] = uses.slice(0, at).split("/");
|
|
390
|
+
const path = rest.join("/");
|
|
391
|
+
if (!owner || !repo || path === "")
|
|
392
|
+
return null;
|
|
393
|
+
return { path, source: { owner, repo, ref } };
|
|
394
|
+
}
|
|
395
|
+
async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
|
|
299
396
|
const entries = [];
|
|
300
397
|
const jobs = wf.jobs ?? {};
|
|
301
398
|
const statuses = {};
|
|
302
399
|
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
303
400
|
const job = jobRaw ?? {};
|
|
304
|
-
let status = evalIf(job.if);
|
|
401
|
+
let status = evalIf(job.if, scope);
|
|
305
402
|
let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
|
|
306
403
|
let needs = job.needs ?? [];
|
|
307
404
|
if (typeof needs === "string")
|
|
@@ -337,7 +434,9 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
|
|
|
337
434
|
if ("uses" in job) {
|
|
338
435
|
// Reusable workflow call. The calling job produces no check of its own;
|
|
339
436
|
// each called job becomes `<calling job name> / <called job name>`, and
|
|
340
|
-
// a matrix on the *caller* multiplies the whole callee set.
|
|
437
|
+
// a matrix on the *caller* multiplies the whole callee set. A cross-repo
|
|
438
|
+
// call names its checks exactly the same way a local one does — probe
|
|
439
|
+
// PR #9, `call-remote-tag / r-inner` alongside `call-plain / inner`.
|
|
341
440
|
const uses = job.uses;
|
|
342
441
|
if (combos == null) {
|
|
343
442
|
entries.push({
|
|
@@ -351,21 +450,28 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
|
|
|
351
450
|
// Resolve the called workflow once, not once per matrix combination.
|
|
352
451
|
let subWf = null;
|
|
353
452
|
let failure = null;
|
|
354
|
-
|
|
453
|
+
// Where the callee's own `./` calls will resolve. A remote `uses:` moves
|
|
454
|
+
// this to the callee's repo and pinned ref; a local one leaves it alone.
|
|
455
|
+
let subSource = source;
|
|
456
|
+
// What `inputs.*` means on the other side of the call.
|
|
457
|
+
let subScope = {};
|
|
458
|
+
const target = parseUses(uses);
|
|
355
459
|
if (depth + 1 > MAX_REUSABLE_DEPTH) {
|
|
356
460
|
failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
|
|
357
461
|
}
|
|
358
|
-
else if (
|
|
359
|
-
failure = `
|
|
462
|
+
else if (target == null) {
|
|
463
|
+
failure = `unresolvable reusable reference: ${uses}`;
|
|
360
464
|
}
|
|
361
465
|
else {
|
|
362
|
-
|
|
466
|
+
subSource = target.source ?? source;
|
|
467
|
+
const content = await fetchWorkflow(target.path, subSource);
|
|
363
468
|
if (content == null) {
|
|
364
469
|
failure = `cannot fetch ${uses}`;
|
|
365
470
|
}
|
|
366
471
|
else {
|
|
367
472
|
try {
|
|
368
473
|
subWf = parseYaml(content);
|
|
474
|
+
subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
|
|
369
475
|
}
|
|
370
476
|
catch (e) {
|
|
371
477
|
failure = `YAML parse error in ${uses}: ${e}`;
|
|
@@ -385,7 +491,7 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
|
|
|
385
491
|
});
|
|
386
492
|
continue;
|
|
387
493
|
}
|
|
388
|
-
entries.push(...(await expandJobs(subWf, ctx,
|
|
494
|
+
entries.push(...(await expandJobs(subWf, ctx, fetchWorkflow, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
|
|
389
495
|
}
|
|
390
496
|
continue;
|
|
391
497
|
}
|
|
@@ -416,8 +522,8 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixReso
|
|
|
416
522
|
* names can be tested against recorded GitHub behaviour without a network
|
|
417
523
|
* round-trip; `predict` is the API you want.
|
|
418
524
|
*/
|
|
419
|
-
export function expandWorkflowJobs(wf, ctx,
|
|
420
|
-
return expandJobs(wf, ctx,
|
|
525
|
+
export function expandWorkflowJobs(wf, ctx, fetchWorkflow, source) {
|
|
526
|
+
return expandJobs(wf, ctx, fetchWorkflow, source);
|
|
421
527
|
}
|
|
422
528
|
// ------------------------------------------------------------------- pipeline
|
|
423
529
|
export function makeOctokit() {
|
|
@@ -449,19 +555,35 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
449
555
|
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
450
556
|
return finalizePrediction([], "head commit message contains a skip instruction");
|
|
451
557
|
}
|
|
452
|
-
|
|
558
|
+
/** The PR's own repo at the head commit — where expansion starts. */
|
|
559
|
+
const headSource = { owner, repo: name, ref: headSha };
|
|
560
|
+
// One callee is commonly reached from several callers — a fleet repo calls
|
|
561
|
+
// the same `testing-conventions@v0` from eight workflows — so remember what
|
|
562
|
+
// each `owner/repo/path@ref` resolved to, misses included.
|
|
563
|
+
const cache = new Map();
|
|
564
|
+
const fetchWorkflow = async (path, src) => {
|
|
565
|
+
const key = `${src.owner}/${src.repo}/${path}@${src.ref}`;
|
|
566
|
+
const hit = cache.get(key);
|
|
567
|
+
if (hit !== undefined)
|
|
568
|
+
return hit;
|
|
569
|
+
let content;
|
|
453
570
|
try {
|
|
454
571
|
const { data } = await octokit.rest.repos.getContent({
|
|
455
|
-
|
|
572
|
+
owner: src.owner,
|
|
573
|
+
repo: src.repo,
|
|
456
574
|
path,
|
|
457
|
-
ref:
|
|
575
|
+
ref: src.ref,
|
|
458
576
|
mediaType: { format: "raw" },
|
|
459
577
|
});
|
|
460
|
-
|
|
578
|
+
content = data;
|
|
461
579
|
}
|
|
462
580
|
catch {
|
|
463
|
-
|
|
581
|
+
// Private, deleted, bad ref, rate limit, network: all one answer here.
|
|
582
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
583
|
+
content = null;
|
|
464
584
|
}
|
|
585
|
+
cache.set(key, content);
|
|
586
|
+
return content;
|
|
465
587
|
};
|
|
466
588
|
const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
467
589
|
...base,
|
|
@@ -481,7 +603,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
481
603
|
});
|
|
482
604
|
continue;
|
|
483
605
|
}
|
|
484
|
-
const content = await
|
|
606
|
+
const content = await fetchWorkflow(path, headSource);
|
|
485
607
|
if (content == null) {
|
|
486
608
|
// The Actions API keeps listing a workflow as `active` after its file is
|
|
487
609
|
// deleted. There is no file at head, so there is nothing to dispatch —
|
|
@@ -515,7 +637,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
515
637
|
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
516
638
|
continue;
|
|
517
639
|
}
|
|
518
|
-
for (const j of await expandJobs(wf, ctx,
|
|
640
|
+
for (const j of await expandJobs(wf, ctx, fetchWorkflow, headSource)) {
|
|
519
641
|
entries.push({
|
|
520
642
|
workflow: path,
|
|
521
643
|
job: jobName(j.job),
|