willfire 0.1.9 → 0.1.11
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 +109 -10
- package/dist/execute.d.ts +123 -0
- package/dist/execute.js +490 -0
- package/dist/expr.d.ts +51 -3
- package/dist/expr.js +85 -18
- package/dist/predict.d.ts +69 -8
- package/dist/predict.js +253 -54
- package/package.json +1 -1
package/dist/expr.js
CHANGED
|
@@ -43,6 +43,10 @@ function truthy(val) {
|
|
|
43
43
|
return false;
|
|
44
44
|
case "unknown":
|
|
45
45
|
return null;
|
|
46
|
+
// GitHub does cast an array or an object to a boolean, but no workflow
|
|
47
|
+
// asks it to, and the answer is not worth guessing at to find out.
|
|
48
|
+
case "json":
|
|
49
|
+
return null;
|
|
46
50
|
case "value": {
|
|
47
51
|
const v = val.v;
|
|
48
52
|
if (typeof v === "boolean")
|
|
@@ -264,10 +268,36 @@ class Parser {
|
|
|
264
268
|
const v = this.scope.github?.[rest];
|
|
265
269
|
return v === undefined ? UNKNOWN : { kind: "value", v };
|
|
266
270
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
if (head === "needs") {
|
|
272
|
+
// Only `needs.<job>.outputs.<name>` is modelled. `needs.<job>.result`
|
|
273
|
+
// is a verdict on a run that has not happened; anything else is not a
|
|
274
|
+
// shape the context has.
|
|
275
|
+
const parts = rest.split(".");
|
|
276
|
+
if (parts.length !== 3 || parts[1] !== "outputs")
|
|
277
|
+
return UNKNOWN;
|
|
278
|
+
const job = this.scope.needs?.[parts[0]];
|
|
279
|
+
if (job == null)
|
|
280
|
+
return UNKNOWN;
|
|
281
|
+
// A known job's missing output is the empty string, not a hole: the
|
|
282
|
+
// caller promised the set is complete, and that is what the runner
|
|
283
|
+
// substitutes for an output no step wrote.
|
|
284
|
+
return { kind: "value", v: job.outputs[parts[2]] ?? "" };
|
|
285
|
+
}
|
|
286
|
+
if (head === "steps") {
|
|
287
|
+
// The same shape as `needs`, for the same reason: only
|
|
288
|
+
// `steps.<id>.outputs.<name>` is modelled. `steps.<id>.outcome` and
|
|
289
|
+
// `.conclusion` are verdicts on how a step ran, which the executor does
|
|
290
|
+
// not track — a failed step fails the whole execution instead.
|
|
291
|
+
const parts = rest.split(".");
|
|
292
|
+
if (parts.length !== 3 || parts[1] !== "outputs")
|
|
293
|
+
return UNKNOWN;
|
|
294
|
+
const step = this.scope.steps?.[parts[0]];
|
|
295
|
+
if (step == null)
|
|
296
|
+
return UNKNOWN;
|
|
297
|
+
return { kind: "value", v: step.outputs[parts[2]] ?? "" };
|
|
298
|
+
}
|
|
299
|
+
// `matrix.*`, `env.*`, `vars.*`, `secrets.*`: all require something that
|
|
300
|
+
// has not happened yet at prediction time.
|
|
271
301
|
return UNKNOWN;
|
|
272
302
|
}
|
|
273
303
|
}
|
|
@@ -332,17 +362,45 @@ function compare(op, left, right) {
|
|
|
332
362
|
return asBool(a > b);
|
|
333
363
|
return asBool(a >= b);
|
|
334
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* `fromJSON(s)` on a known string.
|
|
367
|
+
*
|
|
368
|
+
* The result is sorted into the lattice rather than dropped in whole: a scalar
|
|
369
|
+
* is an ordinary value and stays comparable, `null` has a known truthiness and
|
|
370
|
+
* no useful value, and only an array or an object needs the `json` point.
|
|
371
|
+
* Anything unparseable is unknown — a workflow that reaches this at runtime
|
|
372
|
+
* fails, and predicting a failure is not this function's job.
|
|
373
|
+
*/
|
|
374
|
+
function fromJson(arg) {
|
|
375
|
+
if (arg.kind !== "value" || typeof arg.v !== "string")
|
|
376
|
+
return UNKNOWN;
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(arg.v);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return UNKNOWN;
|
|
383
|
+
}
|
|
384
|
+
if (parsed === null)
|
|
385
|
+
return { kind: "falsy" };
|
|
386
|
+
if (typeof parsed === "object")
|
|
387
|
+
return { kind: "json", v: parsed };
|
|
388
|
+
return { kind: "value", v: parsed };
|
|
389
|
+
}
|
|
335
390
|
/**
|
|
336
391
|
* The functions worth modelling.
|
|
337
392
|
*
|
|
338
393
|
* `always()` is true by definition. `contains` on two known strings is the one
|
|
339
|
-
* that unlocks the fleet's `gates` pattern. `
|
|
340
|
-
*
|
|
341
|
-
* simply not modelled — all
|
|
394
|
+
* that unlocks the fleet's `gates` pattern. `fromJSON` is what a dynamic matrix
|
|
395
|
+
* axis is built out of. `success()`, `failure()` and `cancelled()` depend on
|
|
396
|
+
* jobs that have not run, and everything else is simply not modelled — all
|
|
397
|
+
* unknown.
|
|
342
398
|
*/
|
|
343
399
|
function applyFunction(name, args) {
|
|
344
400
|
if (name === "always")
|
|
345
401
|
return { kind: "value", v: true };
|
|
402
|
+
if (name === "fromjson" && args.length === 1)
|
|
403
|
+
return fromJson(args[0]);
|
|
346
404
|
if (name === "contains" && args.length === 2) {
|
|
347
405
|
const [hay, needle] = args;
|
|
348
406
|
if (hay.kind !== "value" || needle.kind !== "value")
|
|
@@ -363,26 +421,35 @@ function applyFunction(name, args) {
|
|
|
363
421
|
}
|
|
364
422
|
// ---------------------------------------------------------------------- entry
|
|
365
423
|
/**
|
|
366
|
-
* Evaluate
|
|
424
|
+
* Evaluate an expression to a value, or UNKNOWN when it cannot be settled.
|
|
367
425
|
*
|
|
368
|
-
* The `${{ }}` wrapper is optional in `if:` and stripped when present.
|
|
369
|
-
*
|
|
426
|
+
* The `${{ }}` wrapper is optional in `if:` and stripped when present. An
|
|
427
|
+
* expression that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
|
|
370
428
|
* interpolation rather than an expression, and is not modelled.
|
|
429
|
+
*
|
|
430
|
+
* A `if:` wants {@link evaluate}, which is this narrowed to truthiness. This
|
|
431
|
+
* one is for the places that need the value itself — a matrix axis written as
|
|
432
|
+
* `${{ fromJSON(...) }}` is an array, and its truthiness says nothing about
|
|
433
|
+
* how many jobs it schedules.
|
|
371
434
|
*/
|
|
372
|
-
export function
|
|
373
|
-
const stripped =
|
|
435
|
+
export function evaluateValue(expr, scope = {}) {
|
|
436
|
+
const stripped = expr.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
374
437
|
if (stripped === "")
|
|
375
|
-
return
|
|
438
|
+
return UNKNOWN;
|
|
376
439
|
if (stripped.includes("${{"))
|
|
377
|
-
return
|
|
440
|
+
return UNKNOWN;
|
|
378
441
|
const toks = tokenize(stripped);
|
|
379
442
|
if (toks == null || toks.length === 0)
|
|
380
|
-
return
|
|
443
|
+
return UNKNOWN;
|
|
381
444
|
const p = new Parser(toks, scope);
|
|
382
445
|
const val = p.or();
|
|
383
|
-
// Trailing tokens mean the grammar did not cover this
|
|
446
|
+
// Trailing tokens mean the grammar did not cover this expression; whatever
|
|
384
447
|
// was parsed describes only a prefix of it, so it decides nothing.
|
|
385
448
|
if (!p.done())
|
|
386
|
-
return
|
|
387
|
-
return
|
|
449
|
+
return UNKNOWN;
|
|
450
|
+
return val;
|
|
451
|
+
}
|
|
452
|
+
/** Evaluate a condition to a truthiness, or null when it cannot be settled. */
|
|
453
|
+
export function evaluate(cond, scope = {}) {
|
|
454
|
+
return truthy(evaluateValue(cond, scope));
|
|
388
455
|
}
|
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 ExecutionGrant, type JobExecutor } from "./execute.js";
|
|
3
4
|
import { type Scope } from "./expr.js";
|
|
4
5
|
interface EntryBase {
|
|
5
6
|
workflow: string;
|
|
@@ -75,6 +76,15 @@ export interface Prediction {
|
|
|
75
76
|
*/
|
|
76
77
|
checkNames: string[];
|
|
77
78
|
skip: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Every repo this prediction read, and the commit each ref resolved to —
|
|
81
|
+
* the PR's own head first, then any cross-repo `uses:` reached from it.
|
|
82
|
+
*
|
|
83
|
+
* Provenance, not input. `v0` is a moving tag, so "willfire said these
|
|
84
|
+
* checks" is only reconcilable against a run if it also says which commits
|
|
85
|
+
* it read to say it. Sorted by `owner/repo@ref`.
|
|
86
|
+
*/
|
|
87
|
+
sources: WorkflowSource[];
|
|
78
88
|
}
|
|
79
89
|
export declare function patternToRegex(pat: string): RegExp;
|
|
80
90
|
/** Order-sensitive match: last matching pattern wins; ! negates. */
|
|
@@ -102,6 +112,17 @@ export interface PredictOptions {
|
|
|
102
112
|
* to a workflow narrowing `types:`, where it matters completely.
|
|
103
113
|
*/
|
|
104
114
|
action?: PrEventAction;
|
|
115
|
+
/**
|
|
116
|
+
* Jobs willfire may *execute* to resolve what reading cannot — the fleet's
|
|
117
|
+
* `detect` job, whose outputs feed every dynamic matrix downstream of it.
|
|
118
|
+
*
|
|
119
|
+
* Off by default, and mechanism only: willfire has no opinion about which
|
|
120
|
+
* jobs are safe to run. The caller that knows names them, one repo and job
|
|
121
|
+
* id at a time (see {@link ExecutionGrant}), and an execution that fails
|
|
122
|
+
* for any reason leaves the dependent entries exactly as unresolved as
|
|
123
|
+
* they were — with the failure spelled into their reasons.
|
|
124
|
+
*/
|
|
125
|
+
execute?: ExecutionGrant[];
|
|
105
126
|
}
|
|
106
127
|
export interface Ctx {
|
|
107
128
|
action: string;
|
|
@@ -111,13 +132,14 @@ export interface Ctx {
|
|
|
111
132
|
type Workflow = Record<string, any>;
|
|
112
133
|
type Combo = Record<string, any> | null;
|
|
113
134
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
114
|
-
export declare function expandMatrix(strategy: any): Combo[] | null;
|
|
135
|
+
export declare function expandMatrix(strategy: any, scope?: Scope): Combo[] | null;
|
|
115
136
|
/**
|
|
116
137
|
* Return run|skipped|unknown for a job-level `if:`.
|
|
117
138
|
*
|
|
118
|
-
* `scope` carries the inputs the calling workflow passed down
|
|
119
|
-
* reusable workflow's guards are all
|
|
120
|
-
* written against `inputs
|
|
139
|
+
* `scope` carries the inputs the calling workflow passed down, and any job
|
|
140
|
+
* outputs the caller knows. Without it a reusable workflow's guards are all
|
|
141
|
+
* unknown, because every one of them is written against `inputs.*` or
|
|
142
|
+
* `needs.*`.
|
|
121
143
|
*/
|
|
122
144
|
export declare function evalIf(cond: any, scope?: Scope): "run" | "skipped" | "unknown";
|
|
123
145
|
/**
|
|
@@ -141,20 +163,54 @@ export interface ExpandedJob {
|
|
|
141
163
|
* `owner/repo/path@ref` resolves against *that* repo at *that* ref — probe
|
|
142
164
|
* verified, see `src/names.test.ts`.
|
|
143
165
|
*/
|
|
144
|
-
export interface
|
|
166
|
+
export interface SourceRef {
|
|
145
167
|
owner: string;
|
|
146
168
|
repo: string;
|
|
147
169
|
/** Tag, branch, or SHA — whatever `@` was pinned to. */
|
|
148
170
|
ref: string;
|
|
149
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* A source whose ref has been resolved to the commit it names.
|
|
174
|
+
*
|
|
175
|
+
* Expansion walks these and never a bare {@link SourceRef}: `v0` is a moving
|
|
176
|
+
* tag, so two reads an hour apart can be two different programs, and a
|
|
177
|
+
* prediction that cannot name the commit it read cannot be reconciled against
|
|
178
|
+
* the run afterwards. Making the SHA required is what stops an unresolved ref
|
|
179
|
+
* being expanded against by accident.
|
|
180
|
+
*/
|
|
181
|
+
export interface WorkflowSource extends SourceRef {
|
|
182
|
+
/** The commit `ref` names. Equal to `ref` when it was already a SHA. */
|
|
183
|
+
sha: string;
|
|
184
|
+
}
|
|
150
185
|
/** Read one workflow file, or null if it is not reachable. Must not throw. */
|
|
151
186
|
export type FetchWorkflow = (path: string, source: WorkflowSource) => Promise<string | null>;
|
|
187
|
+
/**
|
|
188
|
+
* Resolve a tag, branch, or SHA to the commit it names, or null when it cannot
|
|
189
|
+
* be resolved. Must not throw.
|
|
190
|
+
*
|
|
191
|
+
* Null is not a cue to fall back to the mutable ref. It leaves every entry
|
|
192
|
+
* behind that source unresolved, which turns the gate red — reading a ref we
|
|
193
|
+
* cannot name is the thing this exists to stop.
|
|
194
|
+
*/
|
|
195
|
+
export type ResolveRef = (source: SourceRef) => Promise<string | null>;
|
|
196
|
+
/**
|
|
197
|
+
* The two reads expansion needs from the outside world, bundled so the recursion
|
|
198
|
+
* carries one parameter instead of two.
|
|
199
|
+
*/
|
|
200
|
+
export interface WorkflowReader {
|
|
201
|
+
fetchWorkflow: FetchWorkflow;
|
|
202
|
+
resolveRef: ResolveRef;
|
|
203
|
+
}
|
|
152
204
|
/** A `uses:` that named a workflow file we know how to go and get. */
|
|
153
205
|
export interface UsesTarget {
|
|
154
206
|
/** Path inside the target repo, e.g. `.github/workflows/x.yml`. */
|
|
155
207
|
path: string;
|
|
156
|
-
/**
|
|
157
|
-
|
|
208
|
+
/**
|
|
209
|
+
* null for a local `./` call: the caller's own repo and ref, already
|
|
210
|
+
* resolved. Non-null sources arrive unresolved — the ref is whatever the
|
|
211
|
+
* `uses:` string spelled.
|
|
212
|
+
*/
|
|
213
|
+
source: SourceRef | null;
|
|
158
214
|
}
|
|
159
215
|
/**
|
|
160
216
|
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
@@ -174,8 +230,13 @@ export declare function parseUses(uses: string): UsesTarget | null;
|
|
|
174
230
|
* Expand one already-parsed workflow into its job entries. Exported so check
|
|
175
231
|
* names can be tested against recorded GitHub behaviour without a network
|
|
176
232
|
* round-trip; `predict` is the API you want.
|
|
233
|
+
*
|
|
234
|
+
* `scope` seeds what this workflow's own `${{ }}` resolve against — notably
|
|
235
|
+
* `needs`, the outputs of jobs that have not run. Nothing here works out what
|
|
236
|
+
* those are; a caller that knows hands them in, and a caller that does not
|
|
237
|
+
* leaves them out and gets `unknown` where they would have been used.
|
|
177
238
|
*/
|
|
178
|
-
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx,
|
|
239
|
+
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource, scope?: Scope, executor?: JobExecutor): Promise<ExpandedJob[]>;
|
|
179
240
|
export declare function makeOctokit(): Octokit;
|
|
180
241
|
export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
|
|
181
242
|
export {};
|