@intentius/chant 0.117.0 → 0.118.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/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/files.d.ts +8 -0
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +11 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +113 -0
- package/dist/op/gate-review.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +47 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +20 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +13 -2
- package/src/cli/commands/build.ts +2 -1
- package/src/cli/handlers/run-wave.ts +13 -1
- package/src/cli/main.ts +7 -1
- package/src/cli/plugins.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/discovery/files.ts +18 -0
- package/src/lexicon.ts +10 -1
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/gate-review.test.ts +225 -0
- package/src/op/gate-review.ts +339 -0
- package/src/op/op-waves-run.ts +190 -12
- package/src/op/op-waves.ts +25 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval modes for a gate (#3684): which approvals of a wave's digest
|
|
3
|
+
* count.
|
|
4
|
+
*
|
|
5
|
+
* - `ledger` (the default): any resolution on `chant/lifecycle` that names
|
|
6
|
+
* the digest, as `chant approve` writes it. Anyone who can push to the
|
|
7
|
+
* branch can approve, in anyone's name.
|
|
8
|
+
* - `sealed`: a resolution counts only when its seal (`chant approve --sign`)
|
|
9
|
+
* verifies against the signers file read at the base commit, for the
|
|
10
|
+
* approver it names. A key that the change being applied adds to the
|
|
11
|
+
* signers file is not at base, so it cannot approve that change.
|
|
12
|
+
* - `pr-review`: a resolution counts as under `ledger`, and so does the
|
|
13
|
+
* forge review of the pull request that merged the applied commit: an
|
|
14
|
+
* approving review of the pull request's head, by a writer other than its
|
|
15
|
+
* author. It approves the digest that head planned, which the pull
|
|
16
|
+
* request's pipeline records on `chant/lifecycle` with `chant run wave
|
|
17
|
+
* --record-plans` ({@link HeadPlans}). A plan that moved after the review
|
|
18
|
+
* waits for a `chant approve` of the new digest.
|
|
19
|
+
*
|
|
20
|
+
* What counts as an approving review, per forge:
|
|
21
|
+
*
|
|
22
|
+
* | Forge | Counts | Writer |
|
|
23
|
+
* |---|---|---|
|
|
24
|
+
* | GitHub | the reviewer's newest deciding review is `APPROVED` and names the head (`commit_id`) | `GET /repos/{repo}/collaborators/{login}/permission` is `admin`, `maintain` or `write` |
|
|
25
|
+
* | Forgejo | the same, not dismissed and not stale | the review is `official`, which Forgejo sets for a reviewer with write access |
|
|
26
|
+
* | GitLab | the merge request's approvals, when the project removes approvals on a new push (`reset_approvals_on_push`), since a GitLab approval names no commit | the approver's access level is Developer (30) or higher |
|
|
27
|
+
*
|
|
28
|
+
* A standing review that requests changes, from a writer, holds the review
|
|
29
|
+
* back. The author's own review never counts.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import { checkGateSeal } from "../workspace/trust/seal";
|
|
34
|
+
import { readTrustPolicy, type TrustPolicy } from "../workspace/trust/policy";
|
|
35
|
+
import { gitRevisionSource } from "../workspace/record-source";
|
|
36
|
+
import { forgePrincipalOf, ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
37
|
+
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
38
|
+
import type { GateApprovalRule } from "./gate";
|
|
39
|
+
|
|
40
|
+
export { GATE_APPROVAL_SOURCES, type GateApprovalSource } from "./op-waves";
|
|
41
|
+
|
|
42
|
+
// ── sealed ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** The signers policy at `base`, read from the repository at `cwd`. */
|
|
45
|
+
export function trustPolicyAt(cwd: string, base: string): TrustPolicy {
|
|
46
|
+
return readTrustPolicy(gitRevisionSource(cwd, base), base);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The rule a `sealed` gate judges approvals by: a seal that verifies for the
|
|
51
|
+
* approver against `policy`, the signers at base. With no signers file at
|
|
52
|
+
* base nothing verifies, so nothing passes the gate.
|
|
53
|
+
*/
|
|
54
|
+
export function sealedApprovalRule(gate: string, policy: TrustPolicy): GateApprovalRule {
|
|
55
|
+
return {
|
|
56
|
+
requirement: { gate, class: null },
|
|
57
|
+
refuses(approval: GateResolutionRecord): string | null {
|
|
58
|
+
if (!policy.active) {
|
|
59
|
+
return `the gate is sealed, and there is no signers file (${policy.signersPath}) at base ${policy.base?.slice(0, 12) ?? ""}`.trim();
|
|
60
|
+
}
|
|
61
|
+
const check = checkGateSeal(policy, approval);
|
|
62
|
+
return check.attested === true ? null : check.message;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Both rules at once: an approval counts only when neither refuses it. */
|
|
68
|
+
export function bothRules(a: GateApprovalRule | null, b: GateApprovalRule | null): GateApprovalRule | null {
|
|
69
|
+
if (!a) return b;
|
|
70
|
+
if (!b) return a;
|
|
71
|
+
return { requirement: a.requirement, refuses: (r) => a.refuses(r) ?? b.refuses(r) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── pr-review ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** The digests each wave planned at one commit, recorded for a review of it to approve. */
|
|
77
|
+
export interface HeadPlans {
|
|
78
|
+
version: 1;
|
|
79
|
+
/** The Op waves spec's name. */
|
|
80
|
+
name: string;
|
|
81
|
+
/** The commit the waves were planned at: a pull request's head. */
|
|
82
|
+
head: string;
|
|
83
|
+
timestamp: string;
|
|
84
|
+
waves: Array<{ wave: number; name: string; digest: string }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
|
|
88
|
+
export const HEAD_PLANS_DIR = "_wave-plans";
|
|
89
|
+
|
|
90
|
+
export function headPlansPath(name: string, head: string): { dir: string; file: string } {
|
|
91
|
+
return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The merged pull request's review, as a `pr-review` gate reads it. */
|
|
95
|
+
export interface MergedReview {
|
|
96
|
+
pr: number;
|
|
97
|
+
url?: string;
|
|
98
|
+
/** The author, as a principal (`github:<login>`). */
|
|
99
|
+
author: string;
|
|
100
|
+
/** The pull request's head commit. */
|
|
101
|
+
head: string;
|
|
102
|
+
/** Writers other than the author whose approval of the head stands, as principals. */
|
|
103
|
+
approvers: string[];
|
|
104
|
+
/** Why a review did not count, one line each. */
|
|
105
|
+
refused: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What a `pr-review` gate asks of the forge. */
|
|
109
|
+
export interface ReviewSource {
|
|
110
|
+
kind: ForgeKind;
|
|
111
|
+
/** The merged pull request whose merge produced `sha`, with its review, or null when none did. */
|
|
112
|
+
mergedReview(sha: string): Promise<MergedReview | null>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
116
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
117
|
+
|
|
118
|
+
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
119
|
+
return async (path: string): Promise<unknown> => {
|
|
120
|
+
const url = `${trimSlash(base)}${path}`;
|
|
121
|
+
const res = await fetchImpl(url, { method: "GET", headers });
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
if (!res.ok) throw new ForgeApiError("GET", url, res.status, text);
|
|
124
|
+
return text ? JSON.parse(text) : null;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hostOf = (url: string | undefined, fallback: string): string => {
|
|
129
|
+
try {
|
|
130
|
+
return url ? new URL(url).host.toLowerCase() : fallback;
|
|
131
|
+
} catch {
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export interface ReviewSourceOptions {
|
|
137
|
+
apiBase: string;
|
|
138
|
+
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
139
|
+
repo: string;
|
|
140
|
+
token: string;
|
|
141
|
+
/** The forge's host, for naming people. */
|
|
142
|
+
host: string;
|
|
143
|
+
fetch?: ForgeFetch;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** GitHub or Forgejo, which share the pull request and review shapes. */
|
|
147
|
+
function githubShapedReviews(kind: "github" | "forgejo", opts: ReviewSourceOptions): ReviewSource {
|
|
148
|
+
const get = client(
|
|
149
|
+
opts.apiBase,
|
|
150
|
+
kind === "github"
|
|
151
|
+
? { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }
|
|
152
|
+
: { Authorization: `token ${opts.token}`, Accept: "application/json" },
|
|
153
|
+
opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch),
|
|
154
|
+
);
|
|
155
|
+
const repo = `/repos/${opts.repo}`;
|
|
156
|
+
const who = (login: string) => forgePrincipalOf(kind, opts.host, login);
|
|
157
|
+
const page = kind === "github" ? "per_page=100" : "limit=50";
|
|
158
|
+
async function prFor(sha: string): Promise<number | null> {
|
|
159
|
+
if (kind === "forgejo") {
|
|
160
|
+
try {
|
|
161
|
+
const pr = await get(`${repo}/commits/${sha}/pull`);
|
|
162
|
+
return isObject(pr) && typeof pr.number === "number" ? pr.number : null;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof ForgeApiError && err.status === 404) return null;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const prs = await get(`${repo}/commits/${sha}/pulls`);
|
|
169
|
+
if (!Array.isArray(prs)) return null;
|
|
170
|
+
const merged = prs.filter((p) => isObject(p) && p.merged_at);
|
|
171
|
+
const pick = (merged.find((p) => isObject(p) && p.merge_commit_sha === sha) ?? merged[0]) as Record<string, unknown> | undefined;
|
|
172
|
+
return pick && typeof pick.number === "number" ? pick.number : null;
|
|
173
|
+
}
|
|
174
|
+
async function writer(login: string): Promise<boolean> {
|
|
175
|
+
const p = await get(`${repo}/collaborators/${encodeURIComponent(login)}/permission`);
|
|
176
|
+
return isObject(p) && ["admin", "maintain", "write", "owner"].includes(String(p.role_name ?? p.permission));
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
kind,
|
|
180
|
+
async mergedReview(sha) {
|
|
181
|
+
const number = await prFor(sha);
|
|
182
|
+
if (number === null) return null;
|
|
183
|
+
const pr = await get(`${repo}/pulls/${number}`);
|
|
184
|
+
if (!isObject(pr) || !isObject(pr.user) || !isObject(pr.head)) throw new Error(`pull request #${number} came back without its author or head`);
|
|
185
|
+
const authorLogin = String(pr.user.login);
|
|
186
|
+
const head = String(pr.head.sha);
|
|
187
|
+
const reviews = await get(`${repo}/pulls/${number}/reviews?${page}`);
|
|
188
|
+
// A reviewer's newest deciding review is their standing one.
|
|
189
|
+
const standing = new Map<string, Record<string, unknown>>();
|
|
190
|
+
for (const r of Array.isArray(reviews) ? reviews : []) {
|
|
191
|
+
if (!isObject(r) || !isObject(r.user) || typeof r.state !== "string") continue;
|
|
192
|
+
const state = r.state.toUpperCase();
|
|
193
|
+
if (state === "COMMENTED" || state === "COMMENT" || state === "PENDING") continue;
|
|
194
|
+
standing.set(String(r.user.login), r);
|
|
195
|
+
}
|
|
196
|
+
const approvers: string[] = [];
|
|
197
|
+
const refused: string[] = [];
|
|
198
|
+
let changesRequested = false;
|
|
199
|
+
for (const [login, r] of standing) {
|
|
200
|
+
const state = r.dismissed === true ? "DISMISSED" : String(r.state).toUpperCase();
|
|
201
|
+
if (login.toLowerCase() === authorLogin.toLowerCase()) {
|
|
202
|
+
if (state === "APPROVED") refused.push(`${who(login)} is the author, whose own review never counts`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const isWriter = kind === "forgejo" ? r.official === true : await writer(login);
|
|
206
|
+
if (state === "REQUEST_CHANGES" || state === "CHANGES_REQUESTED") {
|
|
207
|
+
if (isWriter) changesRequested = true;
|
|
208
|
+
refused.push(`${who(login)} requests changes`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (state !== "APPROVED") continue;
|
|
212
|
+
if (!isWriter) refused.push(`${who(login)} approved without write access${kind === "forgejo" ? " (the review is not official)" : ""}`);
|
|
213
|
+
else if (r.commit_id !== head || r.stale === true) refused.push(`${who(login)} approved ${String(r.commit_id ?? "another commit").slice(0, 12)}, not the head ${head.slice(0, 12)}`);
|
|
214
|
+
else approvers.push(who(login));
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
pr: number,
|
|
218
|
+
...(typeof pr.html_url === "string" ? { url: pr.html_url } : {}),
|
|
219
|
+
author: who(authorLogin),
|
|
220
|
+
head,
|
|
221
|
+
approvers: changesRequested ? [] : approvers.sort(),
|
|
222
|
+
refused,
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A GitHub reader of pull request reviews. The token needs `pull-requests: read`. */
|
|
229
|
+
export function githubReviews(opts: ReviewSourceOptions): ReviewSource {
|
|
230
|
+
return githubShapedReviews("github", opts);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A Forgejo (or Gitea) reader of pull request reviews. */
|
|
234
|
+
export function forgejoReviews(opts: ReviewSourceOptions): ReviewSource {
|
|
235
|
+
return githubShapedReviews("forgejo", opts);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A GitLab reader of merge request approvals. The token needs `read_api`. */
|
|
239
|
+
export function gitlabReviews(opts: ReviewSourceOptions): ReviewSource {
|
|
240
|
+
const get = client(opts.apiBase, { "PRIVATE-TOKEN": opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
241
|
+
const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.repo))}`;
|
|
242
|
+
const who = (login: string) => forgePrincipalOf("gitlab", opts.host, login);
|
|
243
|
+
return {
|
|
244
|
+
kind: "gitlab",
|
|
245
|
+
async mergedReview(sha) {
|
|
246
|
+
const mrs = await get(`${project}/repository/commits/${sha}/merge_requests`);
|
|
247
|
+
const merged = Array.isArray(mrs)
|
|
248
|
+
? mrs.find((m) => isObject(m) && m.state === "merged" && (m.merge_commit_sha === sha || m.squash_commit_sha === sha)) ??
|
|
249
|
+
mrs.find((m) => isObject(m) && m.state === "merged")
|
|
250
|
+
: undefined;
|
|
251
|
+
if (!isObject(merged) || typeof merged.iid !== "number") return null;
|
|
252
|
+
const iid = merged.iid;
|
|
253
|
+
const author = isObject(merged.author) ? String(merged.author.username) : "";
|
|
254
|
+
const head = String(merged.sha);
|
|
255
|
+
const settings = await get(`${project}/approvals`);
|
|
256
|
+
const resets = isObject(settings) && settings.reset_approvals_on_push === true;
|
|
257
|
+
const approvals = await get(`${project}/merge_requests/${iid}/approvals`);
|
|
258
|
+
const by = isObject(approvals) && Array.isArray(approvals.approved_by) ? approvals.approved_by : [];
|
|
259
|
+
const approvers: string[] = [];
|
|
260
|
+
const refused: string[] = [];
|
|
261
|
+
for (const a of by) {
|
|
262
|
+
const user = isObject(a) && isObject(a.user) ? a.user : undefined;
|
|
263
|
+
if (!user || typeof user.username !== "string") continue;
|
|
264
|
+
const login = user.username;
|
|
265
|
+
if (login.toLowerCase() === author.toLowerCase()) {
|
|
266
|
+
refused.push(`${who(login)} is the author, whose own approval never counts`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!resets) {
|
|
270
|
+
refused.push(`${who(login)}'s approval names no commit, and the project keeps approvals after a new push (turn on "Remove all approvals when commits are added")`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const member = await get(`${project}/members/all/${String(user.id)}`).catch((err) => {
|
|
274
|
+
if (err instanceof ForgeApiError && err.status === 404) return null;
|
|
275
|
+
throw err;
|
|
276
|
+
});
|
|
277
|
+
if (!isObject(member) || typeof member.access_level !== "number" || member.access_level < 30) {
|
|
278
|
+
refused.push(`${who(login)} approved without the Developer role`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
approvers.push(who(login));
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
pr: iid,
|
|
285
|
+
...(typeof merged.web_url === "string" ? { url: merged.web_url } : {}),
|
|
286
|
+
author: who(author),
|
|
287
|
+
head,
|
|
288
|
+
approvers: approvers.sort(),
|
|
289
|
+
refused,
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The review source for the CI job this runs in: GitLab CI, Forgejo Actions
|
|
297
|
+
* or GitHub Actions, told apart as `./gate-resume.ts` does. The token is
|
|
298
|
+
* `CHANT_FORGE_TOKEN`, else `GITHUB_TOKEN`/`GH_TOKEN` or `GITLAB_TOKEN`.
|
|
299
|
+
* Throws naming what is missing.
|
|
300
|
+
*/
|
|
301
|
+
export function reviewSourceFromEnv(env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): ReviewSource {
|
|
302
|
+
const f = fetchImpl ? { fetch: fetchImpl } : {};
|
|
303
|
+
if (env.GITLAB_CI === "true") {
|
|
304
|
+
const token = env.CHANT_FORGE_TOKEN ?? env.GITLAB_TOKEN;
|
|
305
|
+
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !token) {
|
|
306
|
+
throw new Error("a pr-review gate on GitLab needs CI_API_V4_URL, CI_PROJECT_ID and CHANT_FORGE_TOKEN (a token with read_api)");
|
|
307
|
+
}
|
|
308
|
+
return gitlabReviews({ apiBase: env.CI_API_V4_URL, repo: env.CI_PROJECT_ID, token, host: env.CI_SERVER_HOST ?? hostOf(env.CI_SERVER_URL, "gitlab.com"), ...f });
|
|
309
|
+
}
|
|
310
|
+
const token = env.CHANT_FORGE_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
311
|
+
if (!env.GITHUB_REPOSITORY || !token) {
|
|
312
|
+
throw new Error("a pr-review gate needs GITHUB_REPOSITORY and a token (GITHUB_TOKEN with pull-requests: read, or CHANT_FORGE_TOKEN)");
|
|
313
|
+
}
|
|
314
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
315
|
+
const host = hostOf(env.GITHUB_SERVER_URL, "github.com");
|
|
316
|
+
const opts = { apiBase: env.GITHUB_API_URL ?? (forgejo ? `${trimSlash(env.GITHUB_SERVER_URL ?? "")}/api/v1` : "https://api.github.com"), repo: env.GITHUB_REPOSITORY, token, host, ...f };
|
|
317
|
+
return forgejo ? forgejoReviews(opts) : githubReviews(opts);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The head of the pull request a CI job runs for, from its environment: the
|
|
322
|
+
* event payload's `pull_request.head.sha` on GitHub and Forgejo (whose
|
|
323
|
+
* checkout is a merge preview, not the head), and on GitLab the merge
|
|
324
|
+
* request's source commit. Undefined outside a pull request's pipeline.
|
|
325
|
+
*/
|
|
326
|
+
export function pullRequestHeadFromEnv(env: Record<string, string | undefined> = process.env): string | undefined {
|
|
327
|
+
if (env.GITLAB_CI === "true") {
|
|
328
|
+
if (!env.CI_MERGE_REQUEST_IID) return undefined;
|
|
329
|
+
return env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA || env.CI_COMMIT_SHA || undefined;
|
|
330
|
+
}
|
|
331
|
+
if (!env.GITHUB_EVENT_PATH) return undefined;
|
|
332
|
+
try {
|
|
333
|
+
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")) as { pull_request?: { head?: { sha?: unknown } } };
|
|
334
|
+
const sha = event.pull_request?.head?.sha;
|
|
335
|
+
return typeof sha === "string" && sha !== "" ? sha : undefined;
|
|
336
|
+
} catch {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* needs an approval waits on `<gate>-wave-<k>` bound to
|
|
12
12
|
* {@link waveSetDigest} over its members, and exits 3 when nobody approved
|
|
13
13
|
* that digest.
|
|
14
|
+
* - Which approvals count is the wave's `approval`, read at base like its
|
|
15
|
+
* policy (#3684): `ledger`, `sealed` or `pr-review` (`./gate-review.ts`).
|
|
16
|
+
* Under `pr-review` the pull request's pipeline runs `--record-plans`
|
|
17
|
+
* ({@link recordOpWaveHeadPlans}), which plans every wave at the head and
|
|
18
|
+
* records the digests a review of that head approves.
|
|
14
19
|
* - Apply: each run's `apply` command. A share job (`--share i`) re-plans its
|
|
15
20
|
* slice first and refuses, without applying it, a run whose plan digest is
|
|
16
21
|
* not the one the deciding job recorded (exit 4).
|
|
@@ -21,12 +26,16 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
21
26
|
import { dirname, join, posix } from "node:path";
|
|
22
27
|
import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
|
|
23
28
|
import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
29
|
+
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
30
|
+
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
24
31
|
import {
|
|
25
32
|
OP_WAVE_DEFAULT_APPLY,
|
|
26
33
|
OP_WAVE_GATE_POLICIES,
|
|
34
|
+
GATE_APPROVAL_SOURCES,
|
|
27
35
|
assertOpWavesSpec,
|
|
28
36
|
opWaveRecordPath,
|
|
29
37
|
opWaveShare,
|
|
38
|
+
type GateApprovalSource,
|
|
30
39
|
type OpWaveGatePolicy,
|
|
31
40
|
type OpWaveRun,
|
|
32
41
|
type OpWavesSpec,
|
|
@@ -59,6 +68,8 @@ export interface OpWaveDecision {
|
|
|
59
68
|
op: string;
|
|
60
69
|
gate: string;
|
|
61
70
|
policy: OpWaveGatePolicy;
|
|
71
|
+
/** Which approvals count, read at base with the policy (#3684). */
|
|
72
|
+
approval: GateApprovalSource;
|
|
62
73
|
policySource: OpWavePolicySource;
|
|
63
74
|
/** The base commit the policy was read at. */
|
|
64
75
|
base: string;
|
|
@@ -67,6 +78,10 @@ export interface OpWaveDecision {
|
|
|
67
78
|
/** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
|
|
68
79
|
status: "approved" | "not-required" | "waiting";
|
|
69
80
|
approvedBy?: string;
|
|
81
|
+
/** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
|
|
82
|
+
via?: "ledger" | "pr-review";
|
|
83
|
+
/** The review that approved the wave, or why the review did not, under `pr-review`. */
|
|
84
|
+
review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
|
|
70
85
|
/** The command that approves this digest, when waiting. */
|
|
71
86
|
approve?: string;
|
|
72
87
|
}
|
|
@@ -128,19 +143,22 @@ export function readOpWavePolicy(
|
|
|
128
143
|
base: string,
|
|
129
144
|
cwd: string,
|
|
130
145
|
show: OpWaveShowAtBase = gitShowAtBase,
|
|
131
|
-
): { policy: OpWaveGatePolicy; source: OpWavePolicySource; base: string } {
|
|
146
|
+
): { policy: OpWaveGatePolicy; approval: GateApprovalSource; source: OpWavePolicySource; base: string } {
|
|
132
147
|
const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
|
|
133
|
-
|
|
148
|
+
const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha };
|
|
149
|
+
if (text === null) return absent;
|
|
134
150
|
let spec: OpWavesSpec;
|
|
135
151
|
try {
|
|
136
152
|
spec = parseOpWavesSpec(text, `${specFile} at ${base}`);
|
|
137
153
|
} catch {
|
|
138
|
-
return
|
|
154
|
+
return absent;
|
|
139
155
|
}
|
|
140
156
|
const wave = spec.waves.find((w) => w.name === name);
|
|
141
|
-
if (!wave) return
|
|
157
|
+
if (!wave) return absent;
|
|
142
158
|
const policy = wave.gate ?? "always";
|
|
143
|
-
|
|
159
|
+
// An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
|
|
160
|
+
const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
|
|
161
|
+
return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha };
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
/** Fill one argv template for one run. */
|
|
@@ -172,6 +190,14 @@ export interface RunOpWaveOptions {
|
|
|
172
190
|
show?: OpWaveShowAtBase;
|
|
173
191
|
now?: string;
|
|
174
192
|
log?: (line: string) => void;
|
|
193
|
+
/** The applied commit, which a `pr-review` gate finds the merged pull request by. Default `git rev-parse HEAD`. */
|
|
194
|
+
head?: string;
|
|
195
|
+
/** The forge's reviews, for a `pr-review` gate. Default: the CI job's forge (`reviewSourceFromEnv`). */
|
|
196
|
+
reviews?: ReviewSource;
|
|
197
|
+
/** The plans recorded for a pull request's head. Default: read from `chant/lifecycle`. */
|
|
198
|
+
headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
|
|
199
|
+
/** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
|
|
200
|
+
trust?: (base: string) => TrustPolicy;
|
|
175
201
|
}
|
|
176
202
|
|
|
177
203
|
export interface RunOpWaveResult {
|
|
@@ -268,23 +294,50 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
268
294
|
|
|
269
295
|
const members = wave.runs.map((run) => planRun(ctx, wave.name, run));
|
|
270
296
|
const digest = waveSetDigest(members);
|
|
271
|
-
const { policy, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
297
|
+
const { policy, approval, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
|
|
272
298
|
const gate = waveGateName(spec.gate ?? spec.name, k);
|
|
273
|
-
const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, policySource: source, base, digest, members, status: "not-required" };
|
|
299
|
+
const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, approval, policySource: source, base, digest, members, status: "not-required" };
|
|
274
300
|
if (opWaveNeedsApproval(policy, members)) {
|
|
275
|
-
const
|
|
301
|
+
const ledger = options.gates ?? gitGateLedgerPort({ cwd });
|
|
302
|
+
let port = ledger;
|
|
303
|
+
if (approval === "sealed") {
|
|
304
|
+
// #3684: only an approval sealed by a signer listed at base counts.
|
|
305
|
+
const review = await import("./gate-review");
|
|
306
|
+
const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
|
|
307
|
+
port = {
|
|
308
|
+
...ledger,
|
|
309
|
+
async approvalRule(g) {
|
|
310
|
+
const own = (await ledger.approvalRule?.(g)) ?? null;
|
|
311
|
+
return g === gate ? review.bothRules(own, sealed) : own;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const now = options.now ?? new Date().toISOString();
|
|
316
|
+
const check = await evaluateGate(port, {
|
|
276
317
|
op: spec.name,
|
|
277
318
|
gate,
|
|
278
319
|
planDigest: digest,
|
|
279
|
-
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}`,
|
|
280
|
-
|
|
320
|
+
description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}`,
|
|
321
|
+
now,
|
|
281
322
|
});
|
|
282
323
|
if (check.satisfied) {
|
|
283
324
|
decision.status = "approved";
|
|
325
|
+
decision.via = "ledger";
|
|
284
326
|
decision.approvedBy = check.resolution.resolvedBy;
|
|
327
|
+
} else if (approval === "pr-review") {
|
|
328
|
+
const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger });
|
|
329
|
+
decision.review = byReview.review;
|
|
330
|
+
if (byReview.approved) {
|
|
331
|
+
decision.status = "approved";
|
|
332
|
+
decision.via = "pr-review";
|
|
333
|
+
decision.approvedBy = byReview.review.approvers!.join(", ");
|
|
334
|
+
} else {
|
|
335
|
+
decision.status = "waiting";
|
|
336
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest);
|
|
337
|
+
}
|
|
285
338
|
} else {
|
|
286
339
|
decision.status = "waiting";
|
|
287
|
-
decision.approve = approveCommand(spec.name, gate, undefined, digest);
|
|
340
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
288
341
|
}
|
|
289
342
|
}
|
|
290
343
|
mkdirSync(dirname(recordPath), { recursive: true });
|
|
@@ -292,8 +345,12 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
|
|
|
292
345
|
result.decision = decision;
|
|
293
346
|
log(
|
|
294
347
|
`wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
|
|
295
|
-
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}
|
|
348
|
+
`digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
|
|
349
|
+
(decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : ""),
|
|
296
350
|
);
|
|
351
|
+
if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
|
|
352
|
+
log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
|
|
353
|
+
}
|
|
297
354
|
if (decision.status === "waiting") {
|
|
298
355
|
log(`Nothing in wave ${k} or after it ran. Read the plans, then approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
|
|
299
356
|
return { ...result, exitCode: 3 };
|
|
@@ -323,3 +380,124 @@ function applyRun(
|
|
|
323
380
|
result.failed.push(run.target);
|
|
324
381
|
}
|
|
325
382
|
}
|
|
383
|
+
|
|
384
|
+
/** What {@link approveByReview} found. */
|
|
385
|
+
interface ReviewOutcome {
|
|
386
|
+
approved: boolean;
|
|
387
|
+
review: NonNullable<OpWaveDecision["review"]>;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Under `pr-review` (#3684): the merged pull request's review approves the
|
|
392
|
+
* wave when a writer other than the author approved its head, and the head
|
|
393
|
+
* planned this wave's digest. The approval is then written to the ledger as a
|
|
394
|
+
* resolution that names the review, so the record says how the wave passed.
|
|
395
|
+
*/
|
|
396
|
+
async function approveByReview(
|
|
397
|
+
options: RunOpWaveOptions,
|
|
398
|
+
at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort },
|
|
399
|
+
): Promise<ReviewOutcome> {
|
|
400
|
+
const review = await import("./gate-review");
|
|
401
|
+
const head = options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: at.cwd, encoding: "utf-8" }).trim();
|
|
402
|
+
let merged: MergedReview | null;
|
|
403
|
+
try {
|
|
404
|
+
merged = await (options.reviews ?? review.reviewSourceFromEnv()).mergedReview(head);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return { approved: false, review: { reason: `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}` } };
|
|
407
|
+
}
|
|
408
|
+
if (!merged) return { approved: false, review: { reason: `no merged pull request produced ${head.slice(0, 12)}` } };
|
|
409
|
+
const seen = { pr: merged.pr, head: merged.head, ...(merged.url ? { url: merged.url } : {}) };
|
|
410
|
+
if (merged.approvers.length === 0) {
|
|
411
|
+
const why = merged.refused.length > 0 ? merged.refused.join("; ") : "nobody approved it";
|
|
412
|
+
return { approved: false, review: { ...seen, reason: `no writer other than its author approved the head of #${merged.pr}: ${why}` } };
|
|
413
|
+
}
|
|
414
|
+
const plans = await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
|
|
415
|
+
const planned = plans?.waves.find((w) => w.wave === at.k);
|
|
416
|
+
if (!planned) {
|
|
417
|
+
return {
|
|
418
|
+
approved: false,
|
|
419
|
+
review: { ...seen, approvers: merged.approvers, reason: `no plans are recorded for the head ${merged.head.slice(0, 12)} of #${merged.pr} (its pipeline runs \`chant run wave --record-plans\`)` },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (planned.digest !== at.digest) {
|
|
423
|
+
return {
|
|
424
|
+
approved: false,
|
|
425
|
+
review: { ...seen, approvers: merged.approvers, reason: `the plan moved after the review: the head planned ${planned.digest}, and the merge plans ${at.digest}` },
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
|
|
429
|
+
await at.port.appendResolution?.({
|
|
430
|
+
op: at.op,
|
|
431
|
+
gate: at.gate,
|
|
432
|
+
resolvedBy: merged.approvers[0]!,
|
|
433
|
+
timestamp: at.now,
|
|
434
|
+
planDigest: at.digest,
|
|
435
|
+
approver: { kind: "human" },
|
|
436
|
+
note,
|
|
437
|
+
...(merged.url ? { url: merged.url } : {}),
|
|
438
|
+
review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
|
|
439
|
+
});
|
|
440
|
+
return { approved: true, review: { ...seen, approvers: merged.approvers } };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
|
|
444
|
+
const { readBlobFromPath, requireLifecycleLedger } = await import("../lifecycle/git");
|
|
445
|
+
const { headPlansPath } = await import("./gate-review");
|
|
446
|
+
await requireLifecycleLedger({ cwd });
|
|
447
|
+
const { dir, file } = headPlansPath(name, head);
|
|
448
|
+
const text = await readBlobFromPath(dir, file, { cwd });
|
|
449
|
+
if (!text) return null;
|
|
450
|
+
try {
|
|
451
|
+
return JSON.parse(text) as HeadPlans;
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export interface RecordHeadPlansOptions {
|
|
458
|
+
spec: OpWavesSpec;
|
|
459
|
+
cwd?: string;
|
|
460
|
+
exec?: OpWaveExec;
|
|
461
|
+
/** The commit the plans are for. Default: the pull request's head from the CI job's environment, else `git rev-parse HEAD`. */
|
|
462
|
+
head?: string;
|
|
463
|
+
now?: string;
|
|
464
|
+
/** Write the record. Default: append to `chant/lifecycle` and push. */
|
|
465
|
+
write?: (plans: HeadPlans) => Promise<void>;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* `chant run wave --spec <file> --record-plans` (#3684): plan every wave at a
|
|
470
|
+
* pull request's head and record each wave's digest on `chant/lifecycle`,
|
|
471
|
+
* `_wave-plans/<name>/<head>.json`. A `pr-review` wave reads it after the
|
|
472
|
+
* merge: the review of that head approves the digests it planned, and only
|
|
473
|
+
* those.
|
|
474
|
+
*/
|
|
475
|
+
export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Promise<HeadPlans> {
|
|
476
|
+
const { spec } = options;
|
|
477
|
+
assertOpWavesSpec(spec);
|
|
478
|
+
const cwd = options.cwd ?? process.cwd();
|
|
479
|
+
const exec = options.exec ?? spawnExec;
|
|
480
|
+
const review = await import("./gate-review");
|
|
481
|
+
const head = options.head ?? review.pullRequestHeadFromEnv() ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).trim();
|
|
482
|
+
const plans: HeadPlans = {
|
|
483
|
+
version: 1,
|
|
484
|
+
name: spec.name,
|
|
485
|
+
head,
|
|
486
|
+
timestamp: options.now ?? new Date().toISOString(),
|
|
487
|
+
waves: spec.waves.map((wave, i) => {
|
|
488
|
+
const members = wave.runs.map((run) => planRun({ spec, wave: i + 1, cwd, exec }, wave.name, run));
|
|
489
|
+
return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
|
|
493
|
+
return plans;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function writeHeadPlans(plans: HeadPlans, cwd: string): Promise<void> {
|
|
497
|
+
const { pushLifecycle, requireLifecycleLedger, writeBlobToPath } = await import("../lifecycle/git");
|
|
498
|
+
const { headPlansPath } = await import("./gate-review");
|
|
499
|
+
await requireLifecycleLedger({ cwd });
|
|
500
|
+
const { dir, file } = headPlansPath(plans.name, plans.head);
|
|
501
|
+
await writeBlobToPath(dir, file, JSON.stringify(plans, null, 2) + "\n", `Op waves plans of ${plans.name} at ${plans.head.slice(0, 12)}`, { cwd });
|
|
502
|
+
if (!(await pushLifecycle({ cwd }))) throw new Error("the plans were recorded locally only: chant/lifecycle has no remote to push to");
|
|
503
|
+
}
|
package/src/op/op-waves.ts
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* the same digest a component fan-out's wave binds). Whether it waits at all
|
|
28
28
|
* is the wave's gate policy, read from the base commit's copy of the spec
|
|
29
29
|
* file, so a change cannot loosen the rule that gates its own merge.
|
|
30
|
+
*
|
|
31
|
+
* So is which approvals count (#3684), the wave's `approval`: `ledger` (any
|
|
32
|
+
* `chant approve` of the digest), `sealed` (only one whose seal verifies
|
|
33
|
+
* against the signers file at base) or `pr-review` (also the merged pull
|
|
34
|
+
* request's review of its head, by a writer other than its author, when the
|
|
35
|
+
* head planned the same digest). See `./gate-review.ts`.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
@@ -42,6 +48,17 @@ export type OpWaveGatePolicy =
|
|
|
42
48
|
|
|
43
49
|
export const OP_WAVE_GATE_POLICIES: readonly OpWaveGatePolicy[] = ["always", "on-destructive", "never"];
|
|
44
50
|
|
|
51
|
+
/** Which approvals of a gate's digest count (#3684). See `./gate-review.ts`. */
|
|
52
|
+
export type GateApprovalSource =
|
|
53
|
+
/** Any resolution on `chant/lifecycle` that names the digest. */
|
|
54
|
+
| "ledger"
|
|
55
|
+
/** As `ledger`, and the merged pull request's review of its head, by a writer other than its author. */
|
|
56
|
+
| "pr-review"
|
|
57
|
+
/** Only a resolution sealed by a key the signers file at base lists for its approver. */
|
|
58
|
+
| "sealed";
|
|
59
|
+
|
|
60
|
+
export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "pr-review", "sealed"];
|
|
61
|
+
|
|
45
62
|
/** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
|
|
46
63
|
export interface OpWaveRun {
|
|
47
64
|
/** What the run applies to. Unique within its wave; it names the run in the set digest. */
|
|
@@ -60,6 +77,11 @@ export interface OpWave {
|
|
|
60
77
|
* spec file at the base commit, never from the change being applied.
|
|
61
78
|
*/
|
|
62
79
|
gate?: OpWaveGatePolicy;
|
|
80
|
+
/**
|
|
81
|
+
* Which approvals of the wave's digest count (#3684). Default `"ledger"`.
|
|
82
|
+
* Like `gate`, the runner reads it from the spec file at the base commit.
|
|
83
|
+
*/
|
|
84
|
+
approval?: GateApprovalSource;
|
|
63
85
|
/**
|
|
64
86
|
* Split the wave into a deciding job and this many share jobs, each
|
|
65
87
|
* applying a slice of the runs. Default 1: one job plans, decides and
|
|
@@ -182,6 +204,9 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
182
204
|
if (wave.gate !== undefined && !OP_WAVE_GATE_POLICIES.includes(wave.gate)) {
|
|
183
205
|
throw new Error(`${where}: wave "${wave.name}" has gate "${wave.gate}"; use ${OP_WAVE_GATE_POLICIES.join(", ")}.`);
|
|
184
206
|
}
|
|
207
|
+
if (wave.approval !== undefined && !GATE_APPROVAL_SOURCES.includes(wave.approval)) {
|
|
208
|
+
throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
|
|
209
|
+
}
|
|
185
210
|
if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
|
|
186
211
|
const targets = new Set<string>();
|
|
187
212
|
for (const run of wave.runs) {
|