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 +86 -5
- 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 +23 -5
- package/dist/predict.js +153 -26
- package/package.json +1 -1
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)
|
|
58
|
-
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;
|
|
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
|
|
104
|
-
|
|
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;
|
package/dist/execute.js
ADDED
|
@@ -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
|
|
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.
|
|
66
|
-
*
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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. `
|
|
340
|
-
*
|
|
341
|
-
* simply not modelled — all
|
|
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
|
|
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.
|
|
369
|
-
*
|
|
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
|
|
373
|
-
const stripped =
|
|
435
|
+
export function evaluateValue(expr, scope = {}) {
|
|
436
|
+
const stripped = expr.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
374
437
|
if (stripped === "")
|
|
375
|
-
return
|
|
438
|
+
return UNKNOWN;
|
|
376
439
|
if (stripped.includes("${{"))
|
|
377
|
-
return
|
|
440
|
+
return UNKNOWN;
|
|
378
441
|
const toks = tokenize(stripped);
|
|
379
442
|
if (toks == null || toks.length === 0)
|
|
380
|
-
return
|
|
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
|
|
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
|
|
387
|
-
return
|
|
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
|
|
128
|
-
* reusable workflow's guards are all
|
|
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 {
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
166
|
+
const vals = axisValues(v, scope);
|
|
167
|
+
if (vals == null)
|
|
144
168
|
return null;
|
|
145
|
-
axes[k] =
|
|
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
|
|
290
|
-
* reusable workflow's guards are all
|
|
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,
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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) {
|