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.
Files changed (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/batch-checks.js +3 -1
  7. package/dist/builder.js +1 -1
  8. package/dist/coding-workspace.js +7 -5
  9. package/dist/contracts/console-api.d.ts +1 -0
  10. package/dist/contracts/console-api.js +1 -0
  11. package/dist/deploy-gate.d.ts +3 -0
  12. package/dist/deploy-gate.js +3 -2
  13. package/dist/desktop-update.d.ts +1 -1
  14. package/dist/discover.js +6 -1
  15. package/dist/first-run.js +3 -1
  16. package/dist/git-incident.d.ts +49 -0
  17. package/dist/git-incident.js +69 -0
  18. package/dist/git-views.d.ts +20 -0
  19. package/dist/git-views.js +120 -0
  20. package/dist/grant.js +3 -1
  21. package/dist/operate.js +7 -1
  22. package/dist/planner.js +5 -1
  23. package/dist/project-knowledge.js +1 -1
  24. package/dist/project-learning.js +22 -2
  25. package/dist/pull-request-flow.js +4 -0
  26. package/dist/release-adapters.d.ts +24 -2
  27. package/dist/release-adapters.js +136 -10
  28. package/dist/release-authority.d.ts +46 -0
  29. package/dist/release-authority.js +64 -0
  30. package/dist/release-auto.d.ts +153 -0
  31. package/dist/release-auto.js +496 -0
  32. package/dist/release-cli.js +56 -2
  33. package/dist/release-coverage.js +1 -1
  34. package/dist/release.d.ts +105 -1
  35. package/dist/release.js +229 -9
  36. package/dist/repository-context.js +4 -6
  37. package/dist/result-follow-ups.js +3 -1
  38. package/dist/scope.d.ts +31 -2
  39. package/dist/scope.js +44 -3
  40. package/dist/scout.js +5 -1
  41. package/dist/server/render-tasks.d.ts +10 -0
  42. package/dist/server/render-tasks.js +23 -13
  43. package/dist/server/route-table.js +3 -0
  44. package/dist/server/settings.js +29 -2
  45. package/dist/server/task-views.js +8 -0
  46. package/dist/store.d.ts +1 -1
  47. package/dist/store.js +19 -4
  48. package/dist/task-base.js +1 -1
  49. package/dist/teardown-git.d.ts +38 -10
  50. package/dist/teardown-git.js +894 -108
  51. package/dist/test-infra.d.ts +26 -0
  52. package/dist/test-infra.js +321 -0
  53. package/dist/worktree.d.ts +4 -0
  54. package/dist/worktree.js +13 -7
  55. package/package.json +1 -1
@@ -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);
@@ -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
- constructor(reason, message) {
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", `${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\`.`);
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 result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
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 = checksVerdict(await adapters.github.checks(j.github, j.candidate.sha), REQUIRED_PR_CHECKS);
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 = checksVerdict(await adapters.github.checks(j.github, j.candidate.sha), REQUIRED_PR_CHECKS);
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 = checksVerdict(runs.filter(run => required.includes(run.name)), required);
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
+ }