willfire 0.1.10 → 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 CHANGED
@@ -54,8 +54,9 @@ workflows all applied. That is the unit required status checks key on, so it
54
54
  is the one worth comparing against. On a `JobEntry` it is `null` only where no
55
55
  single name is knowable ahead of the run:
56
56
 
57
- - a matrix computed at runtime (`fromJSON` of another job's output), reported
58
- as one `unknown` entry for that job and nothing else;
57
+ - a matrix computed at runtime (`fromJSON` of another job's output) whose
58
+ outputs were not supplied, reported as one `unknown` entry for that job and
59
+ nothing else — see "Supplying job outputs" below;
59
60
  - a reusable workflow we cannot read — private, deleted, a ref that does not
60
61
  exist, a `uses:` built from an expression, or one nested past GitHub's
61
62
  four-level limit;
@@ -83,7 +84,8 @@ Auth is any token with `contents: read`, `actions: read`, and
83
84
 
84
85
  ```sh
85
86
  GH_TOKEN=... willfire --repo owner/repo --pr 123 \
86
- [--action opened|synchronize|reopened] [--json]
87
+ [--action opened|synchronize|reopened] [--json] \
88
+ [--execute owner/repo:job1,job2]...
87
89
  ```
88
90
 
89
91
  Plain-text output is one line per entry, then a `# read owner/repo@ref -> sha`
@@ -100,8 +102,87 @@ workflows — both the local `./.github/workflows/x.yml` form and the cross-repo
100
102
  commit and the callee then read at that commit. Jobs whose `if` is false are
101
103
  predicted as `skipped` entries, matching how they appear in the checks UI.
102
104
 
103
- Things that cannot be known statically e.g. a matrix computed at runtime
104
- from another job's output — are reported as `unknown` rather than guessed.
105
+ Things that cannot be known statically are reported as `unknown` rather than
106
+ guessed.
107
+
108
+ ## Supplying job outputs
109
+
110
+ A matrix built from another job's output is the common way a workflow decides
111
+ its own check names:
112
+
113
+ ```yaml
114
+ strategy:
115
+ matrix:
116
+ language: ${{ fromJSON(needs.detect.outputs.coverage_languages) }}
117
+ ```
118
+
119
+ Given those outputs, willfire expands it. `expandWorkflowJobs` takes a `scope`
120
+ whose `needs` maps a job id to its outputs:
121
+
122
+ ```ts
123
+ await expandWorkflowJobs(wf, ctx, fetchWorkflow, source, {
124
+ needs: { detect: { outputs: { coverage_languages: '["typescript"]' } } },
125
+ });
126
+ ```
127
+
128
+ Values are raw strings — what a step wrote to `$GITHUB_OUTPUT`, and what the
129
+ runner substitutes. Parsing them here would break the guards written against
130
+ them: `!= '[]'` compares a string to a string, and an array on the left makes
131
+ it unknown. `fromJSON` is the only thing that turns one into a structure.
132
+
133
+ `outputs` must be the job's *complete* output set, because a key absent from it
134
+ reads as the empty string — the same answer the runner gives for an output no
135
+ step wrote. A job you know nothing about belongs left out entirely; every
136
+ lookup against it then stays unknown.
137
+
138
+ `needs` is workflow-scoped and is not inherited across a reusable-workflow
139
+ call: a callee's `needs.detect` is the callee's own job.
140
+
141
+ Nothing in willfire works out what those outputs are on its own. `predict`
142
+ supplies none unless the caller grants execution — see below — so a dynamic
143
+ matrix stays `unknown` by default.
144
+
145
+ ## Executing granted jobs
146
+
147
+ The job those outputs come from is usually a few shell steps over the checked
148
+ out tree — cheap to run for real. `predict` will do that, but only for jobs
149
+ the caller names:
150
+
151
+ ```ts
152
+ await predict(octokit, "owner/repo", 123, {
153
+ execute: [{ repo: "the-org/conventions", jobs: ["detect"] }],
154
+ });
155
+ ```
156
+
157
+ ```sh
158
+ willfire --repo owner/repo --pr 123 \
159
+ --execute the-org/conventions:detect
160
+ ```
161
+
162
+ A grant names the repo the workflow *file* lives in — for a reusable workflow,
163
+ the callee — and the job ids within it. Before expansion reads `needs`, each
164
+ granted job that is predicted to run is executed: the PR's head tree is
165
+ materialized from a tarball, the job's steps run in order under their declared
166
+ shell and env, step-level `if:` guards are evaluated, composite actions are
167
+ fetched at their pinned commit and recursed into, and a bare
168
+ `actions/checkout` is satisfied by the tree already present. What the steps
169
+ write to `$GITHUB_OUTPUT` becomes the job's outputs, exactly as if they had
170
+ been supplied by hand.
171
+
172
+ Execution is mechanism, not policy: willfire knows nothing about any repo, and
173
+ with no grants nothing runs. The steps execute for real — nothing interprets
174
+ or approximates shell — so grant only jobs whose code you trust at the commit
175
+ being predicted; a granted job runs the PR's version of itself.
176
+
177
+ Anything execution cannot do faithfully fails the grant rather than guessing:
178
+ a JavaScript or Docker action, a checkout with inputs, a matrix'd or
179
+ containerized granted job, an undecidable step `if:`, a non-zero exit, output
180
+ willfire cannot parse. The failure does not change any verdict — entries that
181
+ needed the outputs stay `unknown`, with the reason threaded through:
182
+
183
+ ```
184
+ dynamic matrix; executing 'detect' failed: step 'scan': exited 1 (...)
185
+ ```
105
186
 
106
187
  ## Development
107
188
 
@@ -0,0 +1,123 @@
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 { type Scope } from "./expr.js";
33
+ import type { ResolveRef, WorkflowSource } from "./predict.js";
34
+ /**
35
+ * Permission to execute named jobs from one repo's workflows.
36
+ *
37
+ * `repo` is the repo the *workflow file* lives in — for a fleet consumer
38
+ * calling `testing-conventions/.github/workflows/testing-conventions.yml@v0`,
39
+ * that is `thekevinscott/testing-conventions`, whatever repo the PR is on.
40
+ * The grant is deliberately this narrow: a job id alone would execute
41
+ * whatever any transitively-reached workflow happens to call by that name.
42
+ */
43
+ export interface ExecutionGrant {
44
+ /** `owner/name` of the repo whose workflow defines the jobs. */
45
+ repo: string;
46
+ /** Job ids within that repo's workflows that may be executed. */
47
+ jobs: string[];
48
+ }
49
+ /** `owner/repo:job1,job2` as the CLI spells a grant. */
50
+ export declare function parseGrant(spec: string): ExecutionGrant | null;
51
+ /** One shell invocation, fully specified — nothing is inherited implicitly. */
52
+ export interface RunSpec {
53
+ script: string;
54
+ shell: "bash" | "sh";
55
+ cwd: string;
56
+ env: Record<string, string>;
57
+ }
58
+ export interface RunResult {
59
+ code: number;
60
+ /** Captured so a failing step can say *why* in its reason. */
61
+ stderr: string;
62
+ }
63
+ export type RunCommand = (spec: RunSpec) => Promise<RunResult>;
64
+ /**
65
+ * Materialize a repo tree at a commit and return its root directory, or null
66
+ * when it cannot be had. Must not throw.
67
+ */
68
+ export type ProvideTree = (source: WorkflowSource) => Promise<string | null>;
69
+ /** The three reaches into the world an execution needs, bundled for injection. */
70
+ export interface ExecDeps {
71
+ provideTree: ProvideTree;
72
+ runCommand: RunCommand;
73
+ resolveRef: ResolveRef;
74
+ }
75
+ export type ExecOutcome = {
76
+ ok: true;
77
+ outputs: Record<string, string>;
78
+ } | {
79
+ ok: false;
80
+ reason: string;
81
+ };
82
+ /**
83
+ * What expansion asks of an executor. The caller decides *whether* a job
84
+ * runs with its own scope — the executor only decides what running it
85
+ * yields. Step-level guards inside the job are evaluated here, against the
86
+ * fixed facts of the run (notably `github.repository`, which the fleet's
87
+ * hermetic-vs-published guards are written against).
88
+ */
89
+ export interface JobExecutor {
90
+ granted(source: WorkflowSource, jobId: string): boolean;
91
+ executeJob(jobId: string, job: any, wf: any, scope: Scope): Promise<ExecOutcome>;
92
+ }
93
+ /**
94
+ * The `$GITHUB_OUTPUT` file format: `name=value` lines, or a
95
+ * `name<<DELIMITER … DELIMITER` heredoc for multi-line values. Anything else
96
+ * fails the parse — the runner fails the step on a malformed line, so
97
+ * tolerating one here would invent outputs a real run never had.
98
+ */
99
+ export declare function parseGithubOutput(text: string): Record<string, string> | null;
100
+ export declare function makeExecutor(opts: {
101
+ grants: ExecutionGrant[];
102
+ /**
103
+ * The PR's own repo at the head commit — what `actions/checkout` provides
104
+ * on a real runner, wherever the workflow file itself lives. A reusable
105
+ * workflow's jobs run in the caller's workspace; this is that fact.
106
+ */
107
+ workspace: WorkflowSource;
108
+ deps: ExecDeps;
109
+ }): JobExecutor;
110
+ /**
111
+ * The runner's default shell invocations, faithfully: `bash --noprofile
112
+ * --norc -e -o pipefail` and `sh -e`. Nothing of the parent environment
113
+ * leaks in beyond what the spec names.
114
+ */
115
+ export declare const runShell: RunCommand;
116
+ /**
117
+ * Materialize repo trees from tarballs, one download per commit however many
118
+ * steps ask. GitHub's tarballs wrap the tree in a single
119
+ * `owner-repo-shortsha/` directory, which is unwrapped so callers get the
120
+ * tree root itself. Extraction shells out to `tar` through the same
121
+ * `RunCommand` seam every other subprocess uses.
122
+ */
123
+ export declare function makeTreeProvider(download: (source: WorkflowSource) => Promise<Uint8Array | null>, runCommand: RunCommand): ProvideTree;
@@ -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;
package/dist/expr.js CHANGED
@@ -43,6 +43,10 @@ function truthy(val) {
43
43
  return false;
44
44
  case "unknown":
45
45
  return null;
46
+ // GitHub does cast an array or an object to a boolean, but no workflow
47
+ // asks it to, and the answer is not worth guessing at to find out.
48
+ case "json":
49
+ return null;
46
50
  case "value": {
47
51
  const v = val.v;
48
52
  if (typeof v === "boolean")
@@ -264,10 +268,36 @@ class Parser {
264
268
  const v = this.scope.github?.[rest];
265
269
  return v === undefined ? UNKNOWN : { kind: "value", v };
266
270
  }
267
- // `needs.*`, `steps.*`, `matrix.*`, `env.*`, `vars.*`, `secrets.*`: all
268
- // require something that has not happened yet at prediction time. This is
269
- // the seam where a `needs` context would attach if willfire ever computes
270
- // a called workflow's outputs ahead of the run.
271
+ if (head === "needs") {
272
+ // Only `needs.<job>.outputs.<name>` is modelled. `needs.<job>.result`
273
+ // is a verdict on a run that has not happened; anything else is not a
274
+ // shape the context has.
275
+ const parts = rest.split(".");
276
+ if (parts.length !== 3 || parts[1] !== "outputs")
277
+ return UNKNOWN;
278
+ const job = this.scope.needs?.[parts[0]];
279
+ if (job == null)
280
+ return UNKNOWN;
281
+ // A known job's missing output is the empty string, not a hole: the
282
+ // caller promised the set is complete, and that is what the runner
283
+ // substitutes for an output no step wrote.
284
+ return { kind: "value", v: job.outputs[parts[2]] ?? "" };
285
+ }
286
+ if (head === "steps") {
287
+ // The same shape as `needs`, for the same reason: only
288
+ // `steps.<id>.outputs.<name>` is modelled. `steps.<id>.outcome` and
289
+ // `.conclusion` are verdicts on how a step ran, which the executor does
290
+ // not track — a failed step fails the whole execution instead.
291
+ const parts = rest.split(".");
292
+ if (parts.length !== 3 || parts[1] !== "outputs")
293
+ return UNKNOWN;
294
+ const step = this.scope.steps?.[parts[0]];
295
+ if (step == null)
296
+ return UNKNOWN;
297
+ return { kind: "value", v: step.outputs[parts[2]] ?? "" };
298
+ }
299
+ // `matrix.*`, `env.*`, `vars.*`, `secrets.*`: all require something that
300
+ // has not happened yet at prediction time.
271
301
  return UNKNOWN;
272
302
  }
273
303
  }
@@ -332,17 +362,45 @@ function compare(op, left, right) {
332
362
  return asBool(a > b);
333
363
  return asBool(a >= b);
334
364
  }
365
+ /**
366
+ * `fromJSON(s)` on a known string.
367
+ *
368
+ * The result is sorted into the lattice rather than dropped in whole: a scalar
369
+ * is an ordinary value and stays comparable, `null` has a known truthiness and
370
+ * no useful value, and only an array or an object needs the `json` point.
371
+ * Anything unparseable is unknown — a workflow that reaches this at runtime
372
+ * fails, and predicting a failure is not this function's job.
373
+ */
374
+ function fromJson(arg) {
375
+ if (arg.kind !== "value" || typeof arg.v !== "string")
376
+ return UNKNOWN;
377
+ let parsed;
378
+ try {
379
+ parsed = JSON.parse(arg.v);
380
+ }
381
+ catch {
382
+ return UNKNOWN;
383
+ }
384
+ if (parsed === null)
385
+ return { kind: "falsy" };
386
+ if (typeof parsed === "object")
387
+ return { kind: "json", v: parsed };
388
+ return { kind: "value", v: parsed };
389
+ }
335
390
  /**
336
391
  * The functions worth modelling.
337
392
  *
338
393
  * `always()` is true by definition. `contains` on two known strings is the one
339
- * that unlocks the fleet's `gates` pattern. `success()`, `failure()` and
340
- * `cancelled()` depend on jobs that have not run, and everything else is
341
- * simply not modelled — all unknown.
394
+ * that unlocks the fleet's `gates` pattern. `fromJSON` is what a dynamic matrix
395
+ * axis is built out of. `success()`, `failure()` and `cancelled()` depend on
396
+ * jobs that have not run, and everything else is simply not modelled — all
397
+ * unknown.
342
398
  */
343
399
  function applyFunction(name, args) {
344
400
  if (name === "always")
345
401
  return { kind: "value", v: true };
402
+ if (name === "fromjson" && args.length === 1)
403
+ return fromJson(args[0]);
346
404
  if (name === "contains" && args.length === 2) {
347
405
  const [hay, needle] = args;
348
406
  if (hay.kind !== "value" || needle.kind !== "value")
@@ -363,26 +421,35 @@ function applyFunction(name, args) {
363
421
  }
364
422
  // ---------------------------------------------------------------------- entry
365
423
  /**
366
- * Evaluate a condition to a truthiness, or null when it cannot be settled.
424
+ * Evaluate an expression to a value, or UNKNOWN when it cannot be settled.
367
425
  *
368
- * The `${{ }}` wrapper is optional in `if:` and stripped when present. A
369
- * condition that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
426
+ * The `${{ }}` wrapper is optional in `if:` and stripped when present. An
427
+ * expression that is only *partly* wrapped (`foo ${{ bar }} baz`) is a string
370
428
  * interpolation rather than an expression, and is not modelled.
429
+ *
430
+ * A `if:` wants {@link evaluate}, which is this narrowed to truthiness. This
431
+ * one is for the places that need the value itself — a matrix axis written as
432
+ * `${{ fromJSON(...) }}` is an array, and its truthiness says nothing about
433
+ * how many jobs it schedules.
371
434
  */
372
- export function evaluate(cond, scope = {}) {
373
- const stripped = cond.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
435
+ export function evaluateValue(expr, scope = {}) {
436
+ const stripped = expr.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
374
437
  if (stripped === "")
375
- return null;
438
+ return UNKNOWN;
376
439
  if (stripped.includes("${{"))
377
- return null;
440
+ return UNKNOWN;
378
441
  const toks = tokenize(stripped);
379
442
  if (toks == null || toks.length === 0)
380
- return null;
443
+ return UNKNOWN;
381
444
  const p = new Parser(toks, scope);
382
445
  const val = p.or();
383
- // Trailing tokens mean the grammar did not cover this condition; whatever
446
+ // Trailing tokens mean the grammar did not cover this expression; whatever
384
447
  // was parsed describes only a prefix of it, so it decides nothing.
385
448
  if (!p.done())
386
- return null;
387
- return truthy(val);
449
+ return UNKNOWN;
450
+ return val;
451
+ }
452
+ /** Evaluate a condition to a truthiness, or null when it cannot be settled. */
453
+ export function evaluate(cond, scope = {}) {
454
+ return truthy(evaluateValue(cond, scope));
388
455
  }
package/dist/predict.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Octokit } from "@octokit/rest";
3
+ import { type ExecutionGrant, type JobExecutor } from "./execute.js";
3
4
  import { type Scope } from "./expr.js";
4
5
  interface EntryBase {
5
6
  workflow: string;
@@ -111,6 +112,17 @@ export interface PredictOptions {
111
112
  * to a workflow narrowing `types:`, where it matters completely.
112
113
  */
113
114
  action?: PrEventAction;
115
+ /**
116
+ * Jobs willfire may *execute* to resolve what reading cannot — the fleet's
117
+ * `detect` job, whose outputs feed every dynamic matrix downstream of it.
118
+ *
119
+ * Off by default, and mechanism only: willfire has no opinion about which
120
+ * jobs are safe to run. The caller that knows names them, one repo and job
121
+ * id at a time (see {@link ExecutionGrant}), and an execution that fails
122
+ * for any reason leaves the dependent entries exactly as unresolved as
123
+ * they were — with the failure spelled into their reasons.
124
+ */
125
+ execute?: ExecutionGrant[];
114
126
  }
115
127
  export interface Ctx {
116
128
  action: string;
@@ -120,13 +132,14 @@ export interface Ctx {
120
132
  type Workflow = Record<string, any>;
121
133
  type Combo = Record<string, any> | null;
122
134
  /** Return list of matrix combination dicts, or null if dynamic. */
123
- export declare function expandMatrix(strategy: any): Combo[] | null;
135
+ export declare function expandMatrix(strategy: any, scope?: Scope): Combo[] | null;
124
136
  /**
125
137
  * Return run|skipped|unknown for a job-level `if:`.
126
138
  *
127
- * `scope` carries the inputs the calling workflow passed down. Without it a
128
- * reusable workflow's guards are all unknown, because every one of them is
129
- * written against `inputs.*`.
139
+ * `scope` carries the inputs the calling workflow passed down, and any job
140
+ * outputs the caller knows. Without it a reusable workflow's guards are all
141
+ * unknown, because every one of them is written against `inputs.*` or
142
+ * `needs.*`.
130
143
  */
131
144
  export declare function evalIf(cond: any, scope?: Scope): "run" | "skipped" | "unknown";
132
145
  /**
@@ -217,8 +230,13 @@ export declare function parseUses(uses: string): UsesTarget | null;
217
230
  * Expand one already-parsed workflow into its job entries. Exported so check
218
231
  * names can be tested against recorded GitHub behaviour without a network
219
232
  * round-trip; `predict` is the API you want.
233
+ *
234
+ * `scope` seeds what this workflow's own `${{ }}` resolve against — notably
235
+ * `needs`, the outputs of jobs that have not run. Nothing here works out what
236
+ * those are; a caller that knows hands them in, and a caller that does not
237
+ * leaves them out and gets `unknown` where they would have been used.
220
238
  */
221
- export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource): Promise<ExpandedJob[]>;
239
+ export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource, scope?: Scope, executor?: JobExecutor): Promise<ExpandedJob[]>;
222
240
  export declare function makeOctokit(): Octokit;
223
241
  export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
224
242
  export {};
package/dist/predict.js CHANGED
@@ -12,7 +12,8 @@
12
12
  // src/names.test.ts.
13
13
  import { Octokit } from "@octokit/rest";
14
14
  import { parse as parseYaml } from "yaml";
15
- import { evaluate, UNKNOWN } from "./expr.js";
15
+ import { makeExecutor, makeTreeProvider, parseGrant, runShell, } from "./execute.js";
16
+ import { evaluate, evaluateValue, UNKNOWN } from "./expr.js";
16
17
  /** Tag a job display name. Rejects the workflow-level sentinel. */
17
18
  export const jobName = (name) => name;
18
19
  /** Narrow to the workflow-level variant without inspecting the sentinel. */
@@ -126,12 +127,34 @@ function workflowDispatches(wf, ctx) {
126
127
  }
127
128
  return [true, "trigger matched"];
128
129
  }
129
- function expandMatrixDetailed(strategy) {
130
+ /**
131
+ * The values of one matrix axis, or null when they cannot be known.
132
+ *
133
+ * A plain list is itself. An axis written as an expression —
134
+ * `language: ${{ fromJSON(needs.detect.outputs.coverage_languages) }}` — is
135
+ * the values another job computed, and is knowable exactly when the scope
136
+ * carries that job's outputs. Anything else stays null, which is what makes
137
+ * the whole job `unknown` rather than a guess at how many checks it creates.
138
+ */
139
+ function axisValues(v, scope) {
140
+ if (Array.isArray(v))
141
+ return v;
142
+ if (typeof v !== "string")
143
+ return null;
144
+ const val = evaluateValue(v, scope);
145
+ if (val.kind !== "json" || !Array.isArray(val.v))
146
+ return null;
147
+ return val.v;
148
+ }
149
+ function expandMatrixDetailed(strategy, scope = {}) {
130
150
  const matrix = strategy?.matrix;
131
151
  if (matrix == null)
132
152
  return [null];
153
+ // `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
154
+ // per-axis form below. It yields include-style entries, not axes, so it is a
155
+ // separate expansion and is not modelled.
133
156
  if (typeof matrix === "string")
134
- return null; // ${{ fromJSON(...) }}
157
+ return null;
135
158
  const include = matrix.include ?? [];
136
159
  const exclude = matrix.exclude ?? [];
137
160
  if (typeof include === "string" || typeof exclude === "string")
@@ -140,9 +163,10 @@ function expandMatrixDetailed(strategy) {
140
163
  for (const [k, v] of Object.entries(matrix)) {
141
164
  if (k === "include" || k === "exclude")
142
165
  continue;
143
- if (!Array.isArray(v))
166
+ const vals = axisValues(v, scope);
167
+ if (vals == null)
144
168
  return null;
145
- axes[k] = v;
169
+ axes[k] = vals;
146
170
  }
147
171
  const axisKeys = Object.keys(axes);
148
172
  let combos = [{ values: {}, displayKeys: axisKeys }];
@@ -177,8 +201,8 @@ function expandMatrixDetailed(strategy) {
177
201
  return combos;
178
202
  }
179
203
  /** Return list of matrix combination dicts, or null if dynamic. */
180
- export function expandMatrix(strategy) {
181
- const detailed = expandMatrixDetailed(strategy);
204
+ export function expandMatrix(strategy, scope = {}) {
205
+ const detailed = expandMatrixDetailed(strategy, scope);
182
206
  return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
183
207
  }
184
208
  /**
@@ -283,20 +307,30 @@ function skippedDisplayName(jobId, job) {
283
307
  * resolve instead of hanging the job on an unknown.
284
308
  */
285
309
  const PR_GITHUB_CONTEXT = { event_name: "pull_request" };
310
+ /**
311
+ * A scope with the fixed pull-request facts filled in.
312
+ *
313
+ * Every `${{ }}` this module evaluates — a job `if:`, a matrix axis — is
314
+ * evaluated for the same event, so they all get the same `github.*`. Anything
315
+ * the caller states wins; there is nothing here worth overriding, but a scope
316
+ * that silently ignored what it was handed would be the wrong shape.
317
+ */
318
+ const prScope = (scope) => ({
319
+ ...scope,
320
+ github: { ...PR_GITHUB_CONTEXT, ...scope.github },
321
+ });
286
322
  /**
287
323
  * Return run|skipped|unknown for a job-level `if:`.
288
324
  *
289
- * `scope` carries the inputs the calling workflow passed down. Without it a
290
- * reusable workflow's guards are all unknown, because every one of them is
291
- * written against `inputs.*`.
325
+ * `scope` carries the inputs the calling workflow passed down, and any job
326
+ * outputs the caller knows. Without it a reusable workflow's guards are all
327
+ * unknown, because every one of them is written against `inputs.*` or
328
+ * `needs.*`.
292
329
  */
293
330
  export function evalIf(cond, scope = {}) {
294
331
  if (cond == null)
295
332
  return "run";
296
- const verdict = evaluate(String(cond), {
297
- inputs: scope.inputs,
298
- github: { ...PR_GITHUB_CONTEXT, ...scope.github },
299
- });
333
+ const verdict = evaluate(String(cond), prScope(scope));
300
334
  if (verdict === null)
301
335
  return "unknown";
302
336
  return verdict ? "run" : "skipped";
@@ -400,13 +434,43 @@ export function parseUses(uses) {
400
434
  return null;
401
435
  return { path, source: { owner, repo, ref } };
402
436
  }
403
- async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
437
+ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}, executor) {
404
438
  const entries = [];
405
439
  const jobs = wf.jobs ?? {};
406
440
  const statuses = {};
441
+ // Execute what the caller granted, before anything reads `needs`. The
442
+ // grant names the repo the workflow *file* lives in, so a granted callee
443
+ // job fires here in the recursion, where `source` is that repo. The guard
444
+ // is the same `evalIf` the main loop applies — same scope, same verdict —
445
+ // so a job is executed exactly when it is predicted to run. A job that
446
+ // would not run is not executed; a job that fails to execute contributes
447
+ // nothing but its reason, which `execNote` threads into the entries that
448
+ // needed it.
449
+ let scoped = scope;
450
+ const execFailures = {};
451
+ if (executor != null) {
452
+ for (const [jobId, jobRaw] of Object.entries(jobs)) {
453
+ if (!executor.granted(source, jobId))
454
+ continue;
455
+ const job = jobRaw ?? {};
456
+ if (evalIf(job.if, scoped) !== "run")
457
+ continue;
458
+ const res = await executor.executeJob(jobId, job, wf, scoped);
459
+ if (res.ok) {
460
+ scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
461
+ }
462
+ else {
463
+ execFailures[jobId] = res.reason;
464
+ }
465
+ }
466
+ }
467
+ const execNote = (needs) => {
468
+ const failed = needs.find((n) => n in execFailures);
469
+ return failed == null ? "" : `; executing '${failed}' failed: ${execFailures[failed]}`;
470
+ };
407
471
  for (const [jobId, jobRaw] of Object.entries(jobs)) {
408
472
  const job = jobRaw ?? {};
409
- let status = evalIf(job.if, scope);
473
+ let status = evalIf(job.if, scoped);
410
474
  let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
411
475
  let needs = job.needs ?? [];
412
476
  if (typeof needs === "string")
@@ -438,7 +502,7 @@ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefi
438
502
  });
439
503
  continue;
440
504
  }
441
- const combos = expandMatrixDetailed(job.strategy);
505
+ const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
442
506
  if ("uses" in job) {
443
507
  // Reusable workflow call. The calling job produces no check of its own;
444
508
  // each called job becomes `<calling job name> / <called job name>`, and
@@ -451,7 +515,7 @@ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefi
451
515
  job: prefix + jobId,
452
516
  checkName: null,
453
517
  status: "unknown",
454
- reason: "dynamic matrix on reusable workflow call",
518
+ reason: "dynamic matrix on reusable workflow call" + execNote(needs),
455
519
  });
456
520
  continue;
457
521
  }
@@ -494,7 +558,10 @@ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefi
494
558
  else {
495
559
  try {
496
560
  subWf = parseYaml(content);
497
- subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
561
+ // `inputs.*` changes at the call boundary; `github.*` does not.
562
+ // A callee's jobs run in the caller's repo, so the facts seeded
563
+ // at the top of the prediction stay true all the way down.
564
+ subScope = { inputs: calleeInputs(job.with, subWf ?? {}), github: scoped.github };
498
565
  }
499
566
  catch (e) {
500
567
  failure = `YAML parse error in ${uses}: ${e}`;
@@ -515,7 +582,7 @@ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefi
515
582
  });
516
583
  continue;
517
584
  }
518
- entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
585
+ entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope, executor)));
519
586
  }
