willfire 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,10 +20,14 @@ pnpm add willfire
20
20
  import { predict } from "willfire";
21
21
  import { getOctokit } from "@actions/github"; // or new Octokit({ auth: token })
22
22
 
23
- const { entries, checkNames, skip } = await predict(getOctokit(token), "owner/repo", 123, {
24
- action: context.payload.action, // "opened" | "synchronize" | "reopened"
25
- });
23
+ const { entries, checkNames, skip, sources } = await predict(
24
+ getOctokit(token),
25
+ "owner/repo",
26
+ 123,
27
+ { action: context.payload.action }, // "opened" | "synchronize" | "reopened"
28
+ );
26
29
  // checkNames: sorted, deduped checkName of every entry with status "run"
30
+ // sources: every repo read, and the commit each ref resolved to
27
31
  ```
28
32
 
29
33
  `action` is optional but worth passing. Omitted, the event action is inferred
@@ -50,13 +54,25 @@ workflows all applied. That is the unit required status checks key on, so it
50
54
  is the one worth comparing against. On a `JobEntry` it is `null` only where no
51
55
  single name is knowable ahead of the run:
52
56
 
53
- - a matrix computed at runtime (`fromJSON` of another job's output), reported
54
- 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;
55
60
  - a reusable workflow we cannot read — private, deleted, a ref that does not
56
61
  exist, a `uses:` built from an expression, or one nested past GitHub's
57
62
  four-level limit;
58
63
  - a `name:` interpolating something we cannot evaluate statically.
59
64
 
65
+ `sources` is the provenance of the answer: the PR's own repo at the head
66
+ commit, then one entry per cross-repo `uses:` that was read, each carrying both
67
+ the `ref` the workflow wrote and the `sha` it resolved to. A ref is resolved
68
+ before the file behind it is read, so the commit named is the commit used. `v0`
69
+ is a tag someone moves; without the sha, "willfire predicted these checks" is
70
+ not a claim that can be checked against the run afterwards.
71
+
72
+ A ref that will not resolve is not a source. Its callee is never read, the jobs
73
+ behind it come back `unknown`, and nothing falls back to reading the mutable
74
+ ref.
75
+
60
76
  Duplicate names in `checkNames` are not possible (it is a set), but duplicate
61
77
  check names *are* — GitHub happily creates two identically named checks when a
62
78
  matrix job's `name:` does not vary per combination. `entries` shows them.
@@ -68,9 +84,13 @@ Auth is any token with `contents: read`, `actions: read`, and
68
84
 
69
85
  ```sh
70
86
  GH_TOKEN=... willfire --repo owner/repo --pr 123 \
71
- [--action opened|synchronize|reopened] [--json]
87
+ [--action opened|synchronize|reopened] [--json] \
88
+ [--execute owner/repo:job1,job2]...
72
89
  ```
73
90
 
91
+ Plain-text output is one line per entry, then a `# read owner/repo@ref -> sha`
92
+ line per source. `--json` prints the whole `Prediction`, `sources` included.
93
+
74
94
  ## What it handles
75
95
 
76
96
  Path filters (`paths`, `paths-ignore`, order-sensitive `!` negation), branch
@@ -78,12 +98,91 @@ filters, event `types`, combined filters, `[skip ci]` and friends, disabled
78
98
  workflows, multi-job workflows, static matrix expansion (including
79
99
  `exclude`/`include`), `needs` skip-propagation, job-level `if`, and reusable
80
100
  workflows — both the local `./.github/workflows/x.yml` form and the cross-repo
81
- `owner/repo/.github/workflows/x.yml@ref` form, whose callee is fetched from its
82
- own repo at the pinned tag, branch, or SHA. Jobs whose `if` is false are
101
+ `owner/repo/.github/workflows/x.yml@ref` form, whose ref is resolved to a
102
+ commit and the callee then read at that commit. Jobs whose `if` is false are
83
103
  predicted as `skipped` entries, matching how they appear in the checks UI.
84
104
 
85
- Things that cannot be known statically e.g. a matrix computed at runtime
86
- 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
+ ```
87
186
 
88
187
  ## Development
89
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;