willfire 0.1.19 → 0.1.21
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 +44 -45
- package/dist/cli/parseArgs.d.ts +0 -2
- package/dist/cli/parseArgs.js +2 -21
- package/dist/cli.js +0 -1
- package/dist/execute.d.ts +44 -72
- package/dist/execute.js +232 -121
- package/dist/index.d.ts +1 -0
- package/dist/jobs/expandJobs.d.ts +1 -1
- package/dist/jobs/expandJobs.js +56 -36
- package/dist/matrix/expandMatrixDetailed.js +23 -3
- package/dist/predict/makeLiveExecutor.d.ts +20 -0
- package/dist/predict/makeLiveExecutor.js +38 -0
- package/dist/predict/predict.js +5 -31
- package/dist/sandbox.d.ts +35 -0
- package/dist/sandbox.js +130 -0
- package/dist/types.d.ts +5 -10
- package/package.json +4 -1
package/dist/execute.js
CHANGED
|
@@ -1,33 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Execute a job
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
2
|
+
* Execute a job whose outputs another job reads, the way the runner would:
|
|
3
|
+
* materialize the tree, walk the steps, read what they wrote to
|
|
4
|
+
* `$GITHUB_OUTPUT`. Two invariants: run it, never interpret it (no shell text
|
|
5
|
+
* is parsed for meaning), and anything off the modelled path is a hard stop
|
|
6
|
+
* with a reason — never a guess.
|
|
31
7
|
*/
|
|
32
8
|
import { spawn } from "node:child_process";
|
|
33
9
|
import { mkdir, mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
@@ -37,36 +13,13 @@ import { parse as parseYaml } from "yaml";
|
|
|
37
13
|
import { evaluate } from "./expr/evaluate.js";
|
|
38
14
|
import { evaluateValue } from "./expr/evaluateValue.js";
|
|
39
15
|
import { UNKNOWN } from "./expr/val.js";
|
|
40
|
-
/** `owner/repo:job1,job2` as the CLI spells a grant. */
|
|
41
|
-
export function parseGrant(spec) {
|
|
42
|
-
const colon = spec.indexOf(":");
|
|
43
|
-
if (colon <= 0) {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
const repo = spec.slice(0, colon);
|
|
47
|
-
const parts = repo.split("/");
|
|
48
|
-
if (parts.length !== 2 || parts.some((p) => p === "")) {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
const jobs = spec
|
|
52
|
-
.slice(colon + 1)
|
|
53
|
-
.split(",")
|
|
54
|
-
.map((s) => s.trim())
|
|
55
|
-
.filter((s) => s !== "");
|
|
56
|
-
if (jobs.length === 0) {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
return { repo, jobs };
|
|
60
|
-
}
|
|
61
16
|
const err = (reason) => ({ ok: false, reason });
|
|
62
17
|
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
63
18
|
/**
|
|
64
|
-
* Render every `${{ }}`
|
|
65
|
-
*
|
|
66
|
-
* a hole in it is a different program, and running a different program is the
|
|
67
|
-
* exact lie rule 2 exists to prevent.
|
|
19
|
+
* Render every `${{ }}` to literal text, or null when any cannot be settled —
|
|
20
|
+
* a partial render would be a different program.
|
|
68
21
|
*/
|
|
69
|
-
function renderTemplate(text, scope) {
|
|
22
|
+
export function renderTemplate(text, scope) {
|
|
70
23
|
let failed = false;
|
|
71
24
|
const out = text.replace(/\$\{\{(.*?)\}\}/g, (_whole, inner) => {
|
|
72
25
|
const val = evaluateValue(String(inner), scope);
|
|
@@ -97,10 +50,8 @@ function renderEnvLayer(layer, scope) {
|
|
|
97
50
|
return { ok: true, v: out };
|
|
98
51
|
}
|
|
99
52
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* fails the parse — the runner fails the step on a malformed line, so
|
|
103
|
-
* tolerating one here would invent outputs a real run never had.
|
|
53
|
+
* `name=value` lines or `name<<DELIMITER` heredocs. Anything else fails the
|
|
54
|
+
* parse, as the runner fails the step on a malformed line.
|
|
104
55
|
*/
|
|
105
56
|
export function parseGithubOutput(text) {
|
|
106
57
|
const out = {};
|
|
@@ -140,9 +91,8 @@ export function parseGithubOutput(text) {
|
|
|
140
91
|
}
|
|
141
92
|
// ------------------------------------------------------------------- actions
|
|
142
93
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* lives at the repo root. Expressions and `docker://` images return null.
|
|
94
|
+
* `owner/repo[/path]@ref` — unlike a reusable-workflow reference the path may
|
|
95
|
+
* be empty, since an action commonly lives at the repo root.
|
|
146
96
|
*/
|
|
147
97
|
function parseActionUses(uses) {
|
|
148
98
|
if (uses.includes("${{") || uses.startsWith("docker://")) {
|
|
@@ -162,25 +112,21 @@ function parseActionUses(uses) {
|
|
|
162
112
|
}
|
|
163
113
|
return { path: rest.join("/"), source: { owner, repo, ref } };
|
|
164
114
|
}
|
|
165
|
-
/** Read `action.yml` (or `.yaml`) from a directory, or null if neither exists. */
|
|
166
115
|
async function readActionManifest(dir) {
|
|
167
116
|
for (const name of ["action.yml", "action.yaml"]) {
|
|
168
117
|
try {
|
|
169
118
|
return await readFile(join(dir, name), "utf8");
|
|
170
119
|
}
|
|
171
120
|
catch {
|
|
172
|
-
|
|
121
|
+
/* try the other spelling */
|
|
173
122
|
}
|
|
174
123
|
}
|
|
175
124
|
return null;
|
|
176
125
|
}
|
|
177
126
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* hole. A value whose `${{ }}` cannot be rendered stays unknown rather than
|
|
182
|
-
* failing here: it only matters if a step actually reads it, and the read is
|
|
183
|
-
* where that failure is honest.
|
|
127
|
+
* Caller's `with:` over declared defaults, everything a string — action inputs
|
|
128
|
+
* are untyped, and an unset input is the empty string. An unrenderable value
|
|
129
|
+
* stays unknown; it only fails if a step reads it.
|
|
184
130
|
*/
|
|
185
131
|
function bindActionInputs(action, withBlock, scope) {
|
|
186
132
|
const bind = (raw) => {
|
|
@@ -208,17 +154,10 @@ function bindActionInputs(action, withBlock, scope) {
|
|
|
208
154
|
return out;
|
|
209
155
|
}
|
|
210
156
|
// ----------------------------------------------------------------- step walk
|
|
211
|
-
/**
|
|
212
|
-
* A cycle guard, not a fidelity claim: a composite action that includes
|
|
213
|
-
* itself would otherwise recurse forever. No granted job in practice nests
|
|
214
|
-
* past one level.
|
|
215
|
-
*/
|
|
157
|
+
/** A cycle guard, not a fidelity claim — a self-including composite would recurse forever. */
|
|
216
158
|
const MAX_ACTION_DEPTH = 4;
|
|
217
159
|
const CHECKOUT_RE = /^actions\/checkout@/;
|
|
218
|
-
|
|
219
|
-
* Walk steps in order, growing the `steps` context as each one completes.
|
|
220
|
-
* Returns the finished context, or the reason the walk stopped.
|
|
221
|
-
*/
|
|
160
|
+
const SETUP_NODE_RE = /^actions\/setup-node@/;
|
|
222
161
|
async function runSteps(steps, scope, ctx) {
|
|
223
162
|
const stepsCtx = {};
|
|
224
163
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -231,8 +170,7 @@ async function runSteps(steps, scope, ctx) {
|
|
|
231
170
|
return err(`cannot decide if: for ${label}`);
|
|
232
171
|
}
|
|
233
172
|
if (!verdict) {
|
|
234
|
-
// A skipped step still occupies its id, with no outputs
|
|
235
|
-
// empty string every later read gets, and what `||` coalesces past.
|
|
173
|
+
// A skipped step still occupies its id, with no outputs.
|
|
236
174
|
if (typeof step.id === "string") {
|
|
237
175
|
stepsCtx[step.id] = { outputs: {} };
|
|
238
176
|
}
|
|
@@ -258,25 +196,53 @@ async function runSteps(steps, scope, ctx) {
|
|
|
258
196
|
}
|
|
259
197
|
return { ok: true, v: stepsCtx };
|
|
260
198
|
}
|
|
261
|
-
/** A `uses:` step:
|
|
199
|
+
/** A `uses:` step: a runner-provided postcondition, an action to run, or a stop. */
|
|
262
200
|
async function runUses(step, label, scope, ctx) {
|
|
263
201
|
const uses = step.uses;
|
|
264
202
|
if (CHECKOUT_RE.test(uses)) {
|
|
265
203
|
// Runner-provided, and its postcondition — the head tree at the workspace
|
|
266
|
-
// path — is already true.
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
204
|
+
// path — is already true. Any input beyond `fetch-depth: 0` asks for a
|
|
205
|
+
// different tree than the one provided.
|
|
206
|
+
const withKeys = Object.keys(step.with ?? {});
|
|
207
|
+
if (withKeys.length === 0) {
|
|
208
|
+
return { ok: true, v: {} };
|
|
270
209
|
}
|
|
271
|
-
|
|
210
|
+
if (withKeys.length === 1 && String(step.with["fetch-depth"]) === "0") {
|
|
211
|
+
// Unmet inside a composite: the pre-scan that picks the tree provider
|
|
212
|
+
// only reads the job's own steps.
|
|
213
|
+
if (!ctx.hasHistory) {
|
|
214
|
+
return err(`${label}: checkout wants history the workspace does not have`);
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, v: {} };
|
|
217
|
+
}
|
|
218
|
+
return err(`${label}: actions/checkout with inputs is not modelled`);
|
|
219
|
+
}
|
|
220
|
+
if (SETUP_NODE_RE.test(uses)) {
|
|
221
|
+
// The execution world ships exactly one node: asking for it is already
|
|
222
|
+
// satisfied, asking for anything else cannot be.
|
|
223
|
+
const withKeys = Object.keys(step.with ?? {});
|
|
224
|
+
if (withKeys.length === 0) {
|
|
225
|
+
return { ok: true, v: {} };
|
|
226
|
+
}
|
|
227
|
+
if (withKeys.length === 1 && withKeys[0] === "node-version") {
|
|
228
|
+
const wanted = renderTemplate(String(step.with["node-version"]), scope);
|
|
229
|
+
if (wanted === null) {
|
|
230
|
+
return err(`${label}: cannot resolve node-version`);
|
|
231
|
+
}
|
|
232
|
+
const m = /^v?(\d+)(\..*)?$/.exec(wanted.trim());
|
|
233
|
+
if (m !== null && Number(m[1]) === ctx.deps.nodeMajor) {
|
|
234
|
+
return { ok: true, v: {} };
|
|
235
|
+
}
|
|
236
|
+
return err(`${label}: setup-node wants node ${wanted}; the sandbox has node ${ctx.deps.nodeMajor}`);
|
|
237
|
+
}
|
|
238
|
+
return err(`${label}: setup-node with inputs beyond node-version is not modelled`);
|
|
272
239
|
}
|
|
273
240
|
if (ctx.depth + 1 > MAX_ACTION_DEPTH) {
|
|
274
241
|
return err(`${label}: actions nested deeper than ${MAX_ACTION_DEPTH} levels`);
|
|
275
242
|
}
|
|
276
243
|
let actionDir;
|
|
244
|
+
let actionRoot;
|
|
277
245
|
if (uses.startsWith("./")) {
|
|
278
|
-
// Relative to the workspace, hermetic-style: the tree under test carries
|
|
279
|
-
// the action. GitHub resolves it the same way.
|
|
280
246
|
actionDir = join(ctx.tree, uses.slice(2));
|
|
281
247
|
}
|
|
282
248
|
else {
|
|
@@ -295,6 +261,7 @@ async function runUses(step, label, scope, ctx) {
|
|
|
295
261
|
return err(`${label}: cannot materialize ${source.owner}/${source.repo}@${sha}`);
|
|
296
262
|
}
|
|
297
263
|
actionDir = target.path === "" ? root : join(root, target.path);
|
|
264
|
+
actionRoot = root;
|
|
298
265
|
}
|
|
299
266
|
const manifest = await readActionManifest(actionDir);
|
|
300
267
|
if (manifest == null) {
|
|
@@ -308,11 +275,12 @@ async function runUses(step, label, scope, ctx) {
|
|
|
308
275
|
return err(`${label}: YAML parse error in ${uses}: ${e}`);
|
|
309
276
|
}
|
|
310
277
|
const using = action?.runs?.using;
|
|
278
|
+
const nodeUsing = /^node(\d+)$/.exec(String(using));
|
|
279
|
+
if (nodeUsing !== null) {
|
|
280
|
+
return runNodeAction(step, label, uses, action, actionDir, actionRoot, Number(nodeUsing[1]), scope, ctx);
|
|
281
|
+
}
|
|
311
282
|
if (using !== "composite") {
|
|
312
|
-
|
|
313
|
-
// own view of the world. Running one is a much larger promise than
|
|
314
|
-
// running a shell step, and no granted job needs it.
|
|
315
|
-
return err(`${label}: action ${uses} runs via '${using}'; only composite actions are executed`);
|
|
283
|
+
return err(`${label}: action ${uses} runs via '${using}'; only composite and node actions are executed`);
|
|
316
284
|
}
|
|
317
285
|
const childScope = {
|
|
318
286
|
inputs: bindActionInputs(action, step.with, scope),
|
|
@@ -321,13 +289,13 @@ async function runUses(step, label, scope, ctx) {
|
|
|
321
289
|
const walked = await runSteps(action?.runs?.steps ?? [], childScope, {
|
|
322
290
|
...ctx,
|
|
323
291
|
actionPath: actionDir,
|
|
292
|
+
actionRoot,
|
|
324
293
|
depth: ctx.depth + 1,
|
|
325
294
|
});
|
|
326
295
|
if (!walked.ok) {
|
|
327
296
|
return err(`${label} (${uses}): ${walked.reason}`);
|
|
328
297
|
}
|
|
329
|
-
//
|
|
330
|
-
// evaluated against the child's own steps, and every one must land.
|
|
298
|
+
// Every declared output must land; a partial map would be a lie.
|
|
331
299
|
const outScope = { ...childScope, steps: walked.v };
|
|
332
300
|
const outputs = {};
|
|
333
301
|
for (const [name, decl] of Object.entries(action.outputs ?? {})) {
|
|
@@ -343,6 +311,77 @@ async function runUses(step, label, scope, ctx) {
|
|
|
343
311
|
}
|
|
344
312
|
return { ok: true, v: outputs };
|
|
345
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* `node <main>` with inputs bound as `INPUT_*` env vars. What lands in
|
|
316
|
+
* `$GITHUB_OUTPUT` is the whole output surface — a node action's manifest
|
|
317
|
+
* `outputs:` block is documentation, not a mapping.
|
|
318
|
+
*/
|
|
319
|
+
async function runNodeAction(step, label, uses, action, actionDir, actionRoot, usingMajor, scope, ctx) {
|
|
320
|
+
if (usingMajor !== ctx.deps.nodeMajor) {
|
|
321
|
+
return err(`${label}: action ${uses} wants node ${usingMajor}; the sandbox has node ${ctx.deps.nodeMajor}`);
|
|
322
|
+
}
|
|
323
|
+
if (action?.runs?.pre !== undefined && action.runs.pre !== null) {
|
|
324
|
+
return err(`${label}: action ${uses} declares a pre: step; not modelled`);
|
|
325
|
+
}
|
|
326
|
+
// `post:` runs after the job's own steps, so no job output can depend on it.
|
|
327
|
+
const main = action?.runs?.main;
|
|
328
|
+
if (typeof main !== "string") {
|
|
329
|
+
return err(`${label}: action ${uses} has no runs.main`);
|
|
330
|
+
}
|
|
331
|
+
const env = {
|
|
332
|
+
PATH: process.env.PATH ?? "",
|
|
333
|
+
HOME: process.env.HOME ?? "",
|
|
334
|
+
GITHUB_WORKSPACE: ctx.tree,
|
|
335
|
+
};
|
|
336
|
+
if (scope.github?.repository !== undefined) {
|
|
337
|
+
env.GITHUB_REPOSITORY = scope.github.repository;
|
|
338
|
+
}
|
|
339
|
+
if (scope.github?.event_name !== undefined) {
|
|
340
|
+
env.GITHUB_EVENT_NAME = scope.github.event_name;
|
|
341
|
+
}
|
|
342
|
+
for (const layer of [...ctx.envLayers, step.env]) {
|
|
343
|
+
const rendered = renderEnvLayer(layer, scope);
|
|
344
|
+
if (!rendered.ok) {
|
|
345
|
+
return err(`${label}: ${rendered.reason}`);
|
|
346
|
+
}
|
|
347
|
+
Object.assign(env, rendered.v);
|
|
348
|
+
}
|
|
349
|
+
// Unlike a composite's, a node action's input reads are opaque, so every
|
|
350
|
+
// binding must be concrete up front.
|
|
351
|
+
for (const [name, val] of Object.entries(bindActionInputs(action, step.with, scope))) {
|
|
352
|
+
if (val.kind !== "value") {
|
|
353
|
+
return err(`${label}: cannot resolve input '${name}' of ${uses}`);
|
|
354
|
+
}
|
|
355
|
+
env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] = String(val.v);
|
|
356
|
+
}
|
|
357
|
+
const outDir = await mkdtemp(join(tmpdir(), "willfire-out-"));
|
|
358
|
+
const outFile = join(outDir, "output");
|
|
359
|
+
await writeFile(outFile, "");
|
|
360
|
+
// After the layers, so no `env:` block can redirect either one.
|
|
361
|
+
env.GITHUB_OUTPUT = outFile;
|
|
362
|
+
env.WILLFIRE_ACTION_MAIN = join(actionDir, main);
|
|
363
|
+
const r = await ctx.deps.runCommand({
|
|
364
|
+
script: 'exec node "$WILLFIRE_ACTION_MAIN"',
|
|
365
|
+
shell: "bash",
|
|
366
|
+
cwd: ctx.tree,
|
|
367
|
+
env,
|
|
368
|
+
mounts: [
|
|
369
|
+
{ path: ctx.tree, writable: true },
|
|
370
|
+
...(actionRoot !== undefined ? [{ path: actionRoot, writable: false }] : []),
|
|
371
|
+
{ path: outDir, writable: true },
|
|
372
|
+
],
|
|
373
|
+
});
|
|
374
|
+
if (r.code !== 0) {
|
|
375
|
+
const trimmed = r.stderr.trim();
|
|
376
|
+
const tail = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
|
|
377
|
+
return err(`${label}: exited ${r.code}${tail === "" ? "" : ` (${tail})`}`);
|
|
378
|
+
}
|
|
379
|
+
const outputs = parseGithubOutput(await readFile(outFile, "utf8"));
|
|
380
|
+
if (outputs === null) {
|
|
381
|
+
return err(`${label}: malformed GITHUB_OUTPUT`);
|
|
382
|
+
}
|
|
383
|
+
return { ok: true, v: outputs };
|
|
384
|
+
}
|
|
346
385
|
/** A `run:` step, executed under its declared shell with its declared env. */
|
|
347
386
|
async function runRun(step, label, scope, ctx) {
|
|
348
387
|
const shell = step.shell == null ? "bash" : String(step.shell);
|
|
@@ -354,13 +393,19 @@ async function runRun(step, label, scope, ctx) {
|
|
|
354
393
|
return err(`${label}: cannot resolve \${{ }} in run`);
|
|
355
394
|
}
|
|
356
395
|
const env = {
|
|
357
|
-
//
|
|
358
|
-
//
|
|
396
|
+
// Everything else a step sees, it declared. A sandboxed runner swaps PATH
|
|
397
|
+
// and HOME for its own.
|
|
359
398
|
PATH: process.env.PATH ?? "",
|
|
360
399
|
HOME: process.env.HOME ?? "",
|
|
361
400
|
GITHUB_WORKSPACE: ctx.tree,
|
|
362
401
|
};
|
|
363
|
-
if (
|
|
402
|
+
if (scope.github?.repository !== undefined) {
|
|
403
|
+
env.GITHUB_REPOSITORY = scope.github.repository;
|
|
404
|
+
}
|
|
405
|
+
if (scope.github?.event_name !== undefined) {
|
|
406
|
+
env.GITHUB_EVENT_NAME = scope.github.event_name;
|
|
407
|
+
}
|
|
408
|
+
if (ctx.actionPath !== undefined) {
|
|
364
409
|
env.GITHUB_ACTION_PATH = ctx.actionPath;
|
|
365
410
|
}
|
|
366
411
|
for (const layer of [...ctx.envLayers, step.env]) {
|
|
@@ -383,7 +428,17 @@ async function runRun(step, label, scope, ctx) {
|
|
|
383
428
|
await writeFile(outFile, "");
|
|
384
429
|
// After the layers, so no `env:` block can redirect where outputs land.
|
|
385
430
|
env.GITHUB_OUTPUT = outFile;
|
|
386
|
-
const r = await ctx.deps.runCommand({
|
|
431
|
+
const r = await ctx.deps.runCommand({
|
|
432
|
+
script,
|
|
433
|
+
shell,
|
|
434
|
+
cwd,
|
|
435
|
+
env,
|
|
436
|
+
mounts: [
|
|
437
|
+
{ path: ctx.tree, writable: true },
|
|
438
|
+
...(ctx.actionRoot !== undefined ? [{ path: ctx.actionRoot, writable: false }] : []),
|
|
439
|
+
{ path: outDir, writable: true },
|
|
440
|
+
],
|
|
441
|
+
});
|
|
387
442
|
if (r.code !== 0) {
|
|
388
443
|
const trimmed = r.stderr.trim();
|
|
389
444
|
const tail = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
|
|
@@ -397,21 +452,15 @@ async function runRun(step, label, scope, ctx) {
|
|
|
397
452
|
}
|
|
398
453
|
// ------------------------------------------------------------------ executor
|
|
399
454
|
export function makeExecutor(opts) {
|
|
400
|
-
const {
|
|
455
|
+
const { workspace, deps } = opts;
|
|
401
456
|
const github = {
|
|
402
457
|
event_name: "pull_request",
|
|
403
|
-
// Fixed for the run being predicted, and the fact the fleet's
|
|
404
|
-
// hermetic-vs-published guards branch on.
|
|
405
458
|
repository: `${workspace.owner}/${workspace.repo}`,
|
|
406
459
|
};
|
|
407
460
|
const fail = (reason) => ({ ok: false, reason });
|
|
408
461
|
return {
|
|
409
|
-
granted: (source, jobId) => grants.some((g) => g.repo === `${source.owner}/${source.repo}` && g.jobs.includes(jobId)),
|
|
410
462
|
async executeJob(jobId, job, wf, scope) {
|
|
411
|
-
|
|
412
|
-
// wrong: a matrix'd job is several executions, and a container changes
|
|
413
|
-
// what every step means.
|
|
414
|
-
if (job.strategy != null) {
|
|
463
|
+
if (job.strategy !== undefined && job.strategy !== null) {
|
|
415
464
|
return fail(`job '${jobId}' has a strategy; not modelled`);
|
|
416
465
|
}
|
|
417
466
|
if (job.container != null || job.services != null) {
|
|
@@ -420,13 +469,21 @@ export function makeExecutor(opts) {
|
|
|
420
469
|
if (!Array.isArray(job.steps)) {
|
|
421
470
|
return fail(`job '${jobId}' has no steps`);
|
|
422
471
|
}
|
|
423
|
-
|
|
472
|
+
// Any checkout input might be the `fetch-depth: 0` form. Over-asking for
|
|
473
|
+
// one the walk will refuse anyway costs a clone, never correctness.
|
|
474
|
+
const needsHistory = job.steps.some((s) => s !== null &&
|
|
475
|
+
s !== undefined &&
|
|
476
|
+
typeof s.uses === "string" &&
|
|
477
|
+
CHECKOUT_RE.test(s.uses) &&
|
|
478
|
+
Object.keys(s.with ?? {}).length > 0);
|
|
479
|
+
const tree = await deps.provideTree(workspace, { history: needsHistory });
|
|
424
480
|
if (tree == null) {
|
|
425
481
|
return fail(`cannot materialize workspace ${workspace.owner}/${workspace.repo}@${workspace.sha}`);
|
|
426
482
|
}
|
|
427
483
|
const jobScope = { ...scope, github: { ...github, ...scope.github } };
|
|
428
484
|
const walked = await runSteps(job.steps, jobScope, {
|
|
429
485
|
tree,
|
|
486
|
+
hasHistory: needsHistory,
|
|
430
487
|
envLayers: [wf?.env, job.env],
|
|
431
488
|
deps,
|
|
432
489
|
depth: 0,
|
|
@@ -434,9 +491,7 @@ export function makeExecutor(opts) {
|
|
|
434
491
|
if (!walked.ok) {
|
|
435
492
|
return fail(walked.reason);
|
|
436
493
|
}
|
|
437
|
-
//
|
|
438
|
-
// Every declared entry must land; a hole here would hand consumers a
|
|
439
|
-
// partial map, which the Scope contract calls a lie.
|
|
494
|
+
// Every declared output must land; a partial map would be a lie.
|
|
440
495
|
const outScope = { ...jobScope, steps: walked.v };
|
|
441
496
|
const outputs = {};
|
|
442
497
|
for (const [name, raw] of Object.entries(job.outputs ?? {})) {
|
|
@@ -452,9 +507,8 @@ export function makeExecutor(opts) {
|
|
|
452
507
|
}
|
|
453
508
|
// ------------------------------------------------------------ real-world deps
|
|
454
509
|
/**
|
|
455
|
-
* The runner's default shell invocations, faithfully
|
|
456
|
-
*
|
|
457
|
-
* leaks in beyond what the spec names.
|
|
510
|
+
* The runner's default shell invocations, faithfully. Nothing of the parent
|
|
511
|
+
* environment leaks in beyond what the spec names.
|
|
458
512
|
*/
|
|
459
513
|
export const runShell = (spec) => new Promise((resolvePromise) => {
|
|
460
514
|
const argv = spec.shell === "bash"
|
|
@@ -468,7 +522,6 @@ export const runShell = (spec) => new Promise((resolvePromise) => {
|
|
|
468
522
|
let stderr = "";
|
|
469
523
|
child.stderr.on("data", (d) => {
|
|
470
524
|
stderr += String(d);
|
|
471
|
-
// Keep the tail; a failure reason wants the last line, not a transcript.
|
|
472
525
|
if (stderr.length > 4096) {
|
|
473
526
|
stderr = stderr.slice(-4096);
|
|
474
527
|
}
|
|
@@ -477,15 +530,16 @@ export const runShell = (spec) => new Promise((resolvePromise) => {
|
|
|
477
530
|
child.on("close", (code) => resolvePromise({ code: code ?? 1, stderr }));
|
|
478
531
|
});
|
|
479
532
|
/**
|
|
480
|
-
* Materialize repo trees from tarballs, one download per commit
|
|
481
|
-
*
|
|
482
|
-
* `owner-repo-shortsha/` directory, which is unwrapped so callers get the
|
|
483
|
-
* tree root itself. Extraction shells out to `tar` through the same
|
|
484
|
-
* `RunCommand` seam every other subprocess uses.
|
|
533
|
+
* Materialize repo trees from tarballs, one download per commit. GitHub wraps
|
|
534
|
+
* the tree in a single `owner-repo-shortsha/` directory, unwrapped here.
|
|
485
535
|
*/
|
|
486
536
|
export function makeTreeProvider(download, runCommand) {
|
|
487
537
|
const cache = new Map();
|
|
488
|
-
return (source) => {
|
|
538
|
+
return (source, opts) => {
|
|
539
|
+
// A tarball has no history to give.
|
|
540
|
+
if (opts?.history === true) {
|
|
541
|
+
return Promise.resolve(null);
|
|
542
|
+
}
|
|
489
543
|
const key = `${source.owner}/${source.repo}@${source.sha}`;
|
|
490
544
|
const hit = cache.get(key);
|
|
491
545
|
if (hit !== undefined) {
|
|
@@ -496,6 +550,63 @@ export function makeTreeProvider(download, runCommand) {
|
|
|
496
550
|
return p;
|
|
497
551
|
};
|
|
498
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Materialize repo trees by full clone, on the host — it needs the network
|
|
555
|
+
* the sandbox denies. The token never touches the URL or persisted git
|
|
556
|
+
* config, because `.git/config` later rides into the sandbox: auth travels
|
|
557
|
+
* as a per-invocation `http.extraheader` and is gone when the command is.
|
|
558
|
+
*/
|
|
559
|
+
export function makeCloneProvider(runCommand, token, opts = {}) {
|
|
560
|
+
const remoteUrl = opts.remoteUrl ?? ((s) => `https://github.com/${s.owner}/${s.repo}.git`);
|
|
561
|
+
const cache = new Map();
|
|
562
|
+
return (source) => {
|
|
563
|
+
const key = `${source.owner}/${source.repo}@${source.sha}`;
|
|
564
|
+
const hit = cache.get(key);
|
|
565
|
+
if (hit !== undefined) {
|
|
566
|
+
return hit;
|
|
567
|
+
}
|
|
568
|
+
const p = cloneAt(source, remoteUrl(source), token, runCommand);
|
|
569
|
+
cache.set(key, p);
|
|
570
|
+
return p;
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async function cloneAt(source, remote, token, runCommand) {
|
|
574
|
+
const dir = await mkdtemp(join(tmpdir(), "willfire-clone-"));
|
|
575
|
+
const dest = join(dir, "tree");
|
|
576
|
+
const env = {
|
|
577
|
+
PATH: process.env.PATH ?? "",
|
|
578
|
+
// A fresh HOME and no system config: none of the invoking user's git
|
|
579
|
+
// identity or credential helpers reach this clone.
|
|
580
|
+
HOME: dir,
|
|
581
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
582
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
583
|
+
WILLFIRE_REMOTE: remote,
|
|
584
|
+
WILLFIRE_DEST: dest,
|
|
585
|
+
WILLFIRE_SHA: source.sha,
|
|
586
|
+
};
|
|
587
|
+
let auth = "";
|
|
588
|
+
if (token !== null) {
|
|
589
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
590
|
+
env.WILLFIRE_AUTH = `http.extraheader=AUTHORIZATION: basic ${basic}`;
|
|
591
|
+
auth = ' -c "$WILLFIRE_AUTH"';
|
|
592
|
+
}
|
|
593
|
+
// A PR head commit may live only under `refs/pull/N/head`, which a plain
|
|
594
|
+
// clone does not fetch, so a failed checkout retries via a direct fetch.
|
|
595
|
+
const r = await runCommand({
|
|
596
|
+
script: [
|
|
597
|
+
`git${auth} clone --quiet "$WILLFIRE_REMOTE" "$WILLFIRE_DEST"`,
|
|
598
|
+
'cd "$WILLFIRE_DEST"',
|
|
599
|
+
`git checkout --quiet --detach "$WILLFIRE_SHA" 2>/dev/null || {`,
|
|
600
|
+
` git${auth} fetch --quiet origin "$WILLFIRE_SHA"`,
|
|
601
|
+
' git checkout --quiet --detach "$WILLFIRE_SHA"',
|
|
602
|
+
"}",
|
|
603
|
+
].join("\n"),
|
|
604
|
+
shell: "bash",
|
|
605
|
+
cwd: dir,
|
|
606
|
+
env,
|
|
607
|
+
});
|
|
608
|
+
return r.code === 0 ? dest : null;
|
|
609
|
+
}
|
|
499
610
|
async function materialize(source, download, runCommand) {
|
|
500
611
|
const bytes = await download(source);
|
|
501
612
|
if (bytes == null) {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export { expandMatrix } from "./matrix/index.js";
|
|
|
4
4
|
export { evalIf, expandWorkflowJobs } from "./jobs/index.js";
|
|
5
5
|
export { parseUses } from "./uses/index.js";
|
|
6
6
|
export { makeOctokit, predict } from "./predict/index.js";
|
|
7
|
+
export type { ExecOutcome, JobExecutor } from "./execute.js";
|
|
7
8
|
export type { JobName, WorkflowEntry, JobEntry, Entry, Prediction, PrEventAction, PredictOptions, Ctx, ExpandedJob, SourceRef, WorkflowSource, FetchWorkflow, ResolveRef, WorkflowReader, UsesTarget, } from "./types.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type Scope } from "../expr/val.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type JobExecutor } from "../execute.js";
|
|
3
3
|
import type { Ctx, ExpandedJob, Workflow, WorkflowReader, WorkflowSource } from "../types.js";
|
|
4
4
|
export declare function expandJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource, depth?: number, prefix?: string, prefixResolved?: boolean, scope?: Scope, executor?: JobExecutor): Promise<ExpandedJob[]>;
|