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 +109 -10
- package/dist/execute.d.ts +123 -0
- package/dist/execute.js +490 -0
- package/dist/expr.d.ts +51 -3
- package/dist/expr.js +85 -18
- package/dist/predict.d.ts +69 -8
- package/dist/predict.js +253 -54
- package/package.json +1 -1
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(
|
|
24
|
-
|
|
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)
|
|
54
|
-
as one `unknown` entry for that job and
|
|
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
|
|
82
|
-
|
|
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
|
|
86
|
-
|
|
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;
|