willfire 0.1.0
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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/predict.d.ts +23 -0
- package/dist/predict.js +375 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +91 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Scott
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# willfire
|
|
2
|
+
|
|
3
|
+
Predicts the set of CI check entries GitHub Actions will create for a pull
|
|
4
|
+
request — before (or without) the runs happening.
|
|
5
|
+
|
|
6
|
+
GitHub's dispatch decision is server-side and unpublished: no API tells you
|
|
7
|
+
which workflows will fire for a PR after branch/path/type filters, or which
|
|
8
|
+
job entries they expand into. willfire evaluates the workflow files statically
|
|
9
|
+
against the PR's base branch, changed files, and head commit message.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add willfire
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Library
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { predict } from "willfire";
|
|
21
|
+
import { getOctokit } from "@actions/github"; // or new Octokit({ auth: token })
|
|
22
|
+
|
|
23
|
+
const { entries, skip } = await predict(getOctokit(token), "owner/repo", 123);
|
|
24
|
+
// entries: [{ workflow, job, status: "run" | "skipped" | "unknown" | "no-dispatch", reason }]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Auth is any token with `contents: read`, `actions: read`, and
|
|
28
|
+
`pull-requests: read` — inside an action, the workflow's `GITHUB_TOKEN`.
|
|
29
|
+
|
|
30
|
+
## CLI
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
GH_TOKEN=... willfire --repo owner/repo --pr 123 [--json]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What it handles
|
|
37
|
+
|
|
38
|
+
Path filters (`paths`, `paths-ignore`, order-sensitive `!` negation), branch
|
|
39
|
+
filters, event `types`, combined filters, `[skip ci]` and friends, disabled
|
|
40
|
+
workflows, multi-job workflows, static matrix expansion (including
|
|
41
|
+
`exclude`/`include`), `needs` skip-propagation, job-level `if`, and local
|
|
42
|
+
reusable workflows. Jobs whose `if` is false are predicted as `skipped`
|
|
43
|
+
entries, matching how they appear in the checks UI.
|
|
44
|
+
|
|
45
|
+
Things that cannot be known statically — e.g. a matrix computed at runtime
|
|
46
|
+
from another job's output — are reported as `unknown` rather than guessed.
|
|
47
|
+
|
|
48
|
+
## Verification
|
|
49
|
+
|
|
50
|
+
Predictions are verified against real GitHub behavior, not just the docs:
|
|
51
|
+
[willrun-probe](https://github.com/thekevinbot/willrun-probe) holds one
|
|
52
|
+
workflow per dispatch rule, and probe PRs exercise each complication
|
|
53
|
+
(docs-only diffs, negation edges, `[skip ci]`, a 301-file diff, PRs into
|
|
54
|
+
non-default branches). The `verify` script diffs predictions against the
|
|
55
|
+
check entries GitHub actually created. All probe PRs currently pass exactly.
|
|
56
|
+
|
|
57
|
+
Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
|
|
58
|
+
live events, cross-repo reusable workflows, `branches-ignore`, and diffs far
|
|
59
|
+
beyond 301 files are not yet probe-verified.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Octokit } from "@octokit/rest";
|
|
3
|
+
export interface Entry {
|
|
4
|
+
workflow: string;
|
|
5
|
+
job: string;
|
|
6
|
+
status: "run" | "skipped" | "unknown" | "no-dispatch";
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Prediction {
|
|
10
|
+
entries: Entry[];
|
|
11
|
+
skip: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function patternToRegex(pat: string): RegExp;
|
|
14
|
+
/** Order-sensitive match: last matching pattern wins; ! negates. */
|
|
15
|
+
export declare function matchFilters(value: string, patterns: string[]): boolean;
|
|
16
|
+
type Combo = Record<string, any> | null;
|
|
17
|
+
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
18
|
+
export declare function expandMatrix(strategy: any): Combo[] | null;
|
|
19
|
+
/** Return run|skipped|unknown for a job-level if. */
|
|
20
|
+
export declare function evalIf(cond: any): "run" | "skipped" | "unknown";
|
|
21
|
+
export declare function makeOctokit(): Octokit;
|
|
22
|
+
export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
|
|
23
|
+
export {};
|
package/dist/predict.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Predict the set of CI check entries GitHub Actions will create for a PR.
|
|
3
|
+
//
|
|
4
|
+
// Usage: pnpm predict --repo owner/name --pr N [--json]
|
|
5
|
+
// Auth: GH_TOKEN or GITHUB_TOKEN env var (any token with contents/actions/
|
|
6
|
+
// pull-requests read). Inside an action, pass the workflow's GITHUB_TOKEN.
|
|
7
|
+
//
|
|
8
|
+
// Faithful port of predict.py, which was verified entry-for-entry against
|
|
9
|
+
// live dispatches on thekevinbot/willrun-probe (PRs 1-7).
|
|
10
|
+
import { Octokit } from "@octokit/rest";
|
|
11
|
+
import { parse as parseYaml } from "yaml";
|
|
12
|
+
// ------------------------------------------------- GitHub filter pattern glob
|
|
13
|
+
// Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
|
|
14
|
+
// preceding char), + (one or more of preceding char), [ranges], leading ! negates.
|
|
15
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
export function patternToRegex(pat) {
|
|
17
|
+
let out = "";
|
|
18
|
+
for (let i = 0; i < pat.length; i++) {
|
|
19
|
+
const c = pat[i];
|
|
20
|
+
if (c === "*") {
|
|
21
|
+
if (pat[i + 1] === "*") {
|
|
22
|
+
out += ".*";
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
out += "[^/]*";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (c === "?" || c === "+") {
|
|
30
|
+
out += c;
|
|
31
|
+
}
|
|
32
|
+
else if (c === "[") {
|
|
33
|
+
const j = pat.indexOf("]", i + 1);
|
|
34
|
+
out += pat.slice(i, j + 1);
|
|
35
|
+
i = j;
|
|
36
|
+
}
|
|
37
|
+
else if (c === "\\") {
|
|
38
|
+
i++;
|
|
39
|
+
out += escapeRegex(pat[i]);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
out += escapeRegex(c);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new RegExp(`^${out}$`);
|
|
46
|
+
}
|
|
47
|
+
/** Order-sensitive match: last matching pattern wins; ! negates. */
|
|
48
|
+
export function matchFilters(value, patterns) {
|
|
49
|
+
let matched = false;
|
|
50
|
+
for (const pat of patterns) {
|
|
51
|
+
const neg = pat.startsWith("!");
|
|
52
|
+
const p = neg ? pat.slice(1) : pat;
|
|
53
|
+
if (patternToRegex(p).test(value))
|
|
54
|
+
matched = !neg;
|
|
55
|
+
}
|
|
56
|
+
return matched;
|
|
57
|
+
}
|
|
58
|
+
// ------------------------------------------------------------ trigger checks
|
|
59
|
+
const SKIP_RE = /\[(skip ci|ci skip|no ci|skip actions|actions skip)\]/i;
|
|
60
|
+
const SKIP_TRAILER_RE = /^skip-checks:\s*true/im;
|
|
61
|
+
const DEFAULT_TYPES = ["opened", "synchronize", "reopened"];
|
|
62
|
+
const MISSING = Symbol("missing");
|
|
63
|
+
function getPrTrigger(wf) {
|
|
64
|
+
// YAML 1.1 parsers read `on` as boolean true; the `yaml` package (1.2)
|
|
65
|
+
// keeps it a string key. Handle both.
|
|
66
|
+
const on = wf["on"] ?? wf["true"];
|
|
67
|
+
if (on == null)
|
|
68
|
+
return MISSING;
|
|
69
|
+
if (typeof on === "string")
|
|
70
|
+
return on === "pull_request" ? {} : MISSING;
|
|
71
|
+
if (Array.isArray(on))
|
|
72
|
+
return on.includes("pull_request") ? {} : MISSING;
|
|
73
|
+
if (typeof on === "object") {
|
|
74
|
+
if ("pull_request" in on)
|
|
75
|
+
return on["pull_request"] ?? {};
|
|
76
|
+
return MISSING;
|
|
77
|
+
}
|
|
78
|
+
return MISSING;
|
|
79
|
+
}
|
|
80
|
+
function workflowDispatches(wf, ctx) {
|
|
81
|
+
const trig = getPrTrigger(wf);
|
|
82
|
+
if (trig === MISSING)
|
|
83
|
+
return ["no-dispatch", "no pull_request trigger"];
|
|
84
|
+
const types = trig["types"] ?? DEFAULT_TYPES;
|
|
85
|
+
if (!types.includes(ctx.action)) {
|
|
86
|
+
return ["no-dispatch", `action '${ctx.action}' not in types [${types}]`];
|
|
87
|
+
}
|
|
88
|
+
if ("branches" in trig && "branches-ignore" in trig) {
|
|
89
|
+
return ["unknown", "both branches and branches-ignore set"];
|
|
90
|
+
}
|
|
91
|
+
if ("branches" in trig && !matchFilters(ctx.baseRef, trig["branches"])) {
|
|
92
|
+
return ["no-dispatch", `base branch '${ctx.baseRef}' not in branches`];
|
|
93
|
+
}
|
|
94
|
+
if ("branches-ignore" in trig && matchFilters(ctx.baseRef, trig["branches-ignore"])) {
|
|
95
|
+
return ["no-dispatch", "base branch in branches-ignore"];
|
|
96
|
+
}
|
|
97
|
+
if ("paths" in trig && "paths-ignore" in trig) {
|
|
98
|
+
return ["unknown", "both paths and paths-ignore set"];
|
|
99
|
+
}
|
|
100
|
+
if ("paths" in trig && !ctx.files.some((f) => matchFilters(f, trig["paths"]))) {
|
|
101
|
+
return ["no-dispatch", "no changed file matches paths"];
|
|
102
|
+
}
|
|
103
|
+
if ("paths-ignore" in trig && ctx.files.every((f) => matchFilters(f, trig["paths-ignore"]))) {
|
|
104
|
+
return ["no-dispatch", "all changed files match paths-ignore"];
|
|
105
|
+
}
|
|
106
|
+
return ["dispatch", "trigger matched"];
|
|
107
|
+
}
|
|
108
|
+
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
109
|
+
export function expandMatrix(strategy) {
|
|
110
|
+
const matrix = strategy?.matrix;
|
|
111
|
+
if (matrix == null)
|
|
112
|
+
return [null];
|
|
113
|
+
if (typeof matrix === "string")
|
|
114
|
+
return null; // ${{ fromJSON(...) }}
|
|
115
|
+
const include = matrix.include ?? [];
|
|
116
|
+
const exclude = matrix.exclude ?? [];
|
|
117
|
+
if (typeof include === "string" || typeof exclude === "string")
|
|
118
|
+
return null;
|
|
119
|
+
const axes = {};
|
|
120
|
+
for (const [k, v] of Object.entries(matrix)) {
|
|
121
|
+
if (k === "include" || k === "exclude")
|
|
122
|
+
continue;
|
|
123
|
+
if (!Array.isArray(v))
|
|
124
|
+
return null;
|
|
125
|
+
axes[k] = v;
|
|
126
|
+
}
|
|
127
|
+
let combos = [{}];
|
|
128
|
+
for (const [k, vals] of Object.entries(axes)) {
|
|
129
|
+
combos = combos.flatMap((c) => vals.map((v) => ({ ...c, [k]: v })));
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(axes).length === 0)
|
|
132
|
+
combos = [];
|
|
133
|
+
combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c[k] === v)));
|
|
134
|
+
const extra = [];
|
|
135
|
+
for (const inc of include) {
|
|
136
|
+
const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
|
|
137
|
+
const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c[k] === v));
|
|
138
|
+
if (Object.keys(overlapping).length > 0 && targets.length > 0) {
|
|
139
|
+
for (const c of targets)
|
|
140
|
+
Object.assign(c, inc);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
extra.push({ ...inc });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
combos.push(...extra);
|
|
147
|
+
return combos.length > 0 ? combos : [null];
|
|
148
|
+
}
|
|
149
|
+
function renderName(template, combo) {
|
|
150
|
+
return template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
|
|
151
|
+
const expr = String(inner).trim();
|
|
152
|
+
if (expr.startsWith("matrix.") && combo) {
|
|
153
|
+
return String(combo[expr.slice("matrix.".length)] ?? "");
|
|
154
|
+
}
|
|
155
|
+
return whole;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function jobDisplayName(jobId, job, combo) {
|
|
159
|
+
if ("name" in job && job.name != null)
|
|
160
|
+
return renderName(String(job.name), combo);
|
|
161
|
+
let name = jobId;
|
|
162
|
+
if (combo)
|
|
163
|
+
name += ` (${Object.values(combo).map(String).join(", ")})`;
|
|
164
|
+
return name;
|
|
165
|
+
}
|
|
166
|
+
/** Return run|skipped|unknown for a job-level if. */
|
|
167
|
+
export function evalIf(cond) {
|
|
168
|
+
if (cond == null)
|
|
169
|
+
return "run";
|
|
170
|
+
let c = String(cond).trim();
|
|
171
|
+
c = c.replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
|
|
172
|
+
if (c === "false" || c === "False")
|
|
173
|
+
return "skipped";
|
|
174
|
+
if (c === "true" || c === "True" || c === "always()")
|
|
175
|
+
return "run";
|
|
176
|
+
const m = c.match(/^github\.event_name\s*(==|!=)\s*'([^']*)'$/);
|
|
177
|
+
if (m) {
|
|
178
|
+
const eq = m[2] === "pull_request";
|
|
179
|
+
const hit = m[1] === "==" ? eq : !eq;
|
|
180
|
+
return hit ? "run" : "skipped";
|
|
181
|
+
}
|
|
182
|
+
return "unknown";
|
|
183
|
+
}
|
|
184
|
+
async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "") {
|
|
185
|
+
const entries = [];
|
|
186
|
+
const jobs = wf.jobs ?? {};
|
|
187
|
+
const statuses = {};
|
|
188
|
+
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
189
|
+
const job = jobRaw ?? {};
|
|
190
|
+
let status = evalIf(job.if);
|
|
191
|
+
let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
|
|
192
|
+
let needs = job.needs ?? [];
|
|
193
|
+
if (typeof needs === "string")
|
|
194
|
+
needs = [needs];
|
|
195
|
+
const cond = String(job.if ?? "");
|
|
196
|
+
if (status !== "skipped" && !cond.includes("always()")) {
|
|
197
|
+
for (const n of needs) {
|
|
198
|
+
if (statuses[n] === "skipped") {
|
|
199
|
+
status = "skipped";
|
|
200
|
+
reason = `needs '${n}' which is skipped`;
|
|
201
|
+
}
|
|
202
|
+
else if (statuses[n] === "unknown" && status === "run") {
|
|
203
|
+
status = "unknown";
|
|
204
|
+
reason = `needs '${n}' whose status is unknown`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
statuses[jobId] = status;
|
|
209
|
+
if ("uses" in job) {
|
|
210
|
+
// reusable workflow call
|
|
211
|
+
const uses = job.uses;
|
|
212
|
+
const baseName = prefix + (job.name != null ? String(job.name) : jobId);
|
|
213
|
+
if (depth >= 1) {
|
|
214
|
+
entries.push([baseName, "unknown", "nested reusable workflow"]);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const m = uses.match(/^\.\/(.+)$/);
|
|
218
|
+
if (!m) {
|
|
219
|
+
entries.push([baseName, "unknown", `non-local reusable: ${uses}`]);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const content = await fetchFile(m[1]);
|
|
223
|
+
if (content == null) {
|
|
224
|
+
entries.push([baseName, "unknown", `cannot fetch ${uses}`]);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (status === "skipped") {
|
|
228
|
+
entries.push([baseName, "skipped", reason]);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const subWf = parseYaml(content);
|
|
232
|
+
const sub = await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `);
|
|
233
|
+
entries.push(...sub);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const combos = expandMatrix(job.strategy);
|
|
237
|
+
if (combos == null) {
|
|
238
|
+
entries.push([prefix + jobId, "unknown", "dynamic matrix"]);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
for (const combo of combos) {
|
|
242
|
+
entries.push([prefix + jobDisplayName(jobId, job, combo), status, reason]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return entries;
|
|
246
|
+
}
|
|
247
|
+
// ------------------------------------------------------------------- pipeline
|
|
248
|
+
export function makeOctokit() {
|
|
249
|
+
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
250
|
+
if (!token)
|
|
251
|
+
throw new Error("GH_TOKEN or GITHUB_TOKEN must be set");
|
|
252
|
+
return new Octokit({ auth: token });
|
|
253
|
+
}
|
|
254
|
+
export async function predict(octokit, repo, prNumber) {
|
|
255
|
+
const [owner, name] = repo.split("/");
|
|
256
|
+
const base = { owner, repo: name };
|
|
257
|
+
const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
|
|
258
|
+
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
|
259
|
+
...base,
|
|
260
|
+
pull_number: prNumber,
|
|
261
|
+
per_page: 100,
|
|
262
|
+
});
|
|
263
|
+
const ctx = {
|
|
264
|
+
action: pr.commits > 1 ? "synchronize" : "opened",
|
|
265
|
+
baseRef: pr.base.ref,
|
|
266
|
+
files: files.map((f) => f.filename),
|
|
267
|
+
};
|
|
268
|
+
const headSha = pr.head.sha;
|
|
269
|
+
const { data: headCommit } = await octokit.rest.repos.getCommit({
|
|
270
|
+
...base,
|
|
271
|
+
ref: headSha,
|
|
272
|
+
});
|
|
273
|
+
const headMsg = headCommit.commit.message;
|
|
274
|
+
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
275
|
+
return { entries: [], skip: "head commit message contains a skip instruction" };
|
|
276
|
+
}
|
|
277
|
+
const fetchFile = async (path) => {
|
|
278
|
+
try {
|
|
279
|
+
const { data } = await octokit.rest.repos.getContent({
|
|
280
|
+
...base,
|
|
281
|
+
path,
|
|
282
|
+
ref: headSha,
|
|
283
|
+
mediaType: { format: "raw" },
|
|
284
|
+
});
|
|
285
|
+
return data;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
292
|
+
...base,
|
|
293
|
+
per_page: 100,
|
|
294
|
+
});
|
|
295
|
+
const entries = [];
|
|
296
|
+
for (const w of workflows) {
|
|
297
|
+
const path = w.path;
|
|
298
|
+
if (!path.startsWith(".github/workflows/"))
|
|
299
|
+
continue;
|
|
300
|
+
if (w.state !== "active") {
|
|
301
|
+
entries.push({
|
|
302
|
+
workflow: path,
|
|
303
|
+
job: "*",
|
|
304
|
+
status: "no-dispatch",
|
|
305
|
+
reason: `workflow state: ${w.state}`,
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const content = await fetchFile(path);
|
|
310
|
+
if (content == null) {
|
|
311
|
+
entries.push({
|
|
312
|
+
workflow: path,
|
|
313
|
+
job: "*",
|
|
314
|
+
status: "unknown",
|
|
315
|
+
reason: "cannot fetch workflow file at head",
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
let wf;
|
|
320
|
+
try {
|
|
321
|
+
wf = parseYaml(content);
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
entries.push({
|
|
325
|
+
workflow: path,
|
|
326
|
+
job: "*",
|
|
327
|
+
status: "unknown",
|
|
328
|
+
reason: `YAML parse error: ${e}`,
|
|
329
|
+
});
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const [verdict, reason] = workflowDispatches(wf, ctx);
|
|
333
|
+
if (verdict !== "dispatch") {
|
|
334
|
+
entries.push({ workflow: path, job: "*", status: verdict, reason });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
for (const [jobName, status, jreason] of await expandJobs(wf, ctx, fetchFile)) {
|
|
338
|
+
entries.push({ workflow: path, job: jobName, status, reason: jreason || reason });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { entries, skip: null };
|
|
342
|
+
}
|
|
343
|
+
// ------------------------------------------------------------------------ CLI
|
|
344
|
+
function parseArgs(argv) {
|
|
345
|
+
const get = (flag) => {
|
|
346
|
+
const i = argv.indexOf(flag);
|
|
347
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
348
|
+
};
|
|
349
|
+
const repo = get("--repo");
|
|
350
|
+
const pr = get("--pr");
|
|
351
|
+
if (!repo || !pr) {
|
|
352
|
+
console.error("usage: predict --repo owner/name --pr N [--json]");
|
|
353
|
+
process.exit(2);
|
|
354
|
+
}
|
|
355
|
+
return { repo, pr: Number(pr), json: argv.includes("--json") };
|
|
356
|
+
}
|
|
357
|
+
const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
|
|
358
|
+
if (isMain) {
|
|
359
|
+
const args = parseArgs(process.argv.slice(2));
|
|
360
|
+
const { entries, skip } = await predict(makeOctokit(), args.repo, args.pr);
|
|
361
|
+
if (args.json) {
|
|
362
|
+
console.log(JSON.stringify({ entries, skip }, null, 2));
|
|
363
|
+
}
|
|
364
|
+
else if (skip) {
|
|
365
|
+
console.log(`# ${skip} -> nothing dispatches`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
for (const e of entries) {
|
|
369
|
+
if (e.job === "*")
|
|
370
|
+
console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
|
|
371
|
+
else
|
|
372
|
+
console.log(`${e.workflow} :: ${e.job} :: ${e.status}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Compare predict.ts output against what GitHub Actions actually dispatched.
|
|
2
|
+
//
|
|
3
|
+
// Usage: pnpm verify --repo owner/name --pr N
|
|
4
|
+
//
|
|
5
|
+
// Ground truth: workflow runs for the PR head SHA with a pull_request event,
|
|
6
|
+
// and the job entries inside each run (skipped jobs included).
|
|
7
|
+
import { makeOctokit, predict } from "./predict.js";
|
|
8
|
+
async function actualEntries(octokit, repo, prNumber) {
|
|
9
|
+
const [owner, name] = repo.split("/");
|
|
10
|
+
const base = { owner, repo: name };
|
|
11
|
+
const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
|
|
12
|
+
const runs = await octokit.paginate(octokit.rest.actions.listWorkflowRunsForRepo, {
|
|
13
|
+
...base,
|
|
14
|
+
head_sha: pr.head.sha,
|
|
15
|
+
event: "pull_request",
|
|
16
|
+
per_page: 100,
|
|
17
|
+
});
|
|
18
|
+
const entries = new Map();
|
|
19
|
+
const incomplete = [];
|
|
20
|
+
for (const run of runs) {
|
|
21
|
+
if (run.status !== "completed")
|
|
22
|
+
incomplete.push(run.path);
|
|
23
|
+
const jobs = await octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, {
|
|
24
|
+
...base,
|
|
25
|
+
run_id: run.id,
|
|
26
|
+
per_page: 100,
|
|
27
|
+
});
|
|
28
|
+
for (const j of jobs) {
|
|
29
|
+
entries.set(`${run.path} :: ${j.name}`, j.conclusion === "skipped" ? "skipped" : "run");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { entries, incomplete };
|
|
33
|
+
}
|
|
34
|
+
const get = (flag) => {
|
|
35
|
+
const i = process.argv.indexOf(flag);
|
|
36
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
37
|
+
};
|
|
38
|
+
const repo = get("--repo");
|
|
39
|
+
const prArg = get("--pr");
|
|
40
|
+
if (!repo || !prArg) {
|
|
41
|
+
console.error("usage: verify --repo owner/name --pr N");
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
const pr = Number(prArg);
|
|
45
|
+
const octokit = makeOctokit();
|
|
46
|
+
const { entries: predictedRaw } = await predict(octokit, repo, pr);
|
|
47
|
+
const predicted = new Map(predictedRaw
|
|
48
|
+
.filter((r) => r.job !== "*")
|
|
49
|
+
.map((r) => [`${r.workflow} :: ${r.job}`, r.status]));
|
|
50
|
+
const unknownWfs = new Set(predictedRaw.filter((r) => r.status === "unknown").map((r) => r.workflow));
|
|
51
|
+
const { entries: actual, incomplete } = await actualEntries(octokit, repo, pr);
|
|
52
|
+
if (incomplete.length > 0) {
|
|
53
|
+
console.log(`WARNING: runs still in progress: ${incomplete}`);
|
|
54
|
+
}
|
|
55
|
+
let ok = true;
|
|
56
|
+
const keys = [...new Set([...predicted.keys(), ...actual.keys()])].sort();
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
const p = predicted.get(key);
|
|
59
|
+
const a = actual.get(key);
|
|
60
|
+
const wf = key.split(" :: ")[0];
|
|
61
|
+
if (p === a) {
|
|
62
|
+
console.log(` OK ${key} :: ${a}`);
|
|
63
|
+
}
|
|
64
|
+
else if (p === "unknown") {
|
|
65
|
+
console.log(` ? ${key} :: predicted unknown, actual ${a}`);
|
|
66
|
+
}
|
|
67
|
+
else if (p === undefined) {
|
|
68
|
+
if (unknownWfs.has(wf)) {
|
|
69
|
+
console.log(` ? ${key} :: actual ${a}, workflow had unknown prediction`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
ok = false;
|
|
73
|
+
console.log(`MISS ${key} :: ran (${a}) but was not predicted`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (a === undefined) {
|
|
77
|
+
ok = false;
|
|
78
|
+
console.log(`OVER ${key} :: predicted ${p} but never appeared`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
ok = false;
|
|
82
|
+
console.log(`DIFF ${key} :: predicted ${p}, actual ${a}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const r of predictedRaw) {
|
|
86
|
+
if (r.job === "*" && r.status === "unknown") {
|
|
87
|
+
console.log(` ? ${r.workflow} :: workflow-level unknown: ${r.reason}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
console.log(ok ? "PASS" : "FAIL");
|
|
91
|
+
process.exit(ok ? 0 : 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "willfire",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kevin Scott <me@thekevinscott.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/thekevinscott/willrun.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"github-actions",
|
|
13
|
+
"ci",
|
|
14
|
+
"workflow",
|
|
15
|
+
"pull-request",
|
|
16
|
+
"prediction"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "dist/predict.js",
|
|
20
|
+
"types": "dist/predict.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/predict.d.ts",
|
|
24
|
+
"default": "./dist/predict.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"willfire": "dist/predict.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"prepublishOnly": "pnpm build",
|
|
39
|
+
"predict": "tsx src/predict.ts",
|
|
40
|
+
"verify": "tsx src/verify.ts"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@octokit/rest": "^22.0.0",
|
|
44
|
+
"yaml": "^2.6.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"tsx": "^4.19.0",
|
|
49
|
+
"typescript": "^5.6.0"
|
|
50
|
+
}
|
|
51
|
+
}
|