toolroll 0.9.75 → 0.9.76
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/CHANGELOG.md +49 -0
- package/dist/agent-fence.js +8 -0
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/batch-checks.js +3 -1
- package/dist/builder.js +1 -1
- package/dist/coding-workspace.js +7 -5
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/first-run.js +3 -1
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +3 -1
- package/dist/operate.js +7 -1
- package/dist/planner.js +5 -1
- package/dist/project-knowledge.js +1 -1
- package/dist/project-learning.js +22 -2
- package/dist/pull-request-flow.js +4 -0
- package/dist/release-adapters.d.ts +24 -2
- package/dist/release-adapters.js +136 -10
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +64 -0
- package/dist/release-auto.d.ts +153 -0
- package/dist/release-auto.js +496 -0
- package/dist/release-cli.js +56 -2
- package/dist/release-coverage.js +1 -1
- package/dist/release.d.ts +105 -1
- package/dist/release.js +229 -9
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +44 -3
- package/dist/scout.js +5 -1
- package/dist/server/render-tasks.d.ts +10 -0
- package/dist/server/render-tasks.js +23 -13
- package/dist/server/route-table.js +3 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +8 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.js +19 -4
- package/dist/task-base.js +1 -1
- package/dist/teardown-git.d.ts +38 -10
- package/dist/teardown-git.js +894 -108
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +1 -1
package/dist/release-cli.js
CHANGED
|
@@ -17,7 +17,8 @@ import { processes, releaseAdapters } from "./release-adapters.js";
|
|
|
17
17
|
import { releaseGate } from "./release-gate.js";
|
|
18
18
|
import { databasePath, openStore, revokeRelease } from "./store.js";
|
|
19
19
|
import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
|
|
20
|
-
import { authenticateApprover, machineActing, personProof } from "./scope.js";
|
|
20
|
+
import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
|
|
21
|
+
import { allowAutoPublish, setReleaseApproval } from "./release-auto.js";
|
|
21
22
|
import { createNightlyCheckFlow } from "./nightly-check.js";
|
|
22
23
|
import { parseSchedule } from "./flow-schedule.js";
|
|
23
24
|
export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
|
|
@@ -52,6 +53,13 @@ toolroll release nightly --repo <path> --as <you> --token <password> [--schedule
|
|
|
52
53
|
toolroll release ask-every-commit on|off --repo <path> [--as <you> --token <password>] — this project's releases ask
|
|
53
54
|
for every later commit (on), or carry test-only commits again (off: your password, never the lead's token)
|
|
54
55
|
|
|
56
|
+
toolroll release auto-approve on|off --repo <path> [--as <you> --token <password>] — this project's release approval:
|
|
57
|
+
on approves a release automatically once its pull request's CI and the release check pass on the exact commit
|
|
58
|
+
(your password typed now, never the lead's token or a remembered login); off asks you again (no password).
|
|
59
|
+
|
|
60
|
+
toolroll release publish-ok <task> --as <you> --token <password> — let an automatically approved release that was held
|
|
61
|
+
after its merge (its checks stopped passing) go on to its tag, publication and Homebrew (a person only)
|
|
62
|
+
|
|
55
63
|
toolroll release unsandboxed-carry on|off --repo <path> [--as <you> --token <password>] — carry release approvals
|
|
56
64
|
when this project's checks aren't sandboxed (on: your password, never the lead's token). With it on, later
|
|
57
65
|
test-only commits of a release run outside the sandbox without being shown to you first. Off by default; it counts
|
|
@@ -134,7 +142,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
134
142
|
return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
|
|
135
143
|
if (positional[0] === "nightly" && positional.length === 1)
|
|
136
144
|
return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: args.includes("--start") }, write, json, deps);
|
|
137
|
-
if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
145
|
+
if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
138
146
|
return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
|
|
139
147
|
}
|
|
140
148
|
if (positional.length !== 1)
|
|
@@ -263,6 +271,52 @@ async function runAuthorityCommand(command, target, flags, write, json, deps) {
|
|
|
263
271
|
const words = target === "on" ? "Every later release commit in this project asks for approval." : "Later test-only release commits ship under the release approval.";
|
|
264
272
|
return answer(true, { repo: flags.repo, ask: target === "on" }, words, 0);
|
|
265
273
|
}
|
|
274
|
+
if (command === "publish-ok") {
|
|
275
|
+
// A person's own decision to go on after an automatic release's checks stopped passing past its merge: never the
|
|
276
|
+
// lead, a remote token or a mode.
|
|
277
|
+
const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
|
|
278
|
+
if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
|
|
279
|
+
return refused("Only a person lets a held release go on, never the lead or a remote token.");
|
|
280
|
+
if (flags.as === undefined || flags.token === undefined || flags.token === "")
|
|
281
|
+
return refused("Letting a held release go on needs your password: add --as <you> --token <password>.");
|
|
282
|
+
const repo = store.lookupRef(target)?.repo ?? null;
|
|
283
|
+
if (repo === null)
|
|
284
|
+
return answer(false, { reason: "not-found", message: `No release task ${target}.` }, `No release task ${target}.`, 1);
|
|
285
|
+
const proved = authenticateApprover(store, flags.as, flags.token, repo);
|
|
286
|
+
if (!proved.ok || !personProof(proved.method))
|
|
287
|
+
return refused("Only an approver of this project, proved as a person, lets a held release go on.");
|
|
288
|
+
const allowed = allowAutoPublish(store, { taskId: target, by: flags.as, now });
|
|
289
|
+
return allowed.ok ? answer(true, { task: target }, `Release task ${target} may be tagged and published: rerun \`toolroll release\` to go on.`, 0) : answer(false, { reason: "refused", message: allowed.why }, allowed.why, 1);
|
|
290
|
+
}
|
|
291
|
+
if (command === "auto-approve") {
|
|
292
|
+
if (target !== "on" && target !== "off")
|
|
293
|
+
return answer(false, { reason: "usage", message: "auto-approve takes on or off." }, "auto-approve takes on or off.", 2);
|
|
294
|
+
if (flags.repo === undefined)
|
|
295
|
+
return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
|
|
296
|
+
const repo = store.listProjects().map(one => one.path).find(path => path === flags.repo) ?? null;
|
|
297
|
+
if (repo === null)
|
|
298
|
+
return answer(false, { reason: "not-found", message: "That isn't a project Toolroll knows." }, "That isn't a project Toolroll knows.", 1);
|
|
299
|
+
// Turning it on lets releases approve themselves: a credential act (docs/plans/passwordless.md), only a password
|
|
300
|
+
// typed now by an approver of this project — never the lead's token, an API token, a remembered login or a session.
|
|
301
|
+
// Turning it off only asks a person again, so it needs no password.
|
|
302
|
+
let actor = flags.as ?? "cli";
|
|
303
|
+
if (target === "on") {
|
|
304
|
+
const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
|
|
305
|
+
if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
|
|
306
|
+
return refused("Only a person turns on automatic release approval, never the lead or a remote token.");
|
|
307
|
+
if (flags.as === undefined || flags.token === undefined || flags.token === "")
|
|
308
|
+
return refused("Turning on automatic release approval needs your password: add --as <you> --token <password>.");
|
|
309
|
+
const proved = proveCredential(store, flags.as, flags.token, repo);
|
|
310
|
+
if (!proved.ok)
|
|
311
|
+
return refused("Only an approver of this project, with their password typed now, turns on automatic release approval.");
|
|
312
|
+
actor = flags.as;
|
|
313
|
+
}
|
|
314
|
+
const changed = setReleaseApproval(store, repo, target === "on", actor, now);
|
|
315
|
+
const words = target === "on"
|
|
316
|
+
? "Releases in this project approve themselves once the pull request's CI and the release check pass on the exact commit. You're told when one does; anything red still asks you."
|
|
317
|
+
: `Releases in this project ask you again.${changed.withdrawn.length === 0 ? "" : ` ${changed.withdrawn.length} automatic approval${changed.withdrawn.length === 1 ? " was" : "s were"} taken back.`}`;
|
|
318
|
+
return answer(true, { repo, autoApprove: target === "on", changed: changed.changed, withdrawn: changed.withdrawn }, words, 0);
|
|
319
|
+
}
|
|
266
320
|
if (command === "unsandboxed-carry") {
|
|
267
321
|
if (target !== "on" && target !== "off")
|
|
268
322
|
return answer(false, { reason: "usage", message: "unsandboxed-carry takes on or off." }, "unsandboxed-carry takes on or off.", 2);
|
package/dist/release-coverage.js
CHANGED
|
@@ -25,7 +25,7 @@ export const gitIsAncestor = (repo, ancestor, descendant) => {
|
|
|
25
25
|
return known;
|
|
26
26
|
let answer;
|
|
27
27
|
try {
|
|
28
|
-
execFileSync("git", ["-C", repo, "merge-base", "--is-ancestor", ancestor, descendant], { stdio: "ignore", timeout: 5_000 });
|
|
28
|
+
execFileSync("git", ["-C", repo, "--no-lazy-fetch", "--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "merge-base", "--is-ancestor", ancestor, descendant], { stdio: "ignore", timeout: 5_000 });
|
|
29
29
|
answer = true;
|
|
30
30
|
}
|
|
31
31
|
catch (error) {
|
package/dist/release.d.ts
CHANGED
|
@@ -42,12 +42,28 @@ export type ReleaseStep = typeof RELEASE_STEPS[number];
|
|
|
42
42
|
type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
|
|
43
43
|
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
|
|
44
44
|
export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
|
|
45
|
-
/** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known
|
|
45
|
+
/** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known; `id` its check
|
|
46
|
+
* run id, `suite` its check suite (one workflow run: its re-runs stay in it) and `head` the commit GitHub filed it
|
|
47
|
+
* under, when known. */
|
|
46
48
|
export type CheckRun = {
|
|
47
49
|
name: string;
|
|
48
50
|
status: string;
|
|
49
51
|
conclusion: string | null;
|
|
50
52
|
app?: string | null;
|
|
53
|
+
id?: number | null;
|
|
54
|
+
suite?: number | null;
|
|
55
|
+
head?: string | null;
|
|
56
|
+
/** The workflow (its id, from GitHub's workflow-run API) that this check's suite is a run of, when known. */
|
|
57
|
+
workflow?: number | null;
|
|
58
|
+
};
|
|
59
|
+
/** How many failed attempts of one CI check, and failed release-check runs, an automatic approval tolerates (flakes on a
|
|
60
|
+
* pinned commit and digest), before a person decides (auto-approve-green.md). */
|
|
61
|
+
export declare const AUTO_FLAKE_CAP = 2;
|
|
62
|
+
/** One failed attempt that a later attempt of the same job passed. */
|
|
63
|
+
export type CheckFailure = {
|
|
64
|
+
id: number;
|
|
65
|
+
name: string;
|
|
66
|
+
conclusion: string | null;
|
|
51
67
|
};
|
|
52
68
|
export type PullRequest = {
|
|
53
69
|
number: number;
|
|
@@ -215,6 +231,64 @@ export type ReleaseAuthorityAdapter = {
|
|
|
215
231
|
* the authority could not be stopped (a stale epoch, a merge already reserved): the coordinator fails closed. */
|
|
216
232
|
needsApproval(authority: number, reason: string, epoch: number): Promise<boolean>;
|
|
217
233
|
operations(authority: number): Promise<OperationView[]>;
|
|
234
|
+
/** Releases that approve themselves when green (docs/plans/auto-approve-green.md): the plane's own seals, each proven
|
|
235
|
+
* again inside the plane. Absent: a plane that can't, and every release asks a person as before. */
|
|
236
|
+
auto?: AutoReleaseAdapter;
|
|
237
|
+
};
|
|
238
|
+
/** The plane's automatic release approval, as the coordinator asks it. Every answer is the plane's own: the coordinator
|
|
239
|
+
* hands it ids, never evidence (the plane reads GitHub CI itself for `approve`). */
|
|
240
|
+
export type AutoReleaseAdapter = {
|
|
241
|
+
/** The project's "Approve automatically when checks pass", read now (off when unreadable). */
|
|
242
|
+
setting(taskId: string): Promise<boolean>;
|
|
243
|
+
/** Seal `auto-check` so the release check runs before anyone approves. */
|
|
244
|
+
authorizeCheck(input: {
|
|
245
|
+
taskId: string;
|
|
246
|
+
digest: string;
|
|
247
|
+
candidate: string;
|
|
248
|
+
}): Promise<{
|
|
249
|
+
ok: true;
|
|
250
|
+
} | {
|
|
251
|
+
ok: false;
|
|
252
|
+
why: string;
|
|
253
|
+
}>;
|
|
254
|
+
/** Whether the `auto-check` seal still stands (the setting on, its evidence, the digest). */
|
|
255
|
+
checking(taskId: string): Promise<{
|
|
256
|
+
ok: true;
|
|
257
|
+
} | {
|
|
258
|
+
ok: false;
|
|
259
|
+
why: string;
|
|
260
|
+
}>;
|
|
261
|
+
/** Observe CI on the candidate and, when everything passed, seal `auto-green` (opening the release's authority). */
|
|
262
|
+
approve(input: {
|
|
263
|
+
taskId: string;
|
|
264
|
+
digest: string;
|
|
265
|
+
candidate: string;
|
|
266
|
+
run: number;
|
|
267
|
+
receipt: string;
|
|
268
|
+
}): Promise<{
|
|
269
|
+
ok: true;
|
|
270
|
+
by: string;
|
|
271
|
+
at: string;
|
|
272
|
+
ci: {
|
|
273
|
+
id: number;
|
|
274
|
+
name: string;
|
|
275
|
+
}[];
|
|
276
|
+
} | {
|
|
277
|
+
ok: false;
|
|
278
|
+
why: string;
|
|
279
|
+
}>;
|
|
280
|
+
/** The automatic road ended without a release: the task asks a person, and the owner is told why. */
|
|
281
|
+
giveUp(taskId: string, why: string): Promise<void>;
|
|
282
|
+
/** The release check failed while checking automatically: the owner is told; the commit may be checked again (a
|
|
283
|
+
* regate of the same commit and digest, within the plane's cap on failed runs). */
|
|
284
|
+
checkFailed(taskId: string, why: string): Promise<void>;
|
|
285
|
+
/** "Released 0.9.x automatically". */
|
|
286
|
+
released(taskId: string, tag: string): Promise<void>;
|
|
287
|
+
/** CI stopped passing after the merge of an automatic release: hold its tag, publication and Homebrew until a person
|
|
288
|
+
* lets it go on (`toolroll release publish-ok`). Durable, in the plane. */
|
|
289
|
+
holdPublish(taskId: string, why: string): Promise<void>;
|
|
290
|
+
/** Why the release's publication waits for a person, or null. Read from the plane, whatever the journal says. */
|
|
291
|
+
publishHeld(taskId: string): Promise<string | null>;
|
|
218
292
|
};
|
|
219
293
|
export type ReleaseAdapters = {
|
|
220
294
|
/** One approval per release (release-once.md); absent on a plane that can't carry. */
|
|
@@ -304,6 +378,9 @@ export type ReleaseAdapters = {
|
|
|
304
378
|
authenticated(): Promise<boolean>;
|
|
305
379
|
pullRequest(repo: string, branch: string): Promise<PullRequest | null>;
|
|
306
380
|
checks(repo: string, sha: string): Promise<CheckRun[]>;
|
|
381
|
+
/** Whether GitHub's check history of this commit can be read whole (fewer check suites than the check-runs
|
|
382
|
+
* endpoint's ceiling). Absent or false: an automatic release fails closed; a person's is unaffected. */
|
|
383
|
+
complete?(repo: string, sha: string): Promise<boolean>;
|
|
307
384
|
merge(repo: string, pr: number, head: string, message?: string): Promise<string>;
|
|
308
385
|
tag(repo: string, tag: string): Promise<string | null>;
|
|
309
386
|
createTag(repo: string, tag: string, sha: string): Promise<void>;
|
|
@@ -406,6 +483,13 @@ export type ReleaseJournal = {
|
|
|
406
483
|
needsApproval?: boolean;
|
|
407
484
|
/** This journal's task was picked fresh after a CI failure after the deploy: an id that never existed, never another. */
|
|
408
485
|
freshTask?: boolean;
|
|
486
|
+
/** The automatic road (auto-approve-green.md): `checking` once the plane sealed `auto-check`, `approved` once it sealed
|
|
487
|
+
* `auto-green`, `refused` (and why) when it wouldn't — then a person approves, as before. */
|
|
488
|
+
auto?: {
|
|
489
|
+
state: "checking" | "approved" | "refused";
|
|
490
|
+
why?: string;
|
|
491
|
+
ci?: number[];
|
|
492
|
+
} | null;
|
|
409
493
|
/** When each step was about to change something outside this machine: what a resume must look for before acting. */
|
|
410
494
|
intents: Partial<Record<ReleaseStep, string>>;
|
|
411
495
|
stopped: {
|
|
@@ -476,4 +560,24 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
|
|
|
476
560
|
export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
|
|
477
561
|
/** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
|
|
478
562
|
export declare function transientNetworkError(error: unknown): boolean;
|
|
563
|
+
/**
|
|
564
|
+
* The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
|
|
565
|
+
* each reported attempt must be filed under `head` with a check-run id; a required check counts only from GitHub
|
|
566
|
+
* Actions (an app that names a check alike can't stand in for it); attempts are grouped by the job they belong to — the
|
|
567
|
+
* app, its check suite (a workflow run, whose re-runs stay in it) and the name — and every group is judged by its
|
|
568
|
+
* latest attempt; every required check needs a group. With `cap`, a group whose earlier attempts failed more than `cap`
|
|
569
|
+
* times fails too (an automatic approval's flake allowance); the failures it tolerated are returned.
|
|
570
|
+
*/
|
|
571
|
+
export declare function prChecksVerdict(runs: readonly CheckRun[], head: string, required: readonly string[], options?: {
|
|
572
|
+
cap?: number;
|
|
573
|
+
}): {
|
|
574
|
+
state: "passed";
|
|
575
|
+
failures: CheckFailure[];
|
|
576
|
+
} | {
|
|
577
|
+
state: "pending";
|
|
578
|
+
waiting: string[];
|
|
579
|
+
} | {
|
|
580
|
+
state: "failed";
|
|
581
|
+
failed: string[];
|
|
582
|
+
};
|
|
479
583
|
export {};
|
package/dist/release.js
CHANGED
|
@@ -51,12 +51,18 @@ export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet",
|
|
|
51
51
|
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
|
|
52
52
|
export const RELEASE_LIMITS = { gate: 60, approval: 24 * 60, check: 120, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
|
|
53
53
|
const POLL_MS = 15_000;
|
|
54
|
+
/** How many failed attempts of one CI check, and failed release-check runs, an automatic approval tolerates (flakes on a
|
|
55
|
+
* pinned commit and digest), before a person decides (auto-approve-green.md). */
|
|
56
|
+
export const AUTO_FLAKE_CAP = 2;
|
|
54
57
|
/** A stop the release reports: the step it stopped at and why, in words. */
|
|
55
58
|
class Stop extends Error {
|
|
56
59
|
reason;
|
|
57
|
-
|
|
60
|
+
build;
|
|
61
|
+
/** `build`: the release build itself failed before its check (not a failed check). */
|
|
62
|
+
constructor(reason, message, build = false) {
|
|
58
63
|
super(message);
|
|
59
64
|
this.reason = reason;
|
|
65
|
+
this.build = build;
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
/** The branch moved before the deploy reservation: carry the approval to the new head, or file it for a person. */
|
|
@@ -367,14 +373,50 @@ export async function runRelease(options, adapters) {
|
|
|
367
373
|
* release needs a person's fresh approval, even for the same commit. It is persisted (the authority stops; the
|
|
368
374
|
* journal says so) before the release stops; `--new` files the commit for a person again.
|
|
369
375
|
*/
|
|
370
|
-
const ciFailedAfterDeploy = async (what) => {
|
|
376
|
+
const ciFailedAfterDeploy = async (what, deploy = "after") => {
|
|
371
377
|
j.needsApproval = true;
|
|
372
378
|
save();
|
|
379
|
+
// An automatic approval never outlives a CI failure: the owner is told, and a person approves again.
|
|
380
|
+
if (j.auto?.state === "approved")
|
|
381
|
+
await rel?.auto?.giveUp(j.task.id, deploy === "after" ? `${what}, after the deploy` : what);
|
|
373
382
|
// Fail closed: the authority must have stopped; otherwise its approval could still carry the commit.
|
|
374
|
-
if (rel !== undefined && j.authority && (epoch === null || !await rel.needsApproval(j.authority.id, "a pull request check failed after the deploy", epoch))) {
|
|
383
|
+
if (rel !== undefined && j.authority && (epoch === null || !await rel.needsApproval(j.authority.id, deploy === "after" ? "a pull request check failed after the deploy" : "a pull request check failed before the deploy", epoch))) {
|
|
375
384
|
throw new Stop("lease", `${what}. Nothing was merged, but this release's approval couldn't be stopped (another coordinator holds it, or a merge is reserved). Check it with \`toolroll release ${j.branch} --repo ${j.checkout}\` before going on.`);
|
|
376
385
|
}
|
|
377
|
-
throw new Stop("ci-failed",
|
|
386
|
+
throw new Stop("ci-failed", deploy === "after"
|
|
387
|
+
? `${what}. Nothing was merged. The deploy had already started, so this commit needs a person's approval again: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`
|
|
388
|
+
: `${what}. Nothing was deployed or merged. This commit needs a person's approval: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
389
|
+
};
|
|
390
|
+
/** An automatic approval's CI allowance: at most AUTO_FLAKE_CAP failed attempts per job; a person's has none. */
|
|
391
|
+
const autoCap = () => (j.auto?.state === "approved" ? { cap: AUTO_FLAKE_CAP } : {});
|
|
392
|
+
/**
|
|
393
|
+
* An automatically approved release stands only while its CI does (auto-approve-green.md): the complete, current
|
|
394
|
+
* checks on the exact commit are read again right before each step that can't be taken back — the deploy, the CLI
|
|
395
|
+
* relink, the merge and the tag. Anything but green (failed, a job re-run and pending, past the flake cap) stops the
|
|
396
|
+
* release, revokes its approval and tells the owner; the commit then needs a person.
|
|
397
|
+
*/
|
|
398
|
+
const autoCiHolds = async (before) => {
|
|
399
|
+
if (j.auto?.state !== "approved")
|
|
400
|
+
return;
|
|
401
|
+
const read = await adapters.github.checks(j.github, j.candidate.sha);
|
|
402
|
+
const whole = adapters.github.complete === undefined ? false : await adapters.github.complete(j.github, j.candidate.sha);
|
|
403
|
+
const verdict = whole ? prChecksVerdict(read, j.candidate.sha, REQUIRED_PR_CHECKS, { cap: AUTO_FLAKE_CAP }) : { state: "failed", failed: ["GitHub's check history of this commit can't be read whole"] };
|
|
404
|
+
if (verdict.state === "passed")
|
|
405
|
+
return;
|
|
406
|
+
if (before === "the tag") {
|
|
407
|
+
// Already merged: nothing to revoke. It isn't tagged or published, and a person decides — durably, in the plane.
|
|
408
|
+
const why = `Pull request checks on ${j.candidate.sha.slice(0, 7)} stopped passing after the merge`;
|
|
409
|
+
await rel?.auto?.holdPublish(j.task.id, why);
|
|
410
|
+
await rel?.auto?.giveUp(j.task.id, why);
|
|
411
|
+
throw new Stop("ci-failed", `Pull request checks on ${j.candidate.sha.slice(0, 7)} are no longer all passing: ${(verdict.state === "failed" ? verdict.failed : verdict.waiting).join(", ")}. It was merged, but nothing was tagged or published; a person decides how to go on.`);
|
|
412
|
+
}
|
|
413
|
+
await ciFailedAfterDeploy(`Pull request checks on ${j.candidate.sha.slice(0, 7)} are no longer all passing before ${before}: ${(verdict.state === "failed" ? verdict.failed : verdict.waiting).join(", ")}`, before === "the deploy" && j.deploy === null ? "before" : "after");
|
|
414
|
+
};
|
|
415
|
+
/** A publication held for a person (the plane's record) stops the tag, the publication and Homebrew. */
|
|
416
|
+
const publishHold = async () => {
|
|
417
|
+
const held = rel?.auto === undefined ? null : await rel.auto.publishHeld(j.task.id);
|
|
418
|
+
if (held !== null)
|
|
419
|
+
throw new Stop("approval", `${held}. It was merged; nothing more is tagged, published or sent to Homebrew until a person lets it go on: \`toolroll release publish-ok ${j.task.id} --as <you> --token <password>\`, then rerun.`);
|
|
378
420
|
};
|
|
379
421
|
/** identity(), on a plane that keeps release authority; a legacy plane keeps the old rules. */
|
|
380
422
|
const watch = async (action) => { if (rel !== undefined)
|
|
@@ -409,6 +451,7 @@ export async function runRelease(options, adapters) {
|
|
|
409
451
|
j.check = null;
|
|
410
452
|
j.completion = null;
|
|
411
453
|
j.authority = null;
|
|
454
|
+
j.auto = null;
|
|
412
455
|
epoch = null;
|
|
413
456
|
delete j.intents.gate;
|
|
414
457
|
delete j.intents.check;
|
|
@@ -417,6 +460,18 @@ export async function runRelease(options, adapters) {
|
|
|
417
460
|
j.stopped = null;
|
|
418
461
|
save();
|
|
419
462
|
};
|
|
463
|
+
/**
|
|
464
|
+
* The automatic road ended without an approval: the plane takes its `auto-check` back (the task asks a person, its
|
|
465
|
+
* passing check kept) and tells the owner; the release waits at approval for a person, as without the setting.
|
|
466
|
+
*/
|
|
467
|
+
const autoRefused = async (why) => {
|
|
468
|
+
j.auto = { state: "refused", why };
|
|
469
|
+
j.check = null;
|
|
470
|
+
j.step = "approval";
|
|
471
|
+
save();
|
|
472
|
+
await rel?.auto?.giveUp(j.task.id, why);
|
|
473
|
+
throw new Stop("approval", `Not approved automatically: ${why}. A person approves task ${j.task.id} (\`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or the console), then rerun \`${resume}\`.`);
|
|
474
|
+
};
|
|
420
475
|
/**
|
|
421
476
|
* The task is approved for `action`: a release task (a carried commit, or any task once the release has an
|
|
422
477
|
* authority) only when the live-release predicate applies to it AND accepts it — fail closed, whatever its own seal
|
|
@@ -425,6 +480,13 @@ export async function runRelease(options, adapters) {
|
|
|
425
480
|
const approved = async (gate, action = "dispatch") => {
|
|
426
481
|
if (gate.candidate !== j.candidate.sha || gate.scopeDigest !== j.task.scopeDigest)
|
|
427
482
|
throw new Stop("scope-changed", `Task ${j.task.id}'s candidate or scope changed after approval.`);
|
|
483
|
+
// Checking automatically: the plane's `auto-check` seal must still stand (the setting on, its evidence, this digest).
|
|
484
|
+
if (j.auto?.state === "checking" && !j.authority) {
|
|
485
|
+
const standing = rel?.auto === undefined ? { ok: false, why: "this plane can't approve releases automatically" } : await rel.auto.checking(j.task.id);
|
|
486
|
+
if (standing.ok)
|
|
487
|
+
return;
|
|
488
|
+
return await autoRefused(standing.why);
|
|
489
|
+
}
|
|
428
490
|
if (rel !== undefined && (j.authority || gate.approval?.basis === "carried")) {
|
|
429
491
|
const live = await rel.live(j.task.id, action);
|
|
430
492
|
if (live.applies && live.ok)
|
|
@@ -502,6 +564,29 @@ export async function runRelease(options, adapters) {
|
|
|
502
564
|
break;
|
|
503
565
|
}
|
|
504
566
|
case "approval": {
|
|
567
|
+
// Approve automatically when checks pass (auto-approve-green.md): with the project's setting on, the plane seals
|
|
568
|
+
// `auto-check` so the release check runs first; the approval itself is sealed after it passed. A fresh task
|
|
569
|
+
// after a CI failure after the deploy, or an automatic refusal, always asks a person.
|
|
570
|
+
const auto = rel?.auto;
|
|
571
|
+
if (auto !== undefined && j.freshTask !== true && j.auto?.state !== "refused" && j.task.scopeDigest !== null && await auto.setting(j.task.id)) {
|
|
572
|
+
await watch("dispatch");
|
|
573
|
+
const gate = await adapters.toolroll.gate(j.task.id);
|
|
574
|
+
if (gate.scopeDigest !== j.task.scopeDigest)
|
|
575
|
+
throw new Stop("scope-changed", `The scope of ${j.task.id} changed after it was filed; the release only runs on the scope it filed.`);
|
|
576
|
+
if (!ownerApproval(gate, j.task.scopeDigest).ok) {
|
|
577
|
+
const authorized = await auto.authorizeCheck({ taskId: j.task.id, digest: j.task.scopeDigest, candidate: j.candidate.sha });
|
|
578
|
+
if (authorized.ok) {
|
|
579
|
+
j.auto = { state: "checking" };
|
|
580
|
+
say(` Approving automatically once CI and the release check pass on ${j.candidate.sha.slice(0, 7)} (this project's release approval setting)`);
|
|
581
|
+
advance("check");
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
j.auto = { state: "refused", why: authorized.why };
|
|
585
|
+
save();
|
|
586
|
+
await auto.giveUp(j.task.id, authorized.why);
|
|
587
|
+
say(` Not approved automatically: ${authorized.why}. A person approves it.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
505
590
|
say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
|
|
506
591
|
const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
|
|
507
592
|
await watch("dispatch");
|
|
@@ -530,7 +615,7 @@ export async function runRelease(options, adapters) {
|
|
|
530
615
|
break;
|
|
531
616
|
}
|
|
532
617
|
case "check": {
|
|
533
|
-
const
|
|
618
|
+
const checkProbe = async () => {
|
|
534
619
|
// Before acting on an old candidate's failure: a moved head restarts the release first.
|
|
535
620
|
await watch("dispatch");
|
|
536
621
|
const gate = await adapters.toolroll.gate(j.task.id);
|
|
@@ -539,7 +624,7 @@ export async function runRelease(options, adapters) {
|
|
|
539
624
|
if (gate.pending === true && gate.result?.check !== "passed")
|
|
540
625
|
return null;
|
|
541
626
|
if (gate.failed !== null)
|
|
542
|
-
throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new
|
|
627
|
+
throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`, true);
|
|
543
628
|
if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
|
|
544
629
|
return null;
|
|
545
630
|
// The build records a prepared candidate as a commit of its own (its message names the task): it must hold
|
|
@@ -552,9 +637,42 @@ export async function runRelease(options, adapters) {
|
|
|
552
637
|
if (gate.result.worktree === null)
|
|
553
638
|
throw new Stop("worktree", `Run ${gate.result.run} names no checkout to deploy from.`);
|
|
554
639
|
return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree, head };
|
|
640
|
+
};
|
|
641
|
+
const result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
642
|
+
try {
|
|
643
|
+
return await checkProbe();
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
// A failed check (or a changed candidate) ends the automatic road: the owner is told, and a person decides.
|
|
647
|
+
// A failed check is told, and stays checkable again on the same commit (a flake); anything else ends the road.
|
|
648
|
+
if (error instanceof Stop && j.auto?.state === "checking" && error.reason === "check-failed" && !error.build)
|
|
649
|
+
await rel?.auto?.checkFailed(j.task.id, error.message.replace(/\.$/, ""));
|
|
650
|
+
else if (error instanceof Stop && j.auto?.state === "checking" && error.reason !== "approval")
|
|
651
|
+
await rel?.auto?.giveUp(j.task.id, error.message.replace(/\.$/, ""));
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
555
654
|
});
|
|
556
655
|
j.check = result;
|
|
557
656
|
say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
657
|
+
if (j.auto?.state === "checking" && !j.authority) {
|
|
658
|
+
// The release approval, sealed only now: the plane reads CI on the exact commit itself and proves the check.
|
|
659
|
+
const sealed = rel?.auto === undefined ? { ok: false, why: "this plane can't approve releases automatically" }
|
|
660
|
+
: await rel.auto.approve({ taskId: j.task.id, digest: j.task.scopeDigest, candidate: j.candidate.sha, run: result.run, receipt: result.receipt });
|
|
661
|
+
if (!sealed.ok)
|
|
662
|
+
await autoRefused(sealed.why);
|
|
663
|
+
else {
|
|
664
|
+
j.auto = { state: "approved", ci: sealed.ci.map(one => one.id) };
|
|
665
|
+
j.approval = { by: `${sealed.by} (automatic)`, at: sealed.at };
|
|
666
|
+
const view = await rel.authority(j.task.id);
|
|
667
|
+
if (view === null)
|
|
668
|
+
throw new Stop("release", `The automatic approval of ${j.task.id} opened no release record; a person approves it.`);
|
|
669
|
+
j.authority = { id: view.id, generation: view.generation };
|
|
670
|
+
j.landed = view.landed;
|
|
671
|
+
save();
|
|
672
|
+
await hold();
|
|
673
|
+
say(` Approved automatically: CI and the release check passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
558
676
|
advance("complete");
|
|
559
677
|
break;
|
|
560
678
|
}
|
|
@@ -597,6 +715,8 @@ export async function runRelease(options, adapters) {
|
|
|
597
715
|
const authority = await rel.authority(j.task.id);
|
|
598
716
|
if (authority !== null && authority.state === "open") {
|
|
599
717
|
await watch("deploy");
|
|
718
|
+
// Every deploy reservation, whatever the journal saved: CI must still be green on the exact commit.
|
|
719
|
+
await autoCiHolds("the deploy");
|
|
600
720
|
const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
601
721
|
if (!reserved.ok)
|
|
602
722
|
throw new Stop("release", `The deploy couldn't be reserved: ${reserved.why}`);
|
|
@@ -609,6 +729,8 @@ export async function runRelease(options, adapters) {
|
|
|
609
729
|
// The last moment a moved head restarts; then the deploy reservation freezes the candidate in the store.
|
|
610
730
|
await watch("deploy");
|
|
611
731
|
await pinned();
|
|
732
|
+
// Every deploy issued, whatever the journal saved (a retry after a rollback included): CI still green.
|
|
733
|
+
await autoCiHolds("the deploy");
|
|
612
734
|
let operation, secret = "";
|
|
613
735
|
if (rel !== undefined && j.authority && epoch !== null) {
|
|
614
736
|
const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
@@ -648,6 +770,7 @@ export async function runRelease(options, adapters) {
|
|
|
648
770
|
}
|
|
649
771
|
case "link": {
|
|
650
772
|
const runtime = j.deploy.runtime;
|
|
773
|
+
await autoCiHolds("the CLI names move");
|
|
651
774
|
if (await served(adapters, builtHead()) !== runtime)
|
|
652
775
|
throw new Stop("drift", `The console and worker no longer run ${runtime}; the CLI names were not moved.`);
|
|
653
776
|
if (j.links === undefined || j.links === null) {
|
|
@@ -670,7 +793,7 @@ export async function runRelease(options, adapters) {
|
|
|
670
793
|
case "ci": {
|
|
671
794
|
await waitFor("ci", `the pull request checks on ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
672
795
|
await pinned();
|
|
673
|
-
const verdict =
|
|
796
|
+
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, autoCap());
|
|
674
797
|
if (verdict.state === "failed")
|
|
675
798
|
await ciFailedAfterDeploy(`Pull request checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}`);
|
|
676
799
|
return verdict.state === "passed" ? true : null;
|
|
@@ -699,7 +822,7 @@ export async function runRelease(options, adapters) {
|
|
|
699
822
|
if (!await adapters.repo.contains(j.candidate.sha, main))
|
|
700
823
|
throw new Stop("base-moved", `main moved to ${main.slice(0, 7)}, which ${j.branch} doesn't contain, so the merge wouldn't be the gated tree. Nothing was merged: update the branch and release the new commit with --new.`);
|
|
701
824
|
// The checks again, at the moment of merging: a rerun that failed since must not merge.
|
|
702
|
-
const verdict =
|
|
825
|
+
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, autoCap());
|
|
703
826
|
if (verdict.state !== "passed")
|
|
704
827
|
await ciFailedAfterDeploy(`Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}`);
|
|
705
828
|
let operation;
|
|
@@ -747,10 +870,12 @@ export async function runRelease(options, adapters) {
|
|
|
747
870
|
}
|
|
748
871
|
case "tag": {
|
|
749
872
|
const merge = j.merge;
|
|
873
|
+
await publishHold();
|
|
750
874
|
const at = await adapters.github.tag(j.github, j.tag);
|
|
751
875
|
if (at !== null && at !== merge.sha)
|
|
752
876
|
throw new Stop("conflict", `${j.tag} already points at ${at.slice(0, 7)}, not the merge ${merge.sha.slice(0, 7)}.`);
|
|
753
877
|
if (at === null) {
|
|
878
|
+
await autoCiHolds("the tag");
|
|
754
879
|
intend("tag");
|
|
755
880
|
await adapters.github.createTag(j.github, j.tag, merge.sha);
|
|
756
881
|
}
|
|
@@ -759,6 +884,7 @@ export async function runRelease(options, adapters) {
|
|
|
759
884
|
break;
|
|
760
885
|
}
|
|
761
886
|
case "publish": {
|
|
887
|
+
await publishHold();
|
|
762
888
|
if (j.intents.publish === undefined)
|
|
763
889
|
intend("publish");
|
|
764
890
|
await waitFor("publish", `npm, the GitHub release and the tag's checks for ${j.tag}`, async () => {
|
|
@@ -776,6 +902,7 @@ export async function runRelease(options, adapters) {
|
|
|
776
902
|
break;
|
|
777
903
|
}
|
|
778
904
|
case "homebrew": {
|
|
905
|
+
await publishHold();
|
|
779
906
|
const brew = adapters.homebrew;
|
|
780
907
|
let state = await brew.state(j.packageVersion);
|
|
781
908
|
if (state.kind === "missing") {
|
|
@@ -825,6 +952,12 @@ export async function runRelease(options, adapters) {
|
|
|
825
952
|
}
|
|
826
953
|
}
|
|
827
954
|
say(`Released ${j.tag}: deployed, merged as ${j.merge.sha.slice(0, 7)}, tagged, published and on Homebrew.`);
|
|
955
|
+
if (j.auto?.state === "approved") {
|
|
956
|
+
try {
|
|
957
|
+
await rel?.auto?.released(j.task.id, j.tag);
|
|
958
|
+
}
|
|
959
|
+
catch { /* the release is done; the note is best effort */ }
|
|
960
|
+
}
|
|
828
961
|
for (const line of landedWords(j.landed ?? []))
|
|
829
962
|
say(line);
|
|
830
963
|
return { ok: true, journal: j, file };
|
|
@@ -930,7 +1063,7 @@ async function earlyChecks(adapters, j, wait) {
|
|
|
930
1063
|
unread = true;
|
|
931
1064
|
return null;
|
|
932
1065
|
}
|
|
933
|
-
const verdict =
|
|
1066
|
+
const verdict = prChecksVerdict(runs.filter(run => required.includes(run.name)), j.candidate.sha, required);
|
|
934
1067
|
if (verdict.state === "failed") {
|
|
935
1068
|
throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}. Nothing was filed or sent for approval. Fix the branch, then release the new commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\` (or rerun the failed job on GitHub and run this command again).`);
|
|
936
1069
|
}
|
|
@@ -962,3 +1095,90 @@ export function transientNetworkError(error) {
|
|
|
962
1095
|
function networkWords(error) {
|
|
963
1096
|
return TRANSIENT_NETWORK.exec(String(error?.message ?? error))?.[0] ?? "a network failure";
|
|
964
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
|
|
1100
|
+
* each reported attempt must be filed under `head` with a check-run id; a required check counts only from GitHub
|
|
1101
|
+
* Actions (an app that names a check alike can't stand in for it); attempts are grouped by the job they belong to — the
|
|
1102
|
+
* app, its check suite (a workflow run, whose re-runs stay in it) and the name — and every group is judged by its
|
|
1103
|
+
* latest attempt; every required check needs a group. With `cap`, a group whose earlier attempts failed more than `cap`
|
|
1104
|
+
* times fails too (an automatic approval's flake allowance); the failures it tolerated are returned.
|
|
1105
|
+
*/
|
|
1106
|
+
export function prChecksVerdict(runs, head, required, options = {}) {
|
|
1107
|
+
const failed = [], waiting = [], failures = [];
|
|
1108
|
+
const valid = [];
|
|
1109
|
+
for (const run of runs) {
|
|
1110
|
+
if (run.head !== head) {
|
|
1111
|
+
failed.push(`${run.name} (reported for ${run.head == null ? "no commit" : run.head.slice(0, 7)}, not ${head.slice(0, 7)})`);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (typeof run.id !== "number" || !Number.isSafeInteger(run.id) || run.id <= 0) {
|
|
1115
|
+
failed.push(`${run.name} (no check run id)`);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (required.includes(run.name) && run.app !== "github-actions") {
|
|
1119
|
+
failed.push(`${run.name} (reported by ${run.app ?? "an unknown app"}, not GitHub Actions)`);
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
if (run.app === "github-actions" && (typeof run.suite !== "number" || !Number.isSafeInteger(run.suite))) {
|
|
1123
|
+
failed.push(`${run.name} (no workflow run)`);
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
valid.push(run);
|
|
1127
|
+
}
|
|
1128
|
+
// Supersession by trusted workflow identity (GitHub's workflow-run API, read by the adapter): a newer run of the same
|
|
1129
|
+
// workflow on this commit replaces the older ones — its jobs are the current ones. A suite whose workflow isn't known
|
|
1130
|
+
// is its own workflow (nothing supersedes it), and another app's suites stand on their own.
|
|
1131
|
+
const workflowOf = (run) => run.app === "github-actions" && typeof run.workflow === "number" ? `w:${run.workflow}` : `s:${run.app ?? ""}:${run.suite ?? ""}`;
|
|
1132
|
+
const current = new Map();
|
|
1133
|
+
for (const run of valid) {
|
|
1134
|
+
const key = workflowOf(run), suite = run.suite ?? null;
|
|
1135
|
+
const was = current.get(key);
|
|
1136
|
+
if (was === undefined || (suite !== null && (was === null || suite > was)))
|
|
1137
|
+
current.set(key, suite);
|
|
1138
|
+
}
|
|
1139
|
+
// Jobs: a workflow and a name, every attempt in every suite of it; judged by the latest attempt in the current suite.
|
|
1140
|
+
const groups = new Map();
|
|
1141
|
+
for (const run of valid) {
|
|
1142
|
+
const key = JSON.stringify([workflowOf(run), run.name]);
|
|
1143
|
+
groups.set(key, [...(groups.get(key) ?? []), run]);
|
|
1144
|
+
}
|
|
1145
|
+
const judged = new Set();
|
|
1146
|
+
for (const attempts of groups.values()) {
|
|
1147
|
+
const ordered = [...attempts].sort((a, b) => a.id - b.id);
|
|
1148
|
+
const now = current.get(workflowOf(ordered[0])) ?? null;
|
|
1149
|
+
const live = ordered.filter(one => (one.suite ?? null) === now);
|
|
1150
|
+
// A job only an older run had (the newer run no longer has it) is history: not judged, and doesn't stand for a
|
|
1151
|
+
// required check.
|
|
1152
|
+
if (live.length === 0)
|
|
1153
|
+
continue;
|
|
1154
|
+
const latest = live.at(-1), name = latest.name;
|
|
1155
|
+
judged.add(name);
|
|
1156
|
+
if (latest.status !== "completed") {
|
|
1157
|
+
waiting.push(`${name} (${latest.status})`);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (latest.conclusion !== "success") {
|
|
1161
|
+
failed.push(`${name} (${latest.conclusion ?? "no conclusion"})`);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
const earlier = ordered.filter(one => one !== latest);
|
|
1165
|
+
// An automatic approval also waits out an earlier attempt of the current run still running (it counts toward the cap).
|
|
1166
|
+
if (options.cap !== undefined && earlier.some(one => (one.suite ?? null) === now && one.status !== "completed")) {
|
|
1167
|
+
waiting.push(`${name} (an earlier attempt still running)`);
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
// The cap counts every failed attempt of the job, in the current run and in the runs it superseded.
|
|
1171
|
+
const flaked = earlier.filter(one => one.status === "completed" && one.conclusion !== "success");
|
|
1172
|
+
if (options.cap !== undefined && flaked.length > options.cap) {
|
|
1173
|
+
failed.push(`${name} (failed ${flaked.length} times before it passed)`);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
failures.push(...flaked.map(one => ({ id: one.id, name, conclusion: one.conclusion })));
|
|
1177
|
+
}
|
|
1178
|
+
for (const name of required)
|
|
1179
|
+
if (!judged.has(name))
|
|
1180
|
+
waiting.push(`${name} (not started)`);
|
|
1181
|
+
if (failed.length > 0)
|
|
1182
|
+
return { state: "failed", failed };
|
|
1183
|
+
return waiting.length > 0 ? { state: "pending", waiting } : { state: "passed", failures };
|
|
1184
|
+
}
|