willfire 0.1.18 → 0.1.20

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/execute.js CHANGED
@@ -1,33 +1,9 @@
1
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.
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 `${{ }}` in a template to its literal text, or null when any
65
- * of them cannot be settled. Null rather than a partial render: a script with
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
- * The `$GITHUB_OUTPUT` file format: `name=value` lines, or a
101
- * `name<<DELIMITER DELIMITER` heredoc for multi-line values. Anything else
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
- * A step-level `uses:` naming another repo: `owner/repo[/path]@ref`. Unlike a
144
- * reusable-workflow reference the path may be empty an action commonly
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
- // fall through to the next spelling
121
+ /* try the other spelling */
173
122
  }
174
123
  }
175
124
  return null;
176
125
  }
177
126
  /**
178
- * What `inputs.*` means inside a composite action: the caller's `with:`
179
- * values over the action's declared defaults, everything a string action
180
- * inputs are untyped, and an input nobody set is the empty string, not a
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 — that is the
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: checkout's postcondition, or a composite action, or a stop. */
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. But only the bare form: `ref:`, `path:`,
267
- // `repository:` each make it a different tree than the one provided.
268
- if (step.with != null && Object.keys(step.with).length > 0) {
269
- return err(`${label}: actions/checkout with inputs is not modelled`);
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
- return { ok: true, v: {} };
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
- // A JavaScript or Docker action is a program with its own runtime and its
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
- // The action's declared outputs are its whole surface: each `value:` is
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
- // The two the runner always provides and scripts assume. Everything else
358
- // a step sees, it declared.
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 (ctx.actionPath != null) {
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({ script, shell, cwd, env });
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 { grants, workspace, deps } = opts;
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
- // The shapes execution does not model, refused by name rather than run
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
- const tree = await deps.provideTree(workspace);
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
- // The job's `outputs:` map is the whole point of having run anything.
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: `bash --noprofile
456
- * --norc -e -o pipefail` and `sh -e`. Nothing of the parent environment
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 however many
481
- * steps ask. GitHub's tarballs wrap the tree in a single
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 { JobExecutor } from "../execute.js";
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[]>;