520
587
  continue;
521
588
  }
@@ -524,7 +591,7 @@ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefi
524
591
  job: prefix + jobId,
525
592
  checkName: null,
526
593
  status: "unknown",
527
- reason: "dynamic matrix",
594
+ reason: "dynamic matrix" + execNote(needs),
528
595
  });
529
596
  continue;
530
597
  }
@@ -545,9 +612,14 @@ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefi
545
612
  * Expand one already-parsed workflow into its job entries. Exported so check
546
613
  * names can be tested against recorded GitHub behaviour without a network
547
614
  * round-trip; `predict` is the API you want.
615
+ *
616
+ * `scope` seeds what this workflow's own `${{ }}` resolve against — notably
617
+ * `needs`, the outputs of jobs that have not run. Nothing here works out what
618
+ * those are; a caller that knows hands them in, and a caller that does not
619
+ * leaves them out and gets `unknown` where they would have been used.
548
620
  */
549
- export function expandWorkflowJobs(wf, ctx, reader, source) {
550
- return expandJobs(wf, ctx, reader, source);
621
+ export function expandWorkflowJobs(wf, ctx, reader, source, scope = {}, executor) {
622
+ return expandJobs(wf, ctx, reader, source, 0, "", true, scope, executor);
551
623
  }
552
624
  // ------------------------------------------------------------------- pipeline
553
625
  export function makeOctokit() {
@@ -650,10 +722,47 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
650
722
  return content;
651
723
  };
652
724
  const reader = { fetchWorkflow, resolveRef };
725
+ // The executor exists only when the caller granted something. Trees come
726
+ // from the tarball endpoint at the resolved commit, and every subprocess —
727
+ // `tar` included — goes through the one `runShell` seam.
728
+ let executor;
729
+ if (opts.execute != null && opts.execute.length > 0) {
730
+ const download = async (src) => {
731
+ try {
732
+ const { data } = await octokit.rest.repos.downloadTarballArchive({
733
+ owner: src.owner,
734
+ repo: src.repo,
735
+ ref: src.sha,
736
+ });
737
+ return new Uint8Array(data);
738
+ }
739
+ catch {
740
+ // Private, deleted, rate limit, network: one answer, and the entries
741
+ // behind it stay unresolved with the failure named.
742
+ return null;
743
+ }
744
+ };
745
+ executor = makeExecutor({
746
+ grants: opts.execute,
747
+ workspace: headSource,
748
+ deps: {
749
+ provideTree: makeTreeProvider(download, runShell),
750
+ runCommand: runShell,
751
+ resolveRef,
752
+ },
753
+ });
754
+ }
653
755
  const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
654
756
  ...base,
655
757
  per_page: 100,
656
758
  });
