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/execute.js
ADDED
|
@@ -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
|
|
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.
|
|
66
|
-
*
|
|
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;
|