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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Execute a job the caller granted, to learn what static reading cannot.
3
+ *
4
+ * A dynamic matrix — `language: ${{ fromJSON(needs.detect.outputs.x) }}` — is
5
+ * the values another job computes at runtime. No amount of reading the YAML
6
+ * yields them; the fleet's `detect` job runs a script over the repo tree and
7
+ * writes what it finds to `$GITHUB_OUTPUT`. So this module runs that job the
8
+ * way the runner would: materialize the tree at the pinned commit, walk the
9
+ * steps in order, execute each `run:` under its declared shell and env, and
10
+ * assemble the job's `outputs:` map from what the steps actually wrote.
11
+ *
12
+ * Three rules keep this honest:
13
+ *
14
+ * 1. **Nothing runs without a grant.** willfire has no opinion about which
15
+ * jobs are safe to execute; the caller names them, one repo and job id at
16
+ * a time, and everything else stays as unresolved as it was.
17
+ * 2. **Run it, never interpret it.** The `run:` script is handed to the shell
18
+ * the step declares, with the env it declares. What lands in
19
+ * `$GITHUB_OUTPUT` is the answer; no shell text is ever parsed for meaning.
20
+ * 3. **Anything off the modelled path is a hard stop with a reason.** A
21
+ * JavaScript action, an undecidable `if:`, a `${{ }}` that will not
22
+ * resolve, a step that exits non-zero — each fails the execution and says
23
+ * what it hit, and the consumers of that job's outputs stay unresolved.
24
+ * Guessing is the one move this module never makes.
25
+ *
26
+ * `actions/checkout` is the deliberate exception to rule 2. It is provided by
27
+ * the runner, not run from its repo, and its whole postcondition — the
28
+ * workspace tree at the commit under test — is something the executor has
29
+ * already satisfied by materializing the tree. A bare checkout is therefore
30
+ * recorded as done; a checkout *with inputs* is not modelled and stops.
31
+ */
32
+ import { spawn } from "node:child_process";
33
+ import { mkdir, mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises";
34
+ import { tmpdir } from "node:os";
35
+ import { join, resolve } from "node:path";
36
+ import { parse as parseYaml } from "yaml";
37
+ import { evaluate, evaluateValue, UNKNOWN } from "./expr.js";
38
+ /** `owner/repo:job1,job2` as the CLI spells a grant. */
39
+ export function parseGrant(spec) {
40
+ const colon = spec.indexOf(":");
41
+ if (colon <= 0)
42
+ return null;
43
+ const repo = spec.slice(0, colon);
44
+ const parts = repo.split("/");
45
+ if (parts.length !== 2 || parts.some((p) => p === ""))
46
+ return null;
47
+ const jobs = spec
48
+ .slice(colon + 1)
49
+ .split(",")
50
+ .map((s) => s.trim())
51
+ .filter((s) => s !== "");
52
+ if (jobs.length === 0)
53
+ return null;
54
+ return { repo, jobs };
55
+ }
56
+ const err = (reason) => ({ ok: false, reason });
57
+ const SHA_RE = /^[0-9a-f]{40}$/i;
58
+ /**
59
+ * Render every `${{ }}` in a template to its literal text, or null when any
60
+ * of them cannot be settled. Null rather than a partial render: a script with
61
+ * a hole in it is a different program, and running a different program is the
62
+ * exact lie rule 2 exists to prevent.
63
+ */
64
+ function renderTemplate(text, scope) {
65
+ let failed = false;
66
+ const out = text.replace(/\$\{\{(.*?)\}\}/g, (_whole, inner) => {
67
+ const val = evaluateValue(String(inner), scope);
68
+ if (val.kind !== "value") {
69
+ failed = true;
70
+ return "";
71
+ }
72
+ return String(val.v);
73
+ });
74
+ return failed ? null : out;
75
+ }
76
+ /** An `env:` block rendered to concrete strings, every key or nothing. */
77
+ function renderEnvLayer(layer, scope) {
78
+ if (layer == null)
79
+ return { ok: true, v: {} };
80
+ if (typeof layer !== "object" || Array.isArray(layer))
81
+ return err("env block is not a map");
82
+ const out = {};
83
+ for (const [k, raw] of Object.entries(layer)) {
84
+ const rendered = renderTemplate(String(raw ?? ""), scope);
85
+ if (rendered == null)
86
+ return err(`cannot resolve env '${k}'`);
87
+ out[k] = rendered;
88
+ }
89
+ return { ok: true, v: out };
90
+ }
91
+ /**
92
+ * The `$GITHUB_OUTPUT` file format: `name=value` lines, or a
93
+ * `name<<DELIMITER … DELIMITER` heredoc for multi-line values. Anything else
94
+ * fails the parse — the runner fails the step on a malformed line, so
95
+ * tolerating one here would invent outputs a real run never had.
96
+ */
97
+ export function parseGithubOutput(text) {
98
+ const out = {};
99
+ const lines = text.split("\n");
100
+ let i = 0;
101
+ while (i < lines.length) {
102
+ const line = lines[i];
103
+ i++;
104
+ if (line === "")
105
+ continue;
106
+ const heredoc = /^([^=<]+)<<(.+)$/.exec(line);
107
+ if (heredoc != null) {
108
+ const [, name, delim] = heredoc;
109
+ const buf = [];
110
+ for (;;) {
111
+ if (i >= lines.length)
112
+ return null; // unterminated heredoc
113
+ if (lines[i] === delim) {
114
+ i++;
115
+ break;
116
+ }
117
+ buf.push(lines[i]);
118
+ i++;
119
+ }
120
+ out[name] = buf.join("\n");
121
+ continue;
122
+ }
123
+ const eq = line.indexOf("=");
124
+ if (eq <= 0)
125
+ return null;
126
+ out[line.slice(0, eq)] = line.slice(eq + 1);
127
+ }
128
+ return out;
129
+ }
130
+ // ------------------------------------------------------------------- actions
131
+ /**
132
+ * A step-level `uses:` naming another repo: `owner/repo[/path]@ref`. Unlike a
133
+ * reusable-workflow reference the path may be empty — an action commonly
134
+ * lives at the repo root. Expressions and `docker://` images return null.
135
+ */
136
+ function parseActionUses(uses) {
137
+ if (uses.includes("${{") || uses.startsWith("docker://"))
138
+ return null;
139
+ const at = uses.lastIndexOf("@");
140
+ if (at <= 0)
141
+ return null;
142
+ const ref = uses.slice(at + 1);
143
+ if (ref === "")
144
+ return null;
145
+ const [owner, repo, ...rest] = uses.slice(0, at).split("/");
146
+ if (!owner || !repo)
147
+ return null;
148
+ return { path: rest.join("/"), source: { owner, repo, ref } };
149
+ }
150
+ /** Read `action.yml` (or `.yaml`) from a directory, or null if neither exists. */
151
+ async function readActionManifest(dir) {
152
+ for (const name of ["action.yml", "action.yaml"]) {
153
+ try {
154
+ return await readFile(join(dir, name), "utf8");
155
+ }
156
+ catch {
157
+ // fall through to the next spelling
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ /**
163
+ * What `inputs.*` means inside a composite action: the caller's `with:`
164
+ * values over the action's declared defaults, everything a string — action
165
+ * inputs are untyped, and an input nobody set is the empty string, not a
166
+ * hole. A value whose `${{ }}` cannot be rendered stays unknown rather than
167
+ * failing here: it only matters if a step actually reads it, and the read is
168
+ * where that failure is honest.
169
+ */
170
+ function bindActionInputs(action, withBlock, scope) {
171
+ const bind = (raw) => {
172
+ if (raw == null)
173
+ return { kind: "value", v: "" };
174
+ if (typeof raw === "boolean" || typeof raw === "number") {
175
+ return { kind: "value", v: String(raw) };
176
+ }
177
+ const rendered = renderTemplate(String(raw), scope);
178
+ return rendered == null ? UNKNOWN : { kind: "value", v: rendered };
179
+ };
180
+ const out = {};
181
+ for (const [name, decl] of Object.entries(action?.inputs ?? {})) {
182
+ out[name] =
183
+ decl != null && typeof decl === "object" && "default" in decl
184
+ ? bind(decl["default"])
185
+ : { kind: "value", v: "" };
186
+ }
187
+ if (withBlock != null && typeof withBlock === "object") {
188
+ for (const [name, raw] of Object.entries(withBlock)) {
189
+ out[name] = bind(raw);
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+ // ----------------------------------------------------------------- step walk
195
+ /**
196
+ * A cycle guard, not a fidelity claim: a composite action that includes
197
+ * itself would otherwise recurse forever. No granted job in practice nests
198
+ * past one level.
199
+ */
200
+ const MAX_ACTION_DEPTH = 4;
201
+ const CHECKOUT_RE = /^actions\/checkout@/;
202
+ /**
203
+ * Walk steps in order, growing the `steps` context as each one completes.
204
+ * Returns the finished context, or the reason the walk stopped.
205
+ */
206
+ async function runSteps(steps, scope, ctx) {
207
+ const stepsCtx = {};
208
+ for (let i = 0; i < steps.length; i++) {
209
+ const step = steps[i] ?? {};
210
+ const label = `step '${step.id ?? step.name ?? `#${i + 1}`}'`;
211
+ const stepScope = { ...scope, steps: stepsCtx };
212
+ if (step.if != null) {
213
+ const verdict = evaluate(String(step.if), stepScope);
214
+ if (verdict == null)
215
+ return err(`cannot decide if: for ${label}`);
216
+ if (!verdict) {
217
+ // A skipped step still occupies its id, with no outputs — that is the
218
+ // empty string every later read gets, and what `||` coalesces past.
219
+ if (typeof step.id === "string")
220
+ stepsCtx[step.id] = { outputs: {} };
221
+ continue;
222
+ }
223
+ }
224
+ let res;
225
+ if (typeof step.uses === "string") {
226
+ res = await runUses(step, label, stepScope, ctx);
227
+ }
228
+ else if (step.run != null) {
229
+ res = await runRun(step, label, stepScope, ctx);
230
+ }
231
+ else {
232
+ return err(`${label} has neither uses nor run`);
233
+ }
234
+ if (!res.ok)
235
+ return res;
236
+ if (typeof step.id === "string")
237
+ stepsCtx[step.id] = { outputs: res.v };
238
+ }
239
+ return { ok: true, v: stepsCtx };
240
+ }
241
+ /** A `uses:` step: checkout's postcondition, or a composite action, or a stop. */
242
+ async function runUses(step, label, scope, ctx) {
243
+ const uses = step.uses;
244
+ if (CHECKOUT_RE.test(uses)) {
245
+ // Runner-provided, and its postcondition — the head tree at the workspace
246
+ // path — is already true. But only the bare form: `ref:`, `path:`,
247
+ // `repository:` each make it a different tree than the one provided.
248
+ if (step.with != null && Object.keys(step.with).length > 0) {
249
+ return err(`${label}: actions/checkout with inputs is not modelled`);
250
+ }
251
+ return { ok: true, v: {} };
252
+ }
253
+ if (ctx.depth + 1 > MAX_ACTION_DEPTH) {
254
+ return err(`${label}: actions nested deeper than ${MAX_ACTION_DEPTH} levels`);
255
+ }
256
+ let actionDir;
257
+ if (uses.startsWith("./")) {
258
+ // Relative to the workspace, hermetic-style: the tree under test carries
259
+ // the action. GitHub resolves it the same way.
260
+ actionDir = join(ctx.tree, uses.slice(2));
261
+ }
262
+ else {
263
+ const target = parseActionUses(uses);
264
+ if (target == null)
265
+ return err(`${label}: unresolvable uses: ${uses}`);
266
+ const { ref } = target.source;
267
+ const sha = SHA_RE.test(ref) ? ref : await ctx.deps.resolveRef(target.source);
268
+ if (sha == null)
269
+ return err(`${label}: cannot resolve ref for ${uses}`);
270
+ const source = { ...target.source, sha };
271
+ const root = await ctx.deps.provideTree(source);
272
+ if (root == null) {
273
+ return err(`${label}: cannot materialize ${source.owner}/${source.repo}@${sha}`);
274
+ }
275
+ actionDir = target.path === "" ? root : join(root, target.path);
276
+ }
277
+ const manifest = await readActionManifest(actionDir);
278
+ if (manifest == null)
279
+ return err(`${label}: no action.yml under ${uses}`);
280
+ let action;
281
+ try {
282
+ action = parseYaml(manifest);
283
+ }
284
+ catch (e) {
285
+ return err(`${label}: YAML parse error in ${uses}: ${e}`);
286
+ }
287
+ const using = action?.runs?.using;
288
+ if (using !== "composite") {
289
+ // A JavaScript or Docker action is a program with its own runtime and its
290
+ // own view of the world. Running one is a much larger promise than
291
+ // running a shell step, and no granted job needs it.
292
+ return err(`${label}: action ${uses} runs via '${using}'; only composite actions are executed`);
293
+ }
294
+ const childScope = {
295
+ inputs: bindActionInputs(action, step.with, scope),
296
+ github: scope.github,
297
+ };
298
+ const walked = await runSteps(action?.runs?.steps ?? [], childScope, {
299
+ ...ctx,
300
+ actionPath: actionDir,
301
+ depth: ctx.depth + 1,
302
+ });
303
+ if (!walked.ok)
304
+ return err(`${label} (${uses}): ${walked.reason}`);
305
+ // The action's declared outputs are its whole surface: each `value:` is
306
+ // evaluated against the child's own steps, and every one must land.
307
+ const outScope = { ...childScope, steps: walked.v };
308
+ const outputs = {};
309
+ for (const [name, decl] of Object.entries(action.outputs ?? {})) {
310
+ const raw = decl?.["value"];
311
+ if (raw == null)
312
+ return err(`${label}: output '${name}' of ${uses} has no value`);
313
+ const rendered = renderTemplate(String(raw), outScope);
314
+ if (rendered == null)
315
+ return err(`${label}: cannot resolve output '${name}' of ${uses}`);
316
+ outputs[name] = rendered;
317
+ }
318
+ return { ok: true, v: outputs };
319
+ }
320
+ /** A `run:` step, executed under its declared shell with its declared env. */
321
+ async function runRun(step, label, scope, ctx) {
322
+ const shell = step.shell == null ? "bash" : String(step.shell);
323
+ if (shell !== "bash" && shell !== "sh") {
324
+ return err(`${label}: shell '${shell}' is not modelled`);
325
+ }
326
+ const script = renderTemplate(String(step.run), scope);
327
+ if (script == null)
328
+ return err(`${label}: cannot resolve \${{ }} in run`);
329
+ const env = {
330
+ // The two the runner always provides and scripts assume. Everything else
331
+ // a step sees, it declared.
332
+ PATH: process.env.PATH ?? "",
333
+ HOME: process.env.HOME ?? "",
334
+ GITHUB_WORKSPACE: ctx.tree,
335
+ };
336
+ if (ctx.actionPath != null)
337
+ env.GITHUB_ACTION_PATH = ctx.actionPath;
338
+ for (const layer of [...ctx.envLayers, step.env]) {
339
+ const rendered = renderEnvLayer(layer, scope);
340
+ if (!rendered.ok)
341
+ return err(`${label}: ${rendered.reason}`);
342
+ Object.assign(env, rendered.v);
343
+ }
344
+ let cwd = ctx.tree;
345
+ if (step["working-directory"] != null) {
346
+ const wd = renderTemplate(String(step["working-directory"]), scope);
347
+ if (wd == null)
348
+ return err(`${label}: cannot resolve working-directory`);
349
+ cwd = resolve(ctx.tree, wd);
350
+ }
351
+ const outDir = await mkdtemp(join(tmpdir(), "willfire-out-"));
352
+ const outFile = join(outDir, "output");
353
+ await writeFile(outFile, "");
354
+ // After the layers, so no `env:` block can redirect where outputs land.
355
+ env.GITHUB_OUTPUT = outFile;
356
+ const r = await ctx.deps.runCommand({ script, shell, cwd, env });
357
+ if (r.code !== 0) {
358
+ const trimmed = r.stderr.trim();
359
+ const tail = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
360
+ return err(`${label}: exited ${r.code}${tail === "" ? "" : ` (${tail})`}`);
361
+ }
362
+ const outputs = parseGithubOutput(await readFile(outFile, "utf8"));
363
+ if (outputs == null)
364
+ return err(`${label}: malformed GITHUB_OUTPUT`);
365
+ return { ok: true, v: outputs };
366
+ }
367
+ // ------------------------------------------------------------------ executor
368
+ export function makeExecutor(opts) {
369
+ const { grants, workspace, deps } = opts;
370
+ const github = {
371
+ event_name: "pull_request",
372
+ // Fixed for the run being predicted, and the fact the fleet's
373
+ // hermetic-vs-published guards branch on.
374
+ repository: `${workspace.owner}/${workspace.repo}`,
375
+ };
376
+ const fail = (reason) => ({ ok: false, reason });
377
+ return {
378
+ granted: (source, jobId) => grants.some((g) => g.repo === `${source.owner}/${source.repo}` && g.jobs.includes(jobId)),
379
+ async executeJob(jobId, job, wf, scope) {
380
+ // The shapes execution does not model, refused by name rather than run
381
+ // wrong: a matrix'd job is several executions, and a container changes
382
+ // what every step means.
383
+ if (job.strategy != null)
384
+ return fail(`job '${jobId}' has a strategy; not modelled`);
385
+ if (job.container != null || job.services != null) {
386
+ return fail(`job '${jobId}' uses a container or services; not modelled`);
387
+ }
388
+ if (!Array.isArray(job.steps))
389
+ return fail(`job '${jobId}' has no steps`);
390
+ const tree = await deps.provideTree(workspace);
391
+ if (tree == null) {
392
+ return fail(`cannot materialize workspace ${workspace.owner}/${workspace.repo}@${workspace.sha}`);
393
+ }
394
+ const jobScope = { ...scope, github: { ...github, ...scope.github } };
395
+ const walked = await runSteps(job.steps, jobScope, {
396
+ tree,
397
+ envLayers: [wf?.env, job.env],
398
+ deps,
399
+ depth: 0,
400
+ });
401
+ if (!walked.ok)
402
+ return fail(walked.reason);
403
+ // The job's `outputs:` map is the whole point of having run anything.
404
+ // Every declared entry must land; a hole here would hand consumers a
405
+ // partial map, which the Scope contract calls a lie.
406
+ const outScope = { ...jobScope, steps: walked.v };
407
+ const outputs = {};
408
+ for (const [name, raw] of Object.entries(job.outputs ?? {})) {
409
+ const rendered = renderTemplate(String(raw), outScope);
410
+ if (rendered == null)
411
+ return fail(`cannot resolve job output '${name}'`);
412
+ outputs[name] = rendered;
413
+ }
414
+ return { ok: true, outputs };
415
+ },
416
+ };
417
+ }
418
+ // ------------------------------------------------------------ real-world deps
419
+ /**
420
+ * The runner's default shell invocations, faithfully: `bash --noprofile
421
+ * --norc -e -o pipefail` and `sh -e`. Nothing of the parent environment
422
+ * leaks in beyond what the spec names.
423
+ */
424
+ export const runShell = (spec) => new Promise((resolvePromise) => {
425
+ const argv = spec.shell === "bash"
426
+ ? ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", spec.script]
427
+ : ["-e", "-c", spec.script];
428
+ const child = spawn(spec.shell, argv, {
429
+ cwd: spec.cwd,
430
+ env: spec.env,
431
+ stdio: ["ignore", "ignore", "pipe"],
432
+ });
433
+ let stderr = "";
434
+ child.stderr.on("data", (d) => {
435
+ stderr += String(d);
436
+ // Keep the tail; a failure reason wants the last line, not a transcript.
437
+ if (stderr.length > 4096)
438
+ stderr = stderr.slice(-4096);
439
+ });
440
+ child.on("error", () => resolvePromise({ code: 127, stderr }));
441
+ child.on("close", (code) => resolvePromise({ code: code ?? 1, stderr }));
442
+ });
443
+ /**
444
+ * Materialize repo trees from tarballs, one download per commit however many
445
+ * steps ask. GitHub's tarballs wrap the tree in a single
446
+ * `owner-repo-shortsha/` directory, which is unwrapped so callers get the
447
+ * tree root itself. Extraction shells out to `tar` through the same
448
+ * `RunCommand` seam every other subprocess uses.
449
+ */
450
+ export function makeTreeProvider(download, runCommand) {
451
+ const cache = new Map();
452
+ return (source) => {
453
+ const key = `${source.owner}/${source.repo}@${source.sha}`;
454
+ const hit = cache.get(key);
455
+ if (hit !== undefined)
456
+ return hit;
457
+ const p = materialize(source, download, runCommand);
458
+ cache.set(key, p);
459
+ return p;
460
+ };
461
+ }
462
+ async function materialize(source, download, runCommand) {
463
+ const bytes = await download(source);
464
+ if (bytes == null)
465
+ return null;
466
+ const dir = await mkdtemp(join(tmpdir(), "willfire-tree-"));
467
+ const archive = join(dir, "tree.tar.gz");
468
+ await writeFile(archive, bytes);
469
+ const dest = join(dir, "tree");
470
+ await mkdir(dest);
471
+ const r = await runCommand({
472
+ script: 'tar -xzf "$WILLFIRE_ARCHIVE" -C "$WILLFIRE_DEST"',
473
+ shell: "bash",
474
+ cwd: dir,
475
+ env: {
476
+ PATH: process.env.PATH ?? "",
477
+ WILLFIRE_ARCHIVE: archive,
478
+ WILLFIRE_DEST: dest,
479
+ },
480
+ });
481
+ if (r.code !== 0)
482
+ return null;
483
+ const entries = await readdir(dest);
484
+ if (entries.length === 1) {
485
+ const sub = join(dest, entries[0]);
486
+ if ((await stat(sub)).isDirectory())
487
+ return sub;
488
+ }
489
+ return dest;
490
+ }
package/dist/expr.d.ts CHANGED
@@ -40,6 +40,16 @@
40
40
  export type Val = {
41
41
  kind: "value";
42
42
  v: string | number | boolean;
43
+ }
44
+ /**
45
+ * An array or an object, which only `fromJSON` produces. Kept apart from
46
+ * `value` because nothing else in the language accepts one: comparison
47
+ * refuses it, and its truthiness is not modelled. Matrix expansion is the
48
+ * one consumer, and it asks for the array directly.
49
+ */
50
+ | {
51
+ kind: "json";
52
+ v: unknown[] | Record<string, unknown>;
43
53
  } | {
44
54
  kind: "truthy";
45
55
  } | {
@@ -58,12 +68,50 @@ export interface Scope {
58
68
  inputs?: Record<string, Val>;
59
69
  /** `github.*` values that are fixed for the run being predicted. */
60
70
  github?: Record<string, string>;
71
+ /**
72
+ * Outputs of jobs this workflow's jobs `needs`, keyed by job id.
73
+ *
74
+ * `outputs` is the *complete* set for that job, which is what makes a key
75
+ * that is absent from it mean the empty string — the same answer the runner
76
+ * gives for an output no step wrote. Handing in a partial map is therefore a
77
+ * lie, not a shortcut: leave the job out entirely instead, and every lookup
78
+ * against it stays unknown.
79
+ *
80
+ * The values are raw strings, because that is what a step wrote to
81
+ * `$GITHUB_OUTPUT` and what the runner substitutes. Parsing eagerly would
82
+ * break the guards written against them — `!= '[]'` compares a string to a
83
+ * string, and an array on the left makes it unknown. `fromJSON` is the only
84
+ * thing that turns one into a structure, at the point the workflow asks.
85
+ */
86
+ needs?: Record<string, {
87
+ outputs: Record<string, string>;
88
+ }>;
89
+ /**
90
+ * Outputs of steps that already ran, keyed by step id. Only one caller can
91
+ * fill this honestly: the executor's step walk (see execute.ts), which is
92
+ * the single place a step has actually run by the time an expression reads
93
+ * it. The contract is the same as `needs`: a step named here carries its
94
+ * *complete* output set, so an absent key is the empty string the runner
95
+ * substitutes, while a step this map does not name stays unknown. A skipped
96
+ * step is present with no outputs at all — which is exactly what lets
97
+ * `steps.a.outputs.x || steps.b.outputs.x` coalesce past it.
98
+ */
99
+ steps?: Record<string, {
100
+ outputs: Record<string, string>;
101
+ }>;
61
102
  }
62
103
  /**
63
- * Evaluate a condition to a truthiness, or null when it cannot be settled.
104
+ * Evaluate an expression to a value, or UNKNOWN when it cannot be settled.
64
105
  *
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
106
+ * The `${{ }}` wrapper is optional in `if:` and stripped when present. An
107
+ * expression that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
67
108
  * interpolation rather than an expression, and is not modelled.
109
+ *
110
+ * A `if:` wants {@link evaluate}, which is this narrowed to truthiness. This
111
+ * one is for the places that need the value itself — a matrix axis written as
112
+ * `${{ fromJSON(...) }}` is an array, and its truthiness says nothing about
113
+ * how many jobs it schedules.
68
114
  */
115
+ export declare function evaluateValue(expr: string, scope?: Scope): Val;
116
+ /** Evaluate a condition to a truthiness, or null when it cannot be settled. */
69
117
  export declare function evaluate(cond: string, scope?: Scope): boolean | null;