759
+ // `github.repository` is fixed for everything predicted here: reusable
760
+ // workflows and composite actions all run in the repo the PR is against.
761
+ // Seeding it once makes guards like the fleet's hermetic-vs-published
762
+ // `github.repository ==` checks decidable everywhere, granted or not.
763
+ const prFacts = {
764
+ github: { repository: `${headSource.owner}/${headSource.repo}` },
765
+ };
657
766
  const entries = [];
658
767
  for (const w of workflows) {
659
768
  const path = w.path;
@@ -702,7 +811,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
702
811
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
703
812
  continue;
704
813
  }
705
- for (const j of await expandJobs(wf, ctx, reader, headSource)) {
814
+ for (const j of await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor)) {
706
815
  entries.push({
707
816
  workflow: path,
708
817
  job: jobName(j.job),
@@ -729,7 +838,8 @@ function finalizePrediction(entries, skip, sources) {
729
838
  };
730
839
  }
731
840
  // ------------------------------------------------------------------------ CLI
732
- const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened] [--json]";
841
+ const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened]" +
842
+ " [--execute owner/repo:job1,job2]... [--json]";
733
843
  const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
734
844
  function parseArgs(argv) {
735
845
  const get = (flag) => {
@@ -751,13 +861,30 @@ function parseArgs(argv) {
751
861
  console.error(USAGE);
752
862
  process.exit(2);
753
863
  }
754
- return { repo, pr: Number(pr), json: argv.includes("--json"), action };
864
+ // Repeatable, one grant per flag. A malformed grant is refused for the same
865
+ // reason a bad --action is: silently dropping it would predict without the
866
+ // execution the caller thought they asked for.
867
+ const execute = [];
868
+ for (let i = 0; i < argv.length; i++) {
869
+ if (argv[i] !== "--execute")
870
+ continue;
871
+ const spec = argv[i + 1];
872
+ const grant = spec == null ? null : parseGrant(spec);
873
+ if (grant == null) {
874
+ console.error(`bad --execute: ${spec}`);
875
+ console.error(USAGE);
876
+ process.exit(2);
877
+ }
878
+ execute.push(grant);
879
+ }
880
+ return { repo, pr: Number(pr), json: argv.includes("--json"), action, execute };
755
881
  }
756
882
  const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
757
883
  if (isMain) {
758
884
  const args = parseArgs(process.argv.slice(2));
759
885
  const prediction = await predict(makeOctokit(), args.repo, args.pr, {
760
886
  action: args.action,
887
+ execute: args.execute,
761
888
  });
762
889
  const { entries, skip, sources } = prediction;
763
890
  if (args.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@10.33.0",