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/README.md CHANGED
@@ -54,9 +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) whose
58
- outputs were not supplied, reported as one `unknown` entry for that job and
59
- nothing else — see "Supplying job outputs" below;
57
+ - a matrix computed at runtime (`fromJSON` of another job's output) that
58
+ execution could not resolve, reported as one `unknown` entry for that job
59
+ and nothing else — see "Executing needed jobs" below;
60
60
  - a reusable workflow we cannot read — private, deleted, a ref that does not
61
61
  exist, a `uses:` built from an expression, or one nested past GitHub's
62
62
  four-level limit;
@@ -84,8 +84,7 @@ Auth is any token with `contents: read`, `actions: read`, and
84
84
 
85
85
  ```sh
86
86
  GH_TOKEN=... willfire --repo owner/repo --pr 123 \
87
- [--action opened|synchronize|reopened] [--json] \
88
- [--execute owner/repo:job1,job2]...
87
+ [--action opened|synchronize|reopened] [--json]
89
88
  ```
90
89
 
91
90
  Plain-text output is one line per entry, then a `# read owner/repo@ref -> sha`
@@ -102,8 +101,9 @@ workflows — both the local `./.github/workflows/x.yml` form and the cross-repo
102
101
  commit and the callee then read at that commit. Jobs whose `if` is false are
103
102
  predicted as `skipped` entries, matching how they appear in the checks UI.
104
103
 
105
- Things that cannot be known statically are reported as `unknown` rather than
106
- guessed.
104
+ A matrix computed by another job's outputs is not static; willfire resolves it
105
+ by running that job in a sandbox — see "Executing needed jobs". Everything
106
+ else that cannot be known is reported as `unknown` rather than guessed.
107
107
 
108
108
  ## Supplying job outputs
109
109
 
@@ -138,47 +138,46 @@ lookup against it then stays unknown.
138
138
  `needs` is workflow-scoped and is not inherited across a reusable-workflow
139
139
  call: a callee's `needs.detect` is the callee's own job.
140
140
 
141
- Nothing in willfire works out what those outputs are on its own. `predict`
142
- supplies none unless the caller grants executionsee below — so a dynamic
143
- matrix stays `unknown` by default.
141
+ Nothing here guesses what those outputs are. By default `predict` computes
142
+ them, by executing the jobs that produce them the section below. With
143
+ execution turned off (`executor: null` in `PredictOptions`) a dynamic matrix
144
+ stays `unknown`.
144
145
 
145
- ## Executing granted jobs
146
+ ## Executing needed jobs
146
147
 
147
148
  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 infor 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:
149
+ out tree — cheap to run for real. `predict` does that, and works out for
150
+ itself which jobs are worth it: a job is executed exactly when some sibling
151
+ job reads its `needs.<id>.outputs`. That fact is written in the workflow being
152
+ predicted, so there is nothing to configure and no repo knowledge in willfire.
153
+
154
+ Each selected job that is predicted to run is executed before expansion reads
155
+ `needs`: the PR's head tree is materialized from a tarball, the job's steps
156
+ run in order under their declared shell and env, step-level `if:` guards are
157
+ evaluated, composite actions are fetched at their pinned commit and recursed
158
+ into, and a bare `actions/checkout` is satisfied by the tree already present.
159
+ A checkout asking for history (`fetch-depth: 0`) switches the workspace to a
160
+ real clone at the same commit. What the steps write to `$GITHUB_OUTPUT`
161
+ becomes the job's outputs, exactly as if they had been supplied by hand.
162
+
163
+ The steps execute for real nothing interprets or approximates shell and
164
+ the code that runs is the PR's own version of itself. What makes that safe to
165
+ do by default is the sandbox: steps run in a docker container with no network,
166
+ none of the host's environment, no credentials, an unprivileged user, and a
167
+ read-only image (`node:24-slim` plus git and python3), with only the workspace tree
168
+ writable. Docker is the one runtime requirement; where it is missing,
169
+ execution fails and the entries that needed it stay `unknown` with the reason
170
+ attached.
171
+
172
+ Node actions (`runs.using: node24`) run under the sandbox's node with their
173
+ `inputs:` bound as `INPUT_*` variables; `actions/setup-node` is satisfied as a
174
+ no-op when it asks for exactly the node the sandbox has. Anything execution
175
+ cannot do faithfully fails rather than guessing: a Docker action, a node
176
+ action wanting another major, a checkout with inputs beyond `fetch-depth`, a
177
+ matrix'd or containerized job, an undecidable step `if:`, a non-zero exit,
178
+ output willfire cannot parse. The failure does not change any verdict —
179
+ entries that needed the outputs stay `unknown`, with the reason threaded
180
+ through:
182
181
 
183
182
  ```
184
183
  dynamic matrix; executing 'detect' failed: step 'scan': exited 1 (...)
@@ -1,9 +1,7 @@
1
- import { type ExecutionGrant } from "../execute.js";
2
1
  import type { PrEventAction } from "../types.js";
3
2
  export declare function parseArgs(argv: string[]): {
4
3
  repo: string;
5
4
  pr: number;
6
5
  json: boolean;
7
6
  action?: PrEventAction;
8
- execute: ExecutionGrant[];
9
7
  };
@@ -1,6 +1,4 @@
1
- import { parseGrant } from "../execute.js";
2
- const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened]" +
3
- " [--execute owner/repo:job1,job2]... [--json]";
1
+ const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened] [--json]";
4
2
  const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
5
3
  export function parseArgs(argv) {
6
4
  const get = (flag) => {
@@ -22,22 +20,5 @@ export function parseArgs(argv) {
22
20
  console.error(USAGE);
23
21
  process.exit(2);
24
22
  }
25
- // Repeatable, one grant per flag. A malformed grant is refused for the same
26
- // reason a bad --action is: silently dropping it would predict without the
27
- // execution the caller thought they asked for.
28
- const execute = [];
29
- for (let i = 0; i < argv.length; i++) {
30
- if (argv[i] !== "--execute") {
31
- continue;
32
- }
33
- const spec = argv[i + 1];
34
- const grant = spec == null ? null : parseGrant(spec);
35
- if (grant == null) {
36
- console.error(`bad --execute: ${spec}`);
37
- console.error(USAGE);
38
- process.exit(2);
39
- }
40
- execute.push(grant);
41
- }
42
- return { repo, pr: Number(pr), json: argv.includes("--json"), action, execute };
23
+ return { repo, pr: Number(pr), json: argv.includes("--json"), action };
43
24
  }
package/dist/cli.js CHANGED
@@ -13,7 +13,6 @@ if (isMain) {
13
13
  const args = parseArgs(process.argv.slice(2));
14
14
  const prediction = await predict(makeOctokit(), args.repo, args.pr, {
15
15
  action: args.action,
16
- execute: args.execute,
17
16
  });
18
17
  const { entries, skip, sources } = prediction;
19
18
  if (args.json) {
package/dist/execute.d.ts CHANGED
@@ -1,76 +1,46 @@
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 { type Scope } from "./expr/val.js";
33
9
  import type { ResolveRef, WorkflowSource } from "./types.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[];
10
+ /** A host path a sandboxed runner must expose inside, at the same path. */
11
+ export interface Mount {
12
+ path: string;
13
+ writable: boolean;
48
14
  }
49
- /** `owner/repo:job1,job2` as the CLI spells a grant. */
50
- export declare function parseGrant(spec: string): ExecutionGrant | null;
51
15
  /** One shell invocation, fully specified — nothing is inherited implicitly. */
52
16
  export interface RunSpec {
53
17
  script: string;
54
18
  shell: "bash" | "sh";
55
19
  cwd: string;
56
20
  env: Record<string, string>;
21
+ /** For runners that isolate: what of the host this run may see. A direct
22
+ * shell ignores this — it already sees everything. */
23
+ mounts?: Mount[];
57
24
  }
58
25
  export interface RunResult {
59
26
  code: number;
60
- /** Captured so a failing step can say *why* in its reason. */
61
27
  stderr: string;
62
28
  }
63
29
  export type RunCommand = (spec: RunSpec) => Promise<RunResult>;
64
30
  /**
65
- * Materialize a repo tree at a commit and return its root directory, or null
66
- * when it cannot be had. Must not throw.
31
+ * Materialize a repo tree at a commit, or null when it cannot be had. Must not
32
+ * throw. `history: true` demands full git history (the `fetch-depth: 0`
33
+ * postcondition); a provider that cannot supply it answers null.
67
34
  */
68
- export type ProvideTree = (source: WorkflowSource) => Promise<string | null>;
69
- /** The three reaches into the world an execution needs, bundled for injection. */
35
+ export type ProvideTree = (source: WorkflowSource, opts?: {
36
+ history?: boolean;
37
+ }) => Promise<string | null>;
70
38
  export interface ExecDeps {
71
39
  provideTree: ProvideTree;
72
40
  runCommand: RunCommand;
73
41
  resolveRef: ResolveRef;
42
+ /** The node major `runCommand`'s world provides; asking for another is refused. */
43
+ nodeMajor: number;
74
44
  }
75
45
  export type ExecOutcome = {
76
46
  ok: true;
@@ -80,44 +50,46 @@ export type ExecOutcome = {
80
50
  reason: string;
81
51
  };
82
52
  /**
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).
53
+ * The caller decides *whether* a job runs; the executor only decides what
54
+ * running it yields.
88
55
  */
89
56
  export interface JobExecutor {
90
- granted(source: WorkflowSource, jobId: string): boolean;
91
57
  executeJob(jobId: string, job: any, wf: any, scope: Scope): Promise<ExecOutcome>;
92
58
  }
93
59
  /**
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.
60
+ * Render every `${{ }}` to literal text, or null when any cannot be settled —
61
+ * a partial render would be a different program.
62
+ */
63
+ export declare function renderTemplate(text: string, scope: Scope): string | null;
64
+ /**
65
+ * `name=value` lines or `name<<DELIMITER` heredocs. Anything else fails the
66
+ * parse, as the runner fails the step on a malformed line.
98
67
  */
99
68
  export declare function parseGithubOutput(text: string): Record<string, string> | null;
100
69
  export declare function makeExecutor(opts: {
101
- grants: ExecutionGrant[];
102
70
  /**
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.
71
+ * The PR's own repo at the head commit. A reusable workflow's jobs run in
72
+ * the caller's workspace, wherever the workflow file lives.
106
73
  */
107
74
  workspace: WorkflowSource;
108
75
  deps: ExecDeps;
109
76
  }): JobExecutor;
110
77
  /**
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.
78
+ * The runner's default shell invocations, faithfully. Nothing of the parent
79
+ * environment leaks in beyond what the spec names.
114
80
  */
115
81
  export declare const runShell: RunCommand;
116
82
  /**
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.
83
+ * Materialize repo trees from tarballs, one download per commit. GitHub wraps
84
+ * the tree in a single `owner-repo-shortsha/` directory, unwrapped here.
122
85
  */
123
86
  export declare function makeTreeProvider(download: (source: WorkflowSource) => Promise<Uint8Array | null>, runCommand: RunCommand): ProvideTree;
87
+ /**
88
+ * Materialize repo trees by full clone, on the host — it needs the network
89
+ * the sandbox denies. The token never touches the URL or persisted git
90
+ * config, because `.git/config` later rides into the sandbox: auth travels
91
+ * as a per-invocation `http.extraheader` and is gone when the command is.
92
+ */
93
+ export declare function makeCloneProvider(runCommand: RunCommand, token: string | null, opts?: {
94
+ remoteUrl?: (source: WorkflowSource) => string;
95
+ }): ProvideTree;