willfire 0.1.11 → 0.1.13
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/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/parseArgs.d.ts +9 -0
- package/dist/cli/parseArgs.js +42 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +41 -0
- package/dist/entries/index.d.ts +3 -0
- package/dist/entries/index.js +3 -0
- package/dist/entries/isJobEntry.d.ts +3 -0
- package/dist/entries/isJobEntry.js +2 -0
- package/dist/entries/isWorkflowEntry.d.ts +3 -0
- package/dist/entries/isWorkflowEntry.js +2 -0
- package/dist/entries/jobName.d.ts +3 -0
- package/dist/entries/jobName.js +2 -0
- package/dist/execute.d.ts +2 -2
- package/dist/execute.js +3 -1
- package/dist/expr/applyFunction.d.ts +11 -0
- package/dist/expr/applyFunction.js +41 -0
- package/dist/expr/asBool.d.ts +10 -0
- package/dist/expr/asBool.js +12 -0
- package/dist/expr/coalesceAnd.d.ts +7 -0
- package/dist/expr/coalesceAnd.js +17 -0
- package/dist/expr/coalesceOr.d.ts +6 -0
- package/dist/expr/coalesceOr.js +16 -0
- package/dist/expr/compare.d.ts +12 -0
- package/dist/expr/compare.js +54 -0
- package/dist/expr/cursor.d.ts +11 -0
- package/dist/expr/cursor.js +25 -0
- package/dist/expr/evaluate.d.ts +3 -0
- package/dist/expr/evaluate.js +6 -0
- package/dist/expr/evaluateValue.d.ts +14 -0
- package/dist/expr/evaluateValue.js +37 -0
- package/dist/expr/fromJson.d.ts +11 -0
- package/dist/expr/fromJson.js +29 -0
- package/dist/expr/index.d.ts +35 -0
- package/dist/expr/index.js +34 -0
- package/dist/expr/indexVal.d.ts +7 -0
- package/dist/expr/indexVal.js +34 -0
- package/dist/expr/lookup.d.ts +2 -0
- package/dist/expr/lookup.js +51 -0
- package/dist/expr/negate.d.ts +1 -0
- package/dist/expr/negate.js +3 -0
- package/dist/expr/parseAnd.d.ts +3 -0
- package/dist/expr/parseAnd.js +10 -0
- package/dist/expr/parseAtom.d.ts +3 -0
- package/dist/expr/parseAtom.js +43 -0
- package/dist/expr/parseCall.d.ts +9 -0
- package/dist/expr/parseCall.js +25 -0
- package/dist/expr/parseCmp.d.ts +3 -0
- package/dist/expr/parseCmp.js +12 -0
- package/dist/expr/parseOr.d.ts +11 -0
- package/dist/expr/parseOr.js +18 -0
- package/dist/expr/parsePrimary.d.ts +4 -0
- package/dist/expr/parsePrimary.js +16 -0
- package/dist/expr/parseUnary.d.ts +3 -0
- package/dist/expr/parseUnary.js +11 -0
- package/dist/expr/tokenize.d.ts +24 -0
- package/dist/expr/tokenize.js +74 -0
- package/dist/expr/truthy.d.ts +7 -0
- package/dist/expr/truthy.js +29 -0
- package/dist/expr/val.d.ts +72 -0
- package/dist/expr/val.js +1 -0
- package/dist/filters/index.d.ts +2 -0
- package/dist/filters/index.js +2 -0
- package/dist/filters/matchFilters.d.ts +2 -0
- package/dist/filters/matchFilters.js +12 -0
- package/dist/filters/patternToRegex.d.ts +1 -0
- package/dist/filters/patternToRegex.js +35 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/jobs/evalIf.d.ts +10 -0
- package/dist/jobs/evalIf.js +18 -0
- package/dist/jobs/expandJobs.d.ts +4 -0
- package/dist/jobs/expandJobs.js +247 -0
- package/dist/jobs/expandWorkflowJobs.d.ts +14 -0
- package/dist/jobs/expandWorkflowJobs.js +14 -0
- package/dist/jobs/index.d.ts +4 -0
- package/dist/jobs/index.js +4 -0
- package/dist/jobs/prScope.d.ts +10 -0
- package/dist/jobs/prScope.js +19 -0
- package/dist/matrix/expandMatrix.d.ts +4 -0
- package/dist/matrix/expandMatrix.js +6 -0
- package/dist/matrix/expandMatrixDetailed.d.ts +3 -0
- package/dist/matrix/expandMatrixDetailed.js +74 -0
- package/dist/matrix/formatMatrixValue.d.ts +8 -0
- package/dist/matrix/formatMatrixValue.js +16 -0
- package/dist/matrix/index.d.ts +4 -0
- package/dist/matrix/index.js +4 -0
- package/dist/matrix/matrixSuffix.d.ts +3 -0
- package/dist/matrix/matrixSuffix.js +8 -0
- package/dist/names/index.d.ts +4 -0
- package/dist/names/index.js +4 -0
- package/dist/names/jobDisplayName.d.ts +16 -0
- package/dist/names/jobDisplayName.js +25 -0
- package/dist/names/lookupPath.d.ts +1 -0
- package/dist/names/lookupPath.js +9 -0
- package/dist/names/renderName.d.ts +2 -0
- package/dist/names/renderName.js +26 -0
- package/dist/names/skippedDisplayName.d.ts +14 -0
- package/dist/names/skippedDisplayName.js +16 -0
- package/dist/predict/finalizePrediction.d.ts +2 -0
- package/dist/predict/finalizePrediction.js +19 -0
- package/dist/predict/index.d.ts +5 -0
- package/dist/predict/index.js +5 -0
- package/dist/predict/makeOctokit.d.ts +2 -0
- package/dist/predict/makeOctokit.js +7 -0
- package/dist/predict/predict.d.ts +3 -0
- package/dist/predict/predict.js +214 -0
- package/dist/predict/sourceKey.d.ts +3 -0
- package/dist/predict/sourceKey.js +2 -0
- package/dist/predict/stackTargetRef.d.ts +14 -0
- package/dist/predict/stackTargetRef.js +58 -0
- package/dist/triggers/getPrTrigger.d.ts +3 -0
- package/dist/triggers/getPrTrigger.js +18 -0
- package/dist/triggers/index.d.ts +2 -0
- package/dist/triggers/index.js +2 -0
- package/dist/triggers/workflowDispatches.d.ts +2 -0
- package/dist/triggers/workflowDispatches.js +45 -0
- package/dist/{predict.d.ts → types.d.ts} +64 -53
- package/dist/types.js +1 -0
- package/dist/uses/index.d.ts +1 -0
- package/dist/uses/index.js +1 -0
- package/dist/uses/parseUses.d.ts +15 -0
- package/dist/uses/parseUses.js +33 -0
- package/dist/verify.js +1 -1
- package/package.json +7 -7
- package/dist/expr.d.ts +0 -117
- package/dist/expr.js +0 -455
- package/dist/predict.js +0 -912
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Predict the set of CI check entries GitHub Actions will create for a PR.
|
|
2
|
+
//
|
|
3
|
+
// Faithful port of predict.py, which was verified entry-for-entry against
|
|
4
|
+
// live dispatches on thekevinbot/willrun-probe (PRs 1-7). Check-name
|
|
5
|
+
// resolution was verified the same way on probe PR 8, and cross-repo reusable
|
|
6
|
+
// workflow calls on probe PR 9; the rules they turned up are pinned in
|
|
7
|
+
// src/names.test.ts.
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
import { jobName } from "../entries/jobName.js";
|
|
10
|
+
import { makeExecutor, makeTreeProvider, runShell } from "../execute.js";
|
|
11
|
+
import { expandJobs } from "../jobs/expandJobs.js";
|
|
12
|
+
import { workflowDispatches } from "../triggers/workflowDispatches.js";
|
|
13
|
+
import { finalizePrediction } from "./finalizePrediction.js";
|
|
14
|
+
import { sourceKey } from "./sourceKey.js";
|
|
15
|
+
import { stackTargetRef } from "./stackTargetRef.js";
|
|
16
|
+
const SKIP_RE = /\[(skip ci|ci skip|no ci|skip actions|actions skip)\]/i;
|
|
17
|
+
const SKIP_TRAILER_RE = /^skip-checks:\s*true/im;
|
|
18
|
+
export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
19
|
+
const [owner, name] = repo.split("/");
|
|
20
|
+
const base = { owner, repo: name };
|
|
21
|
+
const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
|
|
22
|
+
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
|
23
|
+
...base,
|
|
24
|
+
pull_number: prNumber,
|
|
25
|
+
per_page: 100,
|
|
26
|
+
});
|
|
27
|
+
const stackTarget = await stackTargetRef(octokit, owner, name, pr);
|
|
28
|
+
const ctx = {
|
|
29
|
+
// The caller's answer wins whenever it has one. The commit-count fallback
|
|
30
|
+
// is a guess kept only so existing callers keep working.
|
|
31
|
+
action: opts.action ?? (pr.commits > 1 ? "synchronize" : "opened"),
|
|
32
|
+
baseRef: pr.base.ref,
|
|
33
|
+
...(stackTarget != null ? { stackTarget } : {}),
|
|
34
|
+
files: files.map((f) => f.filename),
|
|
35
|
+
};
|
|
36
|
+
const headSha = pr.head.sha;
|
|
37
|
+
/**
|
|
38
|
+
* The PR's own repo at the head commit — where expansion starts, and already
|
|
39
|
+
* a commit id, so its `ref` and `sha` are the same string.
|
|
40
|
+
*/
|
|
41
|
+
const headSource = { owner, repo: name, ref: headSha, sha: headSha };
|
|
42
|
+
// Provenance for the answer, filled as expansion reaches each source. The head
|
|
43
|
+
// is in from the start: it is read even on the skip path, where the commit
|
|
44
|
+
// message is what decides the verdict.
|
|
45
|
+
const sources = new Map([[sourceKey(headSource), headSource]]);
|
|
46
|
+
const { data: headCommit } = await octokit.rest.repos.getCommit({
|
|
47
|
+
...base,
|
|
48
|
+
ref: headSha,
|
|
49
|
+
});
|
|
50
|
+
const headMsg = headCommit.commit.message;
|
|
51
|
+
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
52
|
+
return finalizePrediction([], "head commit message contains a skip instruction", sources);
|
|
53
|
+
}
|
|
54
|
+
// A `uses:` naming a tag is the same lookup from every caller that writes it,
|
|
55
|
+
// so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
|
|
56
|
+
// cannot be resolved will not start resolving on the second ask.
|
|
57
|
+
const refCache = new Map();
|
|
58
|
+
const resolveRef = async (src) => {
|
|
59
|
+
const key = sourceKey(src);
|
|
60
|
+
const hit = refCache.get(key);
|
|
61
|
+
if (hit !== undefined)
|
|
62
|
+
return hit;
|
|
63
|
+
let sha;
|
|
64
|
+
try {
|
|
65
|
+
const { data } = await octokit.rest.repos.getCommit({
|
|
66
|
+
owner: src.owner,
|
|
67
|
+
repo: src.repo,
|
|
68
|
+
ref: src.ref,
|
|
69
|
+
});
|
|
70
|
+
sha = data.sha;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Deleted tag, private repo, rate limit, network: all one answer here.
|
|
74
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
75
|
+
sha = null;
|
|
76
|
+
}
|
|
77
|
+
refCache.set(key, sha);
|
|
78
|
+
if (sha != null)
|
|
79
|
+
sources.set(key, { ...src, sha });
|
|
80
|
+
return sha;
|
|
81
|
+
};
|
|
82
|
+
// One callee is commonly reached from several callers — a fleet repo calls
|
|
83
|
+
// the same `testing-conventions@v0` from eight workflows — so remember what
|
|
84
|
+
// each `owner/repo/path@sha` resolved to, misses included.
|
|
85
|
+
const cache = new Map();
|
|
86
|
+
const fetchWorkflow = async (path, src) => {
|
|
87
|
+
// Keyed and fetched on the commit, never the ref that named it. Two callers
|
|
88
|
+
// writing `@v0` and `@abc123` for the same commit are one read, and a tag
|
|
89
|
+
// that moves mid-prediction cannot hand back two different files.
|
|
90
|
+
const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
|
|
91
|
+
const hit = cache.get(key);
|
|
92
|
+
if (hit !== undefined)
|
|
93
|
+
return hit;
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
const { data } = await octokit.rest.repos.getContent({
|
|
97
|
+
owner: src.owner,
|
|
98
|
+
repo: src.repo,
|
|
99
|
+
path,
|
|
100
|
+
ref: src.sha,
|
|
101
|
+
mediaType: { format: "raw" },
|
|
102
|
+
});
|
|
103
|
+
content = data;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Private, deleted, bad ref, rate limit, network: all one answer here.
|
|
107
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
108
|
+
content = null;
|
|
109
|
+
}
|
|
110
|
+
cache.set(key, content);
|
|
111
|
+
return content;
|
|
112
|
+
};
|
|
113
|
+
const reader = { fetchWorkflow, resolveRef };
|
|
114
|
+
// The executor exists only when the caller granted something. Trees come
|
|
115
|
+
// from the tarball endpoint at the resolved commit, and every subprocess —
|
|
116
|
+
// `tar` included — goes through the one `runShell` seam.
|
|
117
|
+
let executor;
|
|
118
|
+
if (opts.execute != null && opts.execute.length > 0) {
|
|
119
|
+
const download = async (src) => {
|
|
120
|
+
try {
|
|
121
|
+
const { data } = await octokit.rest.repos.downloadTarballArchive({
|
|
122
|
+
owner: src.owner,
|
|
123
|
+
repo: src.repo,
|
|
124
|
+
ref: src.sha,
|
|
125
|
+
});
|
|
126
|
+
return new Uint8Array(data);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Private, deleted, rate limit, network: one answer, and the entries
|
|
130
|
+
// behind it stay unresolved with the failure named.
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
executor = makeExecutor({
|
|
135
|
+
grants: opts.execute,
|
|
136
|
+
workspace: headSource,
|
|
137
|
+
deps: {
|
|
138
|
+
provideTree: makeTreeProvider(download, runShell),
|
|
139
|
+
runCommand: runShell,
|
|
140
|
+
resolveRef,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
145
|
+
...base,
|
|
146
|
+
per_page: 100,
|
|
147
|
+
});
|
|
148
|
+
// `github.repository` is fixed for everything predicted here: reusable
|
|
149
|
+
// workflows and composite actions all run in the repo the PR is against.
|
|
150
|
+
// Seeding it once makes guards like the fleet's hermetic-vs-published
|
|
151
|
+
// `github.repository ==` checks decidable everywhere, granted or not.
|
|
152
|
+
const prFacts = {
|
|
153
|
+
github: { repository: `${headSource.owner}/${headSource.repo}` },
|
|
154
|
+
};
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const w of workflows) {
|
|
157
|
+
const path = w.path;
|
|
158
|
+
if (!path.startsWith(".github/workflows/"))
|
|
159
|
+
continue;
|
|
160
|
+
if (w.state !== "active") {
|
|
161
|
+
entries.push({
|
|
162
|
+
workflow: path,
|
|
163
|
+
job: "*",
|
|
164
|
+
status: "no-dispatch",
|
|
165
|
+
reason: `workflow state: ${w.state}`,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const content = await fetchWorkflow(path, headSource);
|
|
170
|
+
if (content == null) {
|
|
171
|
+
// The Actions API keeps listing a workflow as `active` after its file is
|
|
172
|
+
// deleted. There is no file at head, so there is nothing to dispatch —
|
|
173
|
+
// the same verdict as the disabled case above, reached a different way.
|
|
174
|
+
entries.push({
|
|
175
|
+
workflow: path,
|
|
176
|
+
job: "*",
|
|
177
|
+
status: "no-dispatch",
|
|
178
|
+
reason: "no workflow file at head",
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let wf;
|
|
183
|
+
try {
|
|
184
|
+
wf = parseYaml(content);
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
// GitHub creates a run for an unparseable workflow file and concludes it
|
|
188
|
+
// `startup_failure`. The run exists but has no jobs, so this is a
|
|
189
|
+
// workflow-level "it dispatches" with nothing to expand.
|
|
190
|
+
entries.push({
|
|
191
|
+
workflow: path,
|
|
192
|
+
job: "*",
|
|
193
|
+
status: "run",
|
|
194
|
+
reason: `YAML parse error: ${e}`,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const [dispatches, reason] = workflowDispatches(wf, ctx);
|
|
199
|
+
if (!dispatches) {
|
|
200
|
+
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
for (const j of await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor)) {
|
|
204
|
+
entries.push({
|
|
205
|
+
workflow: path,
|
|
206
|
+
job: jobName(j.job),
|
|
207
|
+
checkName: j.checkName,
|
|
208
|
+
status: j.status,
|
|
209
|
+
reason: j.reason || reason,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return finalizePrediction(entries, null, sources);
|
|
214
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Octokit } from "@octokit/rest";
|
|
2
|
+
import type { StackNode } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The branch this PR's stack ultimately targets, or null for a plain PR.
|
|
5
|
+
*
|
|
6
|
+
* GitHub's stacked-PR machinery (server-side, per-repo rollout — engaged on
|
|
7
|
+
* dirsql, not on willrun-probe, so it cannot be inferred from PR structure)
|
|
8
|
+
* builds a child PR's test merge on the parent PR's test merge and evaluates
|
|
9
|
+
* `branches:` against the stack's terminal target (#30). The mode is read off
|
|
10
|
+
* `merge_commit_sha`: its first parent is the base tip in normal mode and the
|
|
11
|
+
* parent PR's own merge sha in stacked mode. Anything undecidable ends the
|
|
12
|
+
* walk at the last proven hop; never throws.
|
|
13
|
+
*/
|
|
14
|
+
export declare function stackTargetRef(octokit: Octokit, owner: string, repo: string, pr: StackNode): Promise<string | null>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Past this the walk stops at the last proven hop, which only narrows reach. */
|
|
2
|
+
const MAX_STACK_DEPTH = 10;
|
|
3
|
+
/**
|
|
4
|
+
* The branch this PR's stack ultimately targets, or null for a plain PR.
|
|
5
|
+
*
|
|
6
|
+
* GitHub's stacked-PR machinery (server-side, per-repo rollout — engaged on
|
|
7
|
+
* dirsql, not on willrun-probe, so it cannot be inferred from PR structure)
|
|
8
|
+
* builds a child PR's test merge on the parent PR's test merge and evaluates
|
|
9
|
+
* `branches:` against the stack's terminal target (#30). The mode is read off
|
|
10
|
+
* `merge_commit_sha`: its first parent is the base tip in normal mode and the
|
|
11
|
+
* parent PR's own merge sha in stacked mode. Anything undecidable ends the
|
|
12
|
+
* walk at the last proven hop; never throws.
|
|
13
|
+
*/
|
|
14
|
+
export async function stackTargetRef(octokit, owner, repo, pr) {
|
|
15
|
+
let target = null;
|
|
16
|
+
let cur = pr;
|
|
17
|
+
try {
|
|
18
|
+
for (let hop = 0; hop < MAX_STACK_DEPTH; hop++) {
|
|
19
|
+
const mergeSha = cur.merge_commit_sha;
|
|
20
|
+
if (mergeSha == null)
|
|
21
|
+
break;
|
|
22
|
+
const { data: preview } = await octokit.rest.repos.getCommit({
|
|
23
|
+
owner,
|
|
24
|
+
repo,
|
|
25
|
+
ref: mergeSha,
|
|
26
|
+
});
|
|
27
|
+
const previewParent = preview.parents[0]?.sha;
|
|
28
|
+
if (previewParent == null)
|
|
29
|
+
break;
|
|
30
|
+
const { data: baseTip } = await octokit.rest.repos.getCommit({
|
|
31
|
+
owner,
|
|
32
|
+
repo,
|
|
33
|
+
ref: cur.base.ref,
|
|
34
|
+
});
|
|
35
|
+
// Built on the base branch tip: normal mode, the walk is done.
|
|
36
|
+
if (previewParent === baseTip.sha)
|
|
37
|
+
break;
|
|
38
|
+
// Otherwise only an exact match against an open PR whose head is the
|
|
39
|
+
// base branch proves stacked mode; a stale preview matches nothing.
|
|
40
|
+
const { data: candidates } = await octokit.rest.pulls.list({
|
|
41
|
+
owner,
|
|
42
|
+
repo,
|
|
43
|
+
state: "open",
|
|
44
|
+
head: `${owner}:${cur.base.ref}`,
|
|
45
|
+
per_page: 100,
|
|
46
|
+
});
|
|
47
|
+
const parent = candidates.find((p) => p.merge_commit_sha === previewParent);
|
|
48
|
+
if (parent == null)
|
|
49
|
+
break;
|
|
50
|
+
target = parent.base.ref;
|
|
51
|
+
cur = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Rate limit, permissions, network: stop at the last proven hop.
|
|
56
|
+
}
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const MISSING = Symbol("missing");
|
|
2
|
+
export function getPrTrigger(wf) {
|
|
3
|
+
// YAML 1.1 parsers read `on` as boolean true; the `yaml` package (1.2)
|
|
4
|
+
// keeps it a string key. Handle both.
|
|
5
|
+
const on = wf["on"] ?? wf["true"];
|
|
6
|
+
if (on == null)
|
|
7
|
+
return MISSING;
|
|
8
|
+
if (typeof on === "string")
|
|
9
|
+
return on === "pull_request" ? {} : MISSING;
|
|
10
|
+
if (Array.isArray(on))
|
|
11
|
+
return on.includes("pull_request") ? {} : MISSING;
|
|
12
|
+
if (typeof on === "object") {
|
|
13
|
+
if ("pull_request" in on)
|
|
14
|
+
return on["pull_request"] ?? {};
|
|
15
|
+
return MISSING;
|
|
16
|
+
}
|
|
17
|
+
return MISSING;
|
|
18
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { matchFilters } from "../filters/matchFilters.js";
|
|
2
|
+
import { getPrTrigger, MISSING } from "./getPrTrigger.js";
|
|
3
|
+
const DEFAULT_TYPES = ["opened", "synchronize", "reopened"];
|
|
4
|
+
// A predicate: does this workflow produce a run for the PR? Every workflow-level
|
|
5
|
+
// verdict is decidable, so there is no third answer to express. Only job
|
|
6
|
+
// expansion can be genuinely undecidable (dynamic matrix, an unreadable
|
|
7
|
+
// reusable workflow, unresolvable `if`), and that is a per-entry status.
|
|
8
|
+
export function workflowDispatches(wf, ctx) {
|
|
9
|
+
const trig = getPrTrigger(wf);
|
|
10
|
+
if (trig === MISSING)
|
|
11
|
+
return [false, "no pull_request trigger"];
|
|
12
|
+
const types = trig["types"] ?? DEFAULT_TYPES;
|
|
13
|
+
if (!types.includes(ctx.action)) {
|
|
14
|
+
return [false, `action '${ctx.action}' not in types [${types}]`];
|
|
15
|
+
}
|
|
16
|
+
// Setting a filter and its -ignore twin on one trigger is invalid config.
|
|
17
|
+
// GitHub does not fall back to "no filter" or skip the workflow: it creates
|
|
18
|
+
// the run and concludes `startup_failure`. The run exists, so it dispatches.
|
|
19
|
+
if ("branches" in trig && "branches-ignore" in trig) {
|
|
20
|
+
return [true, "both branches and branches-ignore set: startup failure"];
|
|
21
|
+
}
|
|
22
|
+
const branchRef = ctx.stackTarget ?? ctx.baseRef;
|
|
23
|
+
if ("branches" in trig && !matchFilters(branchRef, trig["branches"])) {
|
|
24
|
+
const label = ctx.stackTarget == null ? "base branch" : "stack target";
|
|
25
|
+
return [false, `${label} '${branchRef}' not in branches`];
|
|
26
|
+
}
|
|
27
|
+
if ("branches-ignore" in trig && matchFilters(branchRef, trig["branches-ignore"])) {
|
|
28
|
+
return [
|
|
29
|
+
false,
|
|
30
|
+
ctx.stackTarget == null
|
|
31
|
+
? "base branch in branches-ignore"
|
|
32
|
+
: `stack target '${branchRef}' in branches-ignore`,
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
if ("paths" in trig && "paths-ignore" in trig) {
|
|
36
|
+
return [true, "both paths and paths-ignore set: startup failure"];
|
|
37
|
+
}
|
|
38
|
+
if ("paths" in trig && !ctx.files.some((f) => matchFilters(f, trig["paths"]))) {
|
|
39
|
+
return [false, "no changed file matches paths"];
|
|
40
|
+
}
|
|
41
|
+
if ("paths-ignore" in trig && ctx.files.every((f) => matchFilters(f, trig["paths-ignore"]))) {
|
|
42
|
+
return [false, "all changed files match paths-ignore"];
|
|
43
|
+
}
|
|
44
|
+
return [true, "trigger matched"];
|
|
45
|
+
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { type ExecutionGrant, type JobExecutor } from "./execute.js";
|
|
4
|
-
import { type Scope } from "./expr.js";
|
|
5
|
-
interface EntryBase {
|
|
1
|
+
import type { ExecutionGrant } from "./execute.js";
|
|
2
|
+
export interface EntryBase {
|
|
6
3
|
workflow: string;
|
|
7
4
|
reason: string;
|
|
8
5
|
}
|
|
@@ -14,13 +11,11 @@ interface EntryBase {
|
|
|
14
11
|
* structurally admit `{ job: "*", status: "unknown" }` as a `JobEntry` — the
|
|
15
12
|
* exact shape this split exists to forbid. TypeScript cannot spell "string
|
|
16
13
|
* but not `"*"`", so the job side is branded instead. Build one with
|
|
17
|
-
*
|
|
14
|
+
* `jobName`; reading one is just a string.
|
|
18
15
|
*/
|
|
19
16
|
export type JobName = string & {
|
|
20
17
|
readonly __jobName: true;
|
|
21
18
|
};
|
|
22
|
-
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
23
|
-
export declare const jobName: <S extends string>(name: S extends "*" ? never : S) => JobName;
|
|
24
19
|
/**
|
|
25
20
|
* A verdict about the workflow as a whole: it produces no run at all, or it
|
|
26
21
|
* produces a run that expands into no job entries.
|
|
@@ -63,10 +58,6 @@ export interface JobEntry extends EntryBase {
|
|
|
63
58
|
status: "run" | "skipped" | "unknown";
|
|
64
59
|
}
|
|
65
60
|
export type Entry = WorkflowEntry | JobEntry;
|
|
66
|
-
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
67
|
-
export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
|
|
68
|
-
/** Narrow to the job-level variant without inspecting the sentinel. */
|
|
69
|
-
export declare const isJobEntry: (e: Entry) => e is JobEntry;
|
|
70
61
|
export interface Prediction {
|
|
71
62
|
entries: Entry[];
|
|
72
63
|
/**
|
|
@@ -86,9 +77,22 @@ export interface Prediction {
|
|
|
86
77
|
*/
|
|
87
78
|
sources: WorkflowSource[];
|
|
88
79
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Internal entry shapes. `checkName` is optional while entries are being
|
|
82
|
+
* accumulated so that workflow-level pushes stay one-liners; `finalize`
|
|
83
|
+
* settles the omitted ones to null.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately a union of two Omits rather than `Omit<Entry, "checkName">`.
|
|
86
|
+
* `Omit` over a union collapses to its common keys, which would re-widen
|
|
87
|
+
* `job`/`status` back into the single flat shape that let
|
|
88
|
+
* `{ job: "*", status: "unknown" }` typecheck. The push sites are exactly
|
|
89
|
+
* where that has to stay illegal, so the draft type keeps the variants apart.
|
|
90
|
+
*/
|
|
91
|
+
export type DraftWorkflowEntry = Omit<WorkflowEntry, "checkName">;
|
|
92
|
+
export type DraftJobEntry = Omit<JobEntry, "checkName"> & {
|
|
93
|
+
checkName?: string | null;
|
|
94
|
+
};
|
|
95
|
+
export type DraftEntry = DraftWorkflowEntry | DraftJobEntry;
|
|
92
96
|
/**
|
|
93
97
|
* The `pull_request` actions a caller can name.
|
|
94
98
|
*
|
|
@@ -127,21 +131,49 @@ export interface PredictOptions {
|
|
|
127
131
|
export interface Ctx {
|
|
128
132
|
action: string;
|
|
129
133
|
baseRef: string;
|
|
134
|
+
/**
|
|
135
|
+
* The branch the PR's stack ultimately targets, set only when GitHub's
|
|
136
|
+
* stacked-PR machinery is engaged (see `stackTargetRef`). Branch
|
|
137
|
+
* filters match against this instead of `baseRef` (#30).
|
|
138
|
+
*/
|
|
139
|
+
stackTarget?: string;
|
|
130
140
|
files: string[];
|
|
131
141
|
}
|
|
132
|
-
type Workflow = Record<string, any>;
|
|
133
|
-
type Combo = Record<string, any> | null;
|
|
134
|
-
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
135
|
-
export declare function expandMatrix(strategy: any, scope?: Scope): Combo[] | null;
|
|
142
|
+
export type Workflow = Record<string, any>;
|
|
143
|
+
export type Combo = Record<string, any> | null;
|
|
136
144
|
/**
|
|
137
|
-
*
|
|
145
|
+
* A matrix combination plus the keys that appear in its check-name
|
|
146
|
+
* parenthetical. The two differ: a key contributed by an `include` entry that
|
|
147
|
+
* merged into an existing axis-product combination is readable as
|
|
148
|
+
* `${{ matrix.key }}` but is NOT shown in the name.
|
|
138
149
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
150
|
+
* Probe-verified with `a: [x]` plus `include: [{a: x, extra: e1},
|
|
151
|
+
* {a: z, extra: e2}]` -> checks `m-include2 (x)` and `m-include2 (z, e2)`.
|
|
152
|
+
* The merged combination shows the axis key only; the combination the include
|
|
153
|
+
* created from scratch shows all of its own keys.
|
|
143
154
|
*/
|
|
144
|
-
export
|
|
155
|
+
export interface DetailedCombo {
|
|
156
|
+
values: Record<string, any>;
|
|
157
|
+
displayKeys: string[];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* null element = no matrix at all (a single, unsuffixed job).
|
|
161
|
+
*
|
|
162
|
+
* An *empty* array is different, and different again from a null return: it
|
|
163
|
+
* means the matrix is present and expanded to zero combinations, so the job
|
|
164
|
+
* schedules nothing. `[null]` would claim one check under the bare job name,
|
|
165
|
+
* which is a name GitHub never creates for a matrix job.
|
|
166
|
+
*/
|
|
167
|
+
export type DetailedCombos = Array<DetailedCombo | null> | null;
|
|
168
|
+
export interface Rendered {
|
|
169
|
+
text: string;
|
|
170
|
+
/** false when an expression was left in place because we cannot evaluate it. */
|
|
171
|
+
resolved: boolean;
|
|
172
|
+
}
|
|
173
|
+
export interface DisplayName {
|
|
174
|
+
name: string;
|
|
175
|
+
resolved: boolean;
|
|
176
|
+
}
|
|
145
177
|
/**
|
|
146
178
|
* One expanded job, before it is paired with its workflow. Named `ExpandedJob`
|
|
147
179
|
* because `JobEntry` is now the exported job-level variant of `Entry`.
|
|
@@ -212,31 +244,10 @@ export interface UsesTarget {
|
|
|
212
244
|
*/
|
|
213
245
|
source: SourceRef | null;
|
|
214
246
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
*
|
|
223
|
-
* The ref is taken from the last `@` so a branch containing a slash
|
|
224
|
-
* (`@feature/foo`) survives. Returns null for anything else — including a
|
|
225
|
-
* reference built from an expression, which we cannot evaluate and so must not
|
|
226
|
-
* guess a fetch target for.
|
|
227
|
-
*/
|
|
228
|
-
export declare function parseUses(uses: string): UsesTarget | null;
|
|
229
|
-
/**
|
|
230
|
-
* Expand one already-parsed workflow into its job entries. Exported so check
|
|
231
|
-
* names can be tested against recorded GitHub behaviour without a network
|
|
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.
|
|
238
|
-
*/
|
|
239
|
-
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource, scope?: Scope, executor?: JobExecutor): Promise<ExpandedJob[]>;
|
|
240
|
-
export declare function makeOctokit(): Octokit;
|
|
241
|
-
export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
|
|
242
|
-
export {};
|
|
247
|
+
/** The two fields the stack walk reads off a PR, whichever route listed it. */
|
|
248
|
+
export interface StackNode {
|
|
249
|
+
base: {
|
|
250
|
+
ref: string;
|
|
251
|
+
};
|
|
252
|
+
merge_commit_sha: string | null;
|
|
253
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { parseUses } from "./parseUses.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { parseUses } from "./parseUses.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UsesTarget } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
4
|
+
*
|
|
5
|
+
* Two spellings are legal:
|
|
6
|
+
*
|
|
7
|
+
* `./.github/workflows/x.yml` -> the caller's repo, same commit
|
|
8
|
+
* `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
|
|
9
|
+
*
|
|
10
|
+
* The ref is taken from the last `@` so a branch containing a slash
|
|
11
|
+
* (`@feature/foo`) survives. Returns null for anything else — including a
|
|
12
|
+
* reference built from an expression, which we cannot evaluate and so must not
|
|
13
|
+
* guess a fetch target for.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseUses(uses: string): UsesTarget | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { EXPRESSION_RE } from "../names/jobDisplayName.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
4
|
+
*
|
|
5
|
+
* Two spellings are legal:
|
|
6
|
+
*
|
|
7
|
+
* `./.github/workflows/x.yml` -> the caller's repo, same commit
|
|
8
|
+
* `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
|
|
9
|
+
*
|
|
10
|
+
* The ref is taken from the last `@` so a branch containing a slash
|
|
11
|
+
* (`@feature/foo`) survives. Returns null for anything else — including a
|
|
12
|
+
* reference built from an expression, which we cannot evaluate and so must not
|
|
13
|
+
* guess a fetch target for.
|
|
14
|
+
*/
|
|
15
|
+
export function parseUses(uses) {
|
|
16
|
+
if (EXPRESSION_RE.test(uses))
|
|
17
|
+
return null;
|
|
18
|
+
if (uses.startsWith("./")) {
|
|
19
|
+
const path = uses.slice(2);
|
|
20
|
+
return path === "" ? null : { path, source: null };
|
|
21
|
+
}
|
|
22
|
+
const at = uses.lastIndexOf("@");
|
|
23
|
+
if (at <= 0)
|
|
24
|
+
return null;
|
|
25
|
+
const ref = uses.slice(at + 1);
|
|
26
|
+
if (ref === "")
|
|
27
|
+
return null;
|
|
28
|
+
const [owner, repo, ...rest] = uses.slice(0, at).split("/");
|
|
29
|
+
const path = rest.join("/");
|
|
30
|
+
if (!owner || !repo || path === "")
|
|
31
|
+
return null;
|
|
32
|
+
return { path, source: { owner, repo, ref } };
|
|
33
|
+
}
|
package/dist/verify.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Ground truth: workflow runs for the PR head SHA with a pull_request event,
|
|
6
6
|
// and the job entries inside each run (skipped jobs included).
|
|
7
|
-
import { isJobEntry, makeOctokit, predict } from "./
|
|
7
|
+
import { isJobEntry, makeOctokit, predict } from "./index.js";
|
|
8
8
|
async function actualEntries(octokit, repo, prNumber) {
|
|
9
9
|
const [owner, name] = repo.split("/");
|
|
10
10
|
const base = { owner, repo: name };
|