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/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
- // `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
+ 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. `success()`, `failure()` and
340
- * `cancelled()` depend on jobs that have not run, and everything else is
341
- * simply not modelled — all unknown.
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 a condition to a truthiness, or null when it cannot be settled.
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. A
369
- * condition that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
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 evaluate(cond, scope = {}) {
373
- const stripped = cond.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
435
+ export function evaluateValue(expr, scope = {}) {
436
+ const stripped = expr.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
374
437
  if (stripped === "")
375
- return null;
438
+ return UNKNOWN;
376
439
  if (stripped.includes("${{"))
377
- return null;
440
+ return UNKNOWN;
378
441
  const toks = tokenize(stripped);
379
442
  if (toks == null || toks.length === 0)
380
- return null;
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 condition; whatever
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 null;
387
- return truthy(val);
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. Without it a
119
- * reusable workflow's guards are all unknown, because every one of them is
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 WorkflowSource {
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
- /** null for a local `./` call: the caller's own repo and ref. */
157
- source: WorkflowSource | null;
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, fetchWorkflow: FetchWorkflow, source: WorkflowSource): Promise<ExpandedJob[]>;
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 {};