toolroll 0.9.71 → 0.9.72

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 (93) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/agent-fence.d.ts +5 -2
  3. package/dist/agent-fence.js +5 -2
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/builder.js +48 -6
  9. package/dist/chat-decide.js +17 -2
  10. package/dist/check-access.js +2 -2
  11. package/dist/check-report.d.ts +109 -0
  12. package/dist/check-report.js +204 -0
  13. package/dist/claim.js +1 -1
  14. package/dist/commit-extract.d.ts +94 -0
  15. package/dist/commit-extract.js +436 -0
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -0
  18. package/dist/contracts/flow.d.ts +16 -0
  19. package/dist/contracts/flow.js +3 -0
  20. package/dist/contracts/release-check.d.ts +335 -0
  21. package/dist/contracts/release-check.js +159 -0
  22. package/dist/contracts/release-terms.d.ts +60 -0
  23. package/dist/contracts/release-terms.js +70 -0
  24. package/dist/contracts/verification-receipt.d.ts +42 -8
  25. package/dist/contracts/verification-receipt.js +24 -7
  26. package/dist/contracts/verification-view.d.ts +1 -0
  27. package/dist/deploy-gate.d.ts +17 -0
  28. package/dist/deploy-gate.js +68 -0
  29. package/dist/dispatch.js +3 -10
  30. package/dist/dispose.js +4 -1
  31. package/dist/evidence-pack.d.ts +1 -1
  32. package/dist/evidence-pack.js +20 -2
  33. package/dist/evidence.d.ts +1 -0
  34. package/dist/evidence.js +2 -1
  35. package/dist/flow-engine.js +5 -1
  36. package/dist/flow-pull-request.d.ts +5 -2
  37. package/dist/flow-pull-request.js +14 -5
  38. package/dist/flow-schedule.js +25 -6
  39. package/dist/flows-ui.js +7 -1
  40. package/dist/invoke.d.ts +17 -6
  41. package/dist/invoke.js +48 -18
  42. package/dist/lead-status.js +5 -2
  43. package/dist/live-terminal.d.ts +10 -6
  44. package/dist/live-terminal.js +55 -14
  45. package/dist/nightly-check.d.ts +104 -0
  46. package/dist/nightly-check.js +362 -0
  47. package/dist/operate.js +39 -2
  48. package/dist/person-proof.d.ts +2 -0
  49. package/dist/person-proof.js +14 -0
  50. package/dist/plan-auto.js +4 -4
  51. package/dist/planner-source.d.ts +1 -1
  52. package/dist/project-delete-ui.js +1 -1
  53. package/dist/proof.js +2 -0
  54. package/dist/publish.js +156 -144
  55. package/dist/pull-request-flow.d.ts +2 -0
  56. package/dist/pull-request-flow.js +24 -2
  57. package/dist/release-adapters.d.ts +8 -0
  58. package/dist/release-adapters.js +212 -15
  59. package/dist/release-authority.d.ts +112 -0
  60. package/dist/release-authority.js +126 -0
  61. package/dist/release-carry.d.ts +233 -0
  62. package/dist/release-carry.js +645 -0
  63. package/dist/release-cli.d.ts +3 -0
  64. package/dist/release-cli.js +124 -5
  65. package/dist/release-coverage.js +3 -0
  66. package/dist/release-recheck.d.ts +146 -0
  67. package/dist/release-recheck.js +657 -0
  68. package/dist/release-view.d.ts +34 -0
  69. package/dist/release-view.js +27 -0
  70. package/dist/release.d.ts +131 -1
  71. package/dist/release.js +562 -244
  72. package/dist/remote-main.d.ts +2 -0
  73. package/dist/remote-main.js +1 -1
  74. package/dist/result-completion.d.ts +6 -0
  75. package/dist/result-completion.js +15 -1
  76. package/dist/result-follow-ups.js +29 -1
  77. package/dist/sandbox-runtime.d.ts +16 -5
  78. package/dist/sandbox-runtime.js +19 -6
  79. package/dist/scope.d.ts +115 -5
  80. package/dist/scope.js +219 -7
  81. package/dist/server/render-tasks.d.ts +8 -0
  82. package/dist/server/render-tasks.js +50 -9
  83. package/dist/server/route-table.js +1 -0
  84. package/dist/server/task-views.js +4 -0
  85. package/dist/server/tasks.js +12 -0
  86. package/dist/store.d.ts +227 -5
  87. package/dist/store.js +727 -37
  88. package/dist/task-outcome-cli.js +7 -3
  89. package/dist/toolroll-update.js +2 -2
  90. package/dist/verification-evidence.d.ts +25 -1
  91. package/dist/verification-evidence.js +57 -2
  92. package/dist/work-index.js +3 -6
  93. package/package.json +1 -1
package/dist/publish.js CHANGED
@@ -26,6 +26,7 @@ import { tmpdir } from "node:os";
26
26
  import { join } from "node:path";
27
27
  import { run as execRun } from "./exec.js";
28
28
  import { summarizeChecks } from "./pulls.js";
29
+ import { releasePublication } from "./store.js";
29
30
  import { headWithin } from "./names.js";
30
31
  export const BODY_TEMPLATE_VERSION = 2;
31
32
  /** After this many failed attempts a publication stops retrying and pages instead. */
@@ -529,168 +530,179 @@ export async function sweepMerges(store, options) {
529
530
  const method = grant.mergeMethod;
530
531
  const termsHash = mergeTermsHash(grant);
531
532
  for (const publication of store.openedPublications()) {
532
- if (publication.prNumber === null || publication.headSha === null)
533
- continue;
534
- const prNumber = publication.prNumber;
535
- const headSha = publication.headSha;
536
- // The local-repo fence (finding 6): repo B's publication never rides
537
- // repo A's sweep, whatever the remote terms coincide on.
538
- const ref = store.refForId(publication.taskRef);
539
- if (ref === null || ref.repo !== options.repo)
540
- continue;
541
- if (publication.githubRepo !== grant.githubRepo)
542
- continue;
543
- const blocker = store.mergeBlockerFor(publication.id);
544
- if (blocker !== null) {
533
+ // A release's result merges only through `toolroll release` (release-once.md): the sweep never takes it up.
534
+ if (releasePublication(store, publication.id)) {
545
535
  report.skipped++;
546
- pagePerson(publication, "blocked", "PR #" + prNumber + " holds for a repair", "A CI repair (" + (blocker.taskId ?? "?") + ") is in flight. It merges nothing until you lift it: toolroll publish unblock " + prNumber + ".");
547
536
  continue;
548
537
  }
549
- store.createMergeIntent({ publication: publication.id, repo: options.repo, grantTermsHash: termsHash, headSha, method, deleteBranch: grant.mergeDeleteBranch === true }, clock());
550
- const intent = store.mergeIntentFor(publication.id);
551
- if (intent === null || intent.state === "merged" || intent.state === "refused" || intent.state === "superseded")
552
- continue;
553
- // WAITING-HUMAN never fires itself (E1): the person is paged once,
554
- // episodically, with the exact ceremony that releases it.
555
- if (intent.state === "waiting-human") {
556
- report.skipped++;
557
- pagePerson(publication, "waiting-human", "PR #" + prNumber + " waits for your go-ahead", "Merges wait for you while this posture holds. When you want it merged: toolroll publish merge " + prNumber + " --as <you> --token <t>. It still merges only when CI is seen green on the exact head.");
558
- continue;
559
- }
560
- // FIRING is one-shot (E1): a live firing row belongs to its owner.
561
- // A STALE one (deadline passed) recovers by STRUCTURED REMOTE REREAD
562
- // (F1): an observed terminal settles it; an OPEN same-head PR is
563
- // NEVER auto-retried - the person gets the recovery ceremony.
564
- if (intent.state === "firing") {
565
- if (intent.firingDeadline !== null && intent.firingDeadline <= clock().toISOString()) {
566
- const reread = await observeOne(store, exec, clock, grant.githubRepo, prNumber);
567
- if (!reread.ok && reread.why === "pr-merged") {
568
- store.settleMergeIntent(intent.id, intent.generation, "merged", clock(), {
569
- receipt: "observed merged remotely after a stale firing claim", from: "firing", bumpGeneration: true,
570
- });
571
- report.merged++;
538
+ // One bad row never stops the rest of the sweep: every other publication, in every project, still merges.
539
+ try {
540
+ if (publication.prNumber === null || publication.headSha === null)
541
+ continue;
542
+ const prNumber = publication.prNumber;
543
+ const headSha = publication.headSha;
544
+ // The local-repo fence (finding 6): repo B's publication never rides
545
+ // repo A's sweep, whatever the remote terms coincide on.
546
+ const ref = store.refForId(publication.taskRef);
547
+ if (ref === null || ref.repo !== options.repo)
548
+ continue;
549
+ if (publication.githubRepo !== grant.githubRepo)
550
+ continue;
551
+ const blocker = store.mergeBlockerFor(publication.id);
552
+ if (blocker !== null) {
553
+ report.skipped++;
554
+ pagePerson(publication, "blocked", "PR #" + prNumber + " holds for a repair", "A CI repair (" + (blocker.taskId ?? "?") + ") is in flight. It merges nothing until you lift it: toolroll publish unblock " + prNumber + ".");
555
+ continue;
556
+ }
557
+ store.createMergeIntent({ publication: publication.id, repo: options.repo, grantTermsHash: termsHash, headSha, method, deleteBranch: grant.mergeDeleteBranch === true }, clock());
558
+ const intent = store.mergeIntentFor(publication.id);
559
+ if (intent === null || intent.state === "merged" || intent.state === "refused" || intent.state === "superseded")
560
+ continue;
561
+ // WAITING-HUMAN never fires itself (E1): the person is paged once,
562
+ // episodically, with the exact ceremony that releases it.
563
+ if (intent.state === "waiting-human") {
564
+ report.skipped++;
565
+ pagePerson(publication, "waiting-human", "PR #" + prNumber + " waits for your go-ahead", "Merges wait for you while this posture holds. When you want it merged: toolroll publish merge " + prNumber + " --as <you> --token <t>. It still merges only when CI is seen green on the exact head.");
566
+ continue;
567
+ }
568
+ // FIRING is one-shot (E1): a live firing row belongs to its owner.
569
+ // A STALE one (deadline passed) recovers by STRUCTURED REMOTE REREAD
570
+ // (F1): an observed terminal settles it; an OPEN same-head PR is
571
+ // NEVER auto-retried - the person gets the recovery ceremony.
572
+ if (intent.state === "firing") {
573
+ if (intent.firingDeadline !== null && intent.firingDeadline <= clock().toISOString()) {
574
+ const reread = await observeOne(store, exec, clock, grant.githubRepo, prNumber);
575
+ if (!reread.ok && reread.why === "pr-merged") {
576
+ store.settleMergeIntent(intent.id, intent.generation, "merged", clock(), {
577
+ receipt: "observed merged remotely after a stale firing claim", from: "firing", bumpGeneration: true,
578
+ });
579
+ report.merged++;
580
+ }
581
+ else if (!reread.ok && reread.why.startsWith("pr-")) {
582
+ store.settleMergeIntent(intent.id, intent.generation, "superseded", clock(), {
583
+ error: reread.why, from: "firing", bumpGeneration: true,
584
+ });
585
+ }
586
+ else if (reread.ok && reread.headSha !== intent.headSha) {
587
+ store.settleMergeIntent(intent.id, intent.generation, "superseded", clock(), {
588
+ error: "the head moved while the firing claim was stale", from: "firing", bumpGeneration: true,
589
+ });
590
+ }
591
+ else {
592
+ report.skipped++;
593
+ pagePerson(publication, "half-fired", "PR #" + prNumber + " may have half-merged", "A merge was issued and its owner went silent; the PR is still open on the same commit. Nothing retries by itself. If you want it fired again: toolroll publish refire " + prNumber + " --as <you> --token <t>.");
594
+ }
572
595
  }
573
- else if (!reread.ok && reread.why.startsWith("pr-")) {
574
- store.settleMergeIntent(intent.id, intent.generation, "superseded", clock(), {
575
- error: reread.why, from: "firing", bumpGeneration: true,
576
- });
596
+ else {
597
+ report.skipped++;
577
598
  }
578
- else if (reread.ok && reread.headSha !== intent.headSha) {
579
- store.settleMergeIntent(intent.id, intent.generation, "superseded", clock(), {
580
- error: "the head moved while the firing claim was stale", from: "firing", bumpGeneration: true,
581
- });
599
+ continue;
600
+ }
601
+ const claim = store.claimMergeIntent(intent.id, who, MERGE_LEASE_MS, clock());
602
+ if (claim === null)
603
+ continue; // someone else's live claim
604
+ const settle = (state, detail = {}) => store.settleMergeIntent(intent.id, claim.generation, state, clock(), { from: "claimed", ...detail });
605
+ // The live grant, re-read AFTER the claim (finding 7): revocation,
606
+ // narrowing, or any term drift supersedes rather than merges.
607
+ const live = store.publicationGrantFor(options.repo);
608
+ if (live === null || live.merge !== true || mergeTermsHash(live) !== termsHash || intent.headSha !== headSha) {
609
+ settle("superseded", { error: "the grant or the head moved since this intent was written" });
610
+ continue;
611
+ }
612
+ const queue = await proveNoMergeQueue(exec, grant.githubRepo, grant.base);
613
+ if (!queue.ok) {
614
+ settle("refused", { error: queue.why });
615
+ report.refused++;
616
+ pagePerson(publication, queue.why, "PR #" + prNumber + " will not auto-merge", queue.why === "merge-queue"
617
+ ? "The base branch requires a merge queue - out of this release's scope. Merge it on GitHub, or lift the queue and run: toolroll publish rearm " + prNumber + "."
618
+ : "The branch's protection could not be READ well enough to prove no merge queue governs it - auto-merge stays paused. Check access, then run: toolroll publish rearm " + prNumber + ".");
619
+ continue;
620
+ }
621
+ // One fresh, winning observation - green on the EXACT publication head.
622
+ const seen = await observeOne(store, exec, clock, grant.githubRepo, prNumber);
623
+ if (!seen.ok) {
624
+ if (seen.why === "draft") {
625
+ settle("refused", { error: "draft" });
626
+ report.refused++;
627
+ pagePerson(publication, "draft", "PR #" + prNumber + " is a draft", "Drafts never merge themselves. Mark it ready on GitHub, then run: toolroll publish rearm " + prNumber + ".");
628
+ }
629
+ else if (seen.why.startsWith("pr-")) {
630
+ settle("superseded", { error: seen.why });
582
631
  }
583
632
  else {
633
+ settle("pending");
634
+ report.problems.push("PR #" + prNumber + ": " + seen.why);
635
+ }
636
+ continue;
637
+ }
638
+ if (seen.headSha !== headSha || seen.state !== "passing") {
639
+ settle(seen.headSha !== headSha ? "superseded" : "pending", {
640
+ error: seen.headSha !== headSha ? "the head moved" : "CI is " + seen.state + ", not green",
641
+ });
642
+ continue;
643
+ }
644
+ // The linearization point (E1): claimed -> firing as a DURABLE CAS
645
+ // re-proving grant terms, head, and basis-specific authority in one
646
+ // transaction. Before it commits, a revocation wins; after, the
647
+ // issued call is one-shot and no revocation demotes it.
648
+ const fired = store.fireMergeIntent(intent.id, claim.generation, { repo: options.repo, headSha, liveGrantTermsHash: mergeTermsHash(live), graceMs: FIRING_GRACE_MS }, clock());
649
+ if (!fired.ok) {
650
+ if (fired.outcome === "waiting-human") {
584
651
  report.skipped++;
585
- pagePerson(publication, "half-fired", "PR #" + prNumber + " may have half-merged", "A merge was issued and its owner went silent; the PR is still open on the same commit. Nothing retries by itself. If you want it fired again: toolroll publish refire " + prNumber + " --as <you> --token <t>.");
652
+ pagePerson(publication, "waiting-human", "PR #" + prNumber + " waits for your go-ahead", fired.why + " - when you want it merged: toolroll publish merge " + prNumber + " --as <you> --token <t>.");
586
653
  }
654
+ continue;
587
655
  }
588
- else {
589
- report.skipped++;
656
+ const merged = await exec("gh", [
657
+ "pr", "merge", String(prNumber),
658
+ "--repo", grant.githubRepo,
659
+ "--" + method,
660
+ "--match-head-commit", headSha,
661
+ ...(grant.mergeDeleteBranch === true ? ["--delete-branch"] : []),
662
+ ], { timeoutMs: EXEC_TIMEOUT_MS });
663
+ if (merged.code === 0) {
664
+ store.transact(() => {
665
+ settle("merged", { receipt: "merged " + headSha.slice(0, 12) + " at " + clock().toISOString(), countAttempt: true, from: "firing" });
666
+ store.recordPublicationRemoteState(publication.id, "MERGED", clock());
667
+ store.resolveCiEpisodes(grant.githubRepo, prNumber, null, clock());
668
+ });
669
+ report.merged++;
670
+ store.resolveEpisodes(`merge-attn:${publication.id}`, clock());
671
+ page(publication, "merge:" + publication.id + ":merged", "merged: PR #" + prNumber, (publication.prUrl ?? grant.githubRepo) + " - " + method + " of " + headSha.slice(0, 12) + ", under the merge terms you approved.");
672
+ continue;
590
673
  }
591
- continue;
592
- }
593
- const claim = store.claimMergeIntent(intent.id, who, MERGE_LEASE_MS, clock());
594
- if (claim === null)
595
- continue; // someone else's live claim
596
- const settle = (state, detail = {}) => store.settleMergeIntent(intent.id, claim.generation, state, clock(), { from: "claimed", ...detail });
597
- // The live grant, re-read AFTER the claim (finding 7): revocation,
598
- // narrowing, or any term drift supersedes rather than merges.
599
- const live = store.publicationGrantFor(options.repo);
600
- if (live === null || live.merge !== true || mergeTermsHash(live) !== termsHash || intent.headSha !== headSha) {
601
- settle("superseded", { error: "the grant or the head moved since this intent was written" });
602
- continue;
603
- }
604
- const queue = await proveNoMergeQueue(exec, grant.githubRepo, grant.base);
605
- if (!queue.ok) {
606
- settle("refused", { error: queue.why });
607
- report.refused++;
608
- pagePerson(publication, queue.why, "PR #" + prNumber + " will not auto-merge", queue.why === "merge-queue"
609
- ? "The base branch requires a merge queue - out of this release's scope. Merge it on GitHub, or lift the queue and run: toolroll publish rearm " + prNumber + "."
610
- : "The branch's protection could not be READ well enough to prove no merge queue governs it - auto-merge stays paused. Check access, then run: toolroll publish rearm " + prNumber + ".");
611
- continue;
612
- }
613
- // One fresh, winning observation - green on the EXACT publication head.
614
- const seen = await observeOne(store, exec, clock, grant.githubRepo, prNumber);
615
- if (!seen.ok) {
616
- if (seen.why === "draft") {
617
- settle("refused", { error: "draft" });
674
+ // NEVER classify from the exit code alone (only auth=4 is distinct):
675
+ // re-read structured state and let the PR say what happened.
676
+ if (merged.code === 4) {
677
+ settle("refused", { error: "credential", countAttempt: true, from: "firing" });
618
678
  report.refused++;
619
- pagePerson(publication, "draft", "PR #" + prNumber + " is a draft", "Drafts never merge themselves. Mark it ready on GitHub, then run: toolroll publish rearm " + prNumber + ".");
679
+ pagePerson(publication, "credential", "PR #" + prNumber + ": gh is not signed in", "The merge acts as this machine's GitHub account, and it could not authenticate. Run gh auth login, then: toolroll publish rearm " + prNumber + ".");
680
+ continue;
620
681
  }
621
- else if (seen.why.startsWith("pr-")) {
622
- settle("superseded", { error: seen.why });
682
+ const after = await observeOne(store, exec, clock, grant.githubRepo, prNumber);
683
+ if (!after.ok && after.why === "pr-merged") {
684
+ settle("merged", { receipt: "merged (confirmed by re-read after an ambiguous exit)", countAttempt: true, from: "firing" });
685
+ report.merged++;
623
686
  }
624
- else {
625
- settle("pending");
626
- report.problems.push("PR #" + prNumber + ": " + seen.why);
687
+ else if (!after.ok && (after.why === "draft" || after.why.startsWith("pr-"))) {
688
+ settle(after.why === "draft" ? "refused" : "superseded", { error: after.why, countAttempt: true, from: "firing" });
689
+ if (after.why === "draft")
690
+ report.refused++;
627
691
  }
628
- continue;
629
- }
630
- if (seen.headSha !== headSha || seen.state !== "passing") {
631
- settle(seen.headSha !== headSha ? "superseded" : "pending", {
632
- error: seen.headSha !== headSha ? "the head moved" : "CI is " + seen.state + ", not green",
633
- });
634
- continue;
635
- }
636
- // The linearization point (E1): claimed -> firing as a DURABLE CAS
637
- // re-proving grant terms, head, and basis-specific authority in one
638
- // transaction. Before it commits, a revocation wins; after, the
639
- // issued call is one-shot and no revocation demotes it.
640
- const fired = store.fireMergeIntent(intent.id, claim.generation, { repo: options.repo, headSha, liveGrantTermsHash: mergeTermsHash(live), graceMs: FIRING_GRACE_MS }, clock());
641
- if (!fired.ok) {
642
- if (fired.outcome === "waiting-human") {
643
- report.skipped++;
644
- pagePerson(publication, "waiting-human", "PR #" + prNumber + " waits for your go-ahead", fired.why + " - when you want it merged: toolroll publish merge " + prNumber + " --as <you> --token <t>.");
692
+ else if (after.ok && after.headSha !== headSha) {
693
+ settle("superseded", { error: "the head moved during the attempt", countAttempt: true, from: "firing" });
645
694
  }
646
- continue;
647
- }
648
- const merged = await exec("gh", [
649
- "pr", "merge", String(prNumber),
650
- "--repo", grant.githubRepo,
651
- "--" + method,
652
- "--match-head-commit", headSha,
653
- ...(grant.mergeDeleteBranch === true ? ["--delete-branch"] : []),
654
- ], { timeoutMs: EXEC_TIMEOUT_MS });
655
- if (merged.code === 0) {
656
- store.transact(() => {
657
- settle("merged", { receipt: "merged " + headSha.slice(0, 12) + " at " + clock().toISOString(), countAttempt: true, from: "firing" });
658
- store.recordPublicationRemoteState(publication.id, "MERGED", clock());
659
- store.resolveCiEpisodes(grant.githubRepo, prNumber, null, clock());
660
- });
661
- report.merged++;
662
- store.resolveEpisodes(`merge-attn:${publication.id}`, clock());
663
- page(publication, "merge:" + publication.id + ":merged", "merged: PR #" + prNumber, (publication.prUrl ?? grant.githubRepo) + " - " + method + " of " + headSha.slice(0, 12) + ", under the merge terms you approved.");
664
- continue;
665
- }
666
- // NEVER classify from the exit code alone (only auth=4 is distinct):
667
- // re-read structured state and let the PR say what happened.
668
- if (merged.code === 4) {
669
- settle("refused", { error: "credential", countAttempt: true, from: "firing" });
670
- report.refused++;
671
- pagePerson(publication, "credential", "PR #" + prNumber + ": gh is not signed in", "The merge acts as this machine's GitHub account, and it could not authenticate. Run gh auth login, then: toolroll publish rearm " + prNumber + ".");
672
- continue;
673
- }
674
- const after = await observeOne(store, exec, clock, grant.githubRepo, prNumber);
675
- if (!after.ok && after.why === "pr-merged") {
676
- settle("merged", { receipt: "merged (confirmed by re-read after an ambiguous exit)", countAttempt: true, from: "firing" });
677
- report.merged++;
678
- }
679
- else if (!after.ok && (after.why === "draft" || after.why.startsWith("pr-"))) {
680
- settle(after.why === "draft" ? "refused" : "superseded", { error: after.why, countAttempt: true, from: "firing" });
681
- if (after.why === "draft")
695
+ else if (intent.attempts + 1 >= MERGE_MAX_ATTEMPTS) {
696
+ settle("refused", { error: firstLine(merged.stderr) || "the merge was rejected", countAttempt: true, from: "firing" });
682
697
  report.refused++;
698
+ pagePerson(publication, "rejected", "PR #" + prNumber + " would not merge", (firstLine(merged.stderr) || "GitHub rejected the merge") + " - likely branch protection, required reviews, or a conflict. Fix the cause, then run: toolroll publish rearm " + prNumber + ".");
699
+ }
700
+ else {
701
+ settle("pending", { error: firstLine(merged.stderr) || "transport", countAttempt: true, from: "firing" });
702
+ }
683
703
  }
684
- else if (after.ok && after.headSha !== headSha) {
685
- settle("superseded", { error: "the head moved during the attempt", countAttempt: true, from: "firing" });
686
- }
687
- else if (intent.attempts + 1 >= MERGE_MAX_ATTEMPTS) {
688
- settle("refused", { error: firstLine(merged.stderr) || "the merge was rejected", countAttempt: true, from: "firing" });
689
- report.refused++;
690
- pagePerson(publication, "rejected", "PR #" + prNumber + " would not merge", (firstLine(merged.stderr) || "GitHub rejected the merge") + " - likely branch protection, required reviews, or a conflict. Fix the cause, then run: toolroll publish rearm " + prNumber + ".");
691
- }
692
- else {
693
- settle("pending", { error: firstLine(merged.stderr) || "transport", countAttempt: true, from: "firing" });
704
+ catch (error) {
705
+ report.problems.push("publication " + publication.id + ": " + (error instanceof Error ? error.message : String(error)));
694
706
  }
695
707
  }
696
708
  return report;
@@ -180,4 +180,6 @@ export type PullRequestView = {
180
180
  export declare function pullRequestViewOf(store: Store, runId: number): PullRequestView | null;
181
181
  /** The newest pull request any version of a task opened through Complete (versions oldest first), or null. */
182
182
  export declare function newestPullRequestOf(store: Store, versions: readonly string[]): PullRequestView | null;
183
+ /** Why a machine credential's merge is refused (approval policy §1.7). */
184
+ export declare const MERGE_NEEDS_PERSON = "Only a person can merge. Merge it on the task page or from your paired chat.";
183
185
  export {};
@@ -21,8 +21,10 @@ import { failingChecks, summarizeChecks } from "./pulls.js";
21
21
  import { assignmentOf, checkAssignmentAsOperator } from "./assignment.js";
22
22
  import { requestResultChanges } from "./result-actions.js";
23
23
  import { revisionSourceOf } from "./result-review.js";
24
- import { authenticateApprover } from "./scope.js";
24
+ import { authenticateApprover, personProof } from "./scope.js";
25
25
  import { BRANCH_PREFIX, headWithin } from "./names.js";
26
+ import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
27
+ import { belongsToRelease } from "./release-authority.js";
26
28
  import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
27
29
  export const MERGE_METHODS = ["squash", "merge", "rebase"];
28
30
  /** CI revisions one task may file across its revisions before a person is asked instead. */
@@ -143,6 +145,10 @@ export function pullRequestBlocker(store, runId) {
143
145
  return "Pull requests aren't set up for this project.";
144
146
  if (run.headRevision === null || run.committed === false)
145
147
  return "This result has no commit to publish.";
148
+ if (nightlyTaskRef(store, run.taskRef))
149
+ return NIGHTLY_CHECK_ONLY;
150
+ if (releaseTaskRef(store, run.taskRef))
151
+ return RELEASE_MERGES_ONLY;
146
152
  if (run.branch === null || !headWithin(run.branch, grant.headPrefix))
147
153
  return "This result's branch isn't one Toolroll may push.";
148
154
  if (store.hasRedactedTerminalDiff(runId))
@@ -370,8 +376,13 @@ export async function mergeAsPerson(store, input) {
370
376
  const repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
371
377
  if (input.password === "")
372
378
  return refuse("password", "Enter your password to merge.");
373
- if (!authenticateApprover(store, input.name, input.password, repo).ok)
379
+ const proved = authenticateApprover(store, input.name, input.password, repo);
380
+ if (!proved.ok)
374
381
  return refuse("password", "That password didn't work, or you can't merge in this project.");
382
+ // A merge by hand is a person's (approval policy §1.7): the lead's token or a remote API token proves a credential,
383
+ // not a person — `--anyway` included.
384
+ if (!personProof(proved.method))
385
+ return refuse("person-required", MERGE_NEEDS_PERSON);
375
386
  return mergePullRequest(store, { runId: input.runId, by: input.name, ...(input.anyway === true ? { anyway: true } : {}), ...(input.exec === undefined ? {} : { exec: input.exec }), ...(input.clock === undefined ? {} : { clock: input.clock }) });
376
387
  }
377
388
  /**
@@ -382,6 +393,15 @@ export async function mergeAsPerson(store, input) {
382
393
  export async function mergePullRequest(store, input) {
383
394
  const exec = input.exec ?? execRun;
384
395
  const clock = input.clock ?? (() => new Date());
396
+ // The nightly check of main (release-once.md, part 3) is a check only: it never merges.
397
+ const mergedRun = store.getRun(input.runId);
398
+ const mergedTask = mergedRun === null ? null : store.refById(mergedRun.taskRef)?.externalId ?? null;
399
+ if (mergedTask !== null && store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(mergedTask) !== undefined)
400
+ return refuse("check-only", "The nightly check of main is only a check; it never merges.");
401
+ // A release's task merges only through `toolroll release` (release-once.md), which proves the live release, reserves
402
+ // the merge at its lease's epoch and checks the exact pull request: never here, by hand or automatically.
403
+ if (mergedTask !== null && belongsToRelease(store, mergedTask))
404
+ return refuse("release", "This result belongs to a release; it merges only through `toolroll release`.");
385
405
  const publication = store.publicationForRun(input.runId);
386
406
  const follow = publication === null ? null : followOf(store, publication.id);
387
407
  if (publication === null || follow === null || publication.prNumber === null)
@@ -505,3 +525,5 @@ export function newestPullRequestOf(store, versions) {
505
525
  }
506
526
  return null;
507
527
  }
528
+ /** Why a machine credential's merge is refused (approval policy §1.7). */
529
+ export const MERGE_NEEDS_PERSON = "Only a person can merge. Merge it on the task page or from your paired chat.";
@@ -5,10 +5,13 @@
5
5
  * the sign-ins this machine already has.
6
6
  */
7
7
  import type { GateState, Installed, ReleaseAdapters } from "./release.js";
8
+ import { Store } from "./store.js";
9
+ import { type BuildRunner } from "./release-carry.js";
8
10
  export type Exec = (command: string, args: readonly string[], options?: {
9
11
  cwd?: string;
10
12
  timeoutMs?: number;
11
13
  env?: NodeJS.ProcessEnv;
14
+ input?: string;
12
15
  }) => Promise<{
13
16
  code: number;
14
17
  stdout: string;
@@ -66,4 +69,9 @@ export declare function releaseAdapters(options: AdapterOptions): ReleaseAdapter
66
69
  export declare function workflowRunsOnChange(text: string): boolean;
67
70
  /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
68
71
  export declare function bumpFormula(text: string, url: string, sha256: string): string;
72
+ /**
73
+ * The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
74
+ * lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
75
+ */
76
+ export declare function storeReleaseAdapter(open: () => Store, done: (store: Store) => void, now: () => Date, runner?: BuildRunner): NonNullable<ReleaseAdapters["release"]>;
69
77
  export {};