toolroll 0.9.75 → 0.9.77

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 (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -1
@@ -0,0 +1,612 @@
1
+ /**
2
+ * Releases that approve themselves when green (docs/plans/auto-approve-green.md): the project setting "Release
3
+ * approval: Ask me / Approve automatically when checks pass", and the plane's two automatic seals of a release task.
4
+ *
5
+ * auto-check (the approval step) lets the release task's build run its check — nothing else;
6
+ * auto-green (after the check) the release approval, once the plane itself saw the pull request's CI and the
7
+ * release check pass on the exact candidate. It opens the release's authority as a
8
+ * person's seal of release terms does.
9
+ *
10
+ * Both are written only here, each in one write transaction that proves every fact again from the plane's own records,
11
+ * its own `gh` observation (handed in by release-adapters.ts, which made it) and verified objects in the project's own
12
+ * Git directory. No command or console road reaches these functions: `toolroll release` calls them through its store
13
+ * adapter. Any refusal unseals `auto-check`, so the task asks a person, its passing check kept.
14
+ */
15
+ import { assignmentOf } from "./assignment.js";
16
+ import { fullCheckProblem, projectGitDir } from "./check-report.js";
17
+ import { sealedRun } from "./release-recheck.js";
18
+ import { runCheckLevel } from "./check-levels.js";
19
+ import { readVerifiedCommit, verifiedBlobs, verifiedTreeDiff, verifiedTreeEntries } from "./commit-extract.js";
20
+ import { mightName, testInfrastructure } from "./test-infra.js";
21
+ import { chatControlHref } from "./chat-controls.js";
22
+ import { INFRA_RERUNS_PER_CANDIDATE, REQUIRED_PR_CHECKS, REQUIRED_PR_CHECKS_WORKFLOW, prChecksVerdict } from "./release.js";
23
+ import { releaseCheckStanding } from "./check-failure.js";
24
+ import { CARRY_RULES, rulePath } from "./release-carry.js";
25
+ import { AUTO_EVIDENCE_ACTION, AUTO_WITHDRAWN_ACTION, LIVE_RELEASE_STATES, RELEASE_APPROVAL_ACTION, releaseAuthorityOfTask, releaseAutoApproves, releaseApprovalSetting } from "./release-authority.js";
26
+ import { autoReleaseApprovalOf, personApprovalOf } from "./scope.js";
27
+ import { releaseOperations, revokeRelease } from "./store.js";
28
+ import { RELEASE_CI_HELD_ACTION, RELEASE_CI_PASSED_ACTION, RELEASE_CI_RUNNING_WORDS, releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
29
+ import { verificationEvidence } from "./verification-evidence.js";
30
+ export { RELEASE_APPROVAL_ACTION, releaseApprovalSetting, releaseAutoApproves };
31
+ /** The setting's words, one place. */
32
+ export const RELEASE_APPROVAL_WORDS = { ask: "Ask me", auto: "Approve automatically when checks pass" };
33
+ /** How old the plane's own CI observation may be when it seals. */
34
+ export const CI_OBSERVATION_MS = 5 * 60_000;
35
+ const refuse = (why) => ({ ok: false, why });
36
+ /**
37
+ * Change the setting: an approver's act, proven by the caller (on: a password typed now; off: one click). Lowering it,
38
+ * in the same transaction, unseals every automatic seal of the project whose release hasn't started merging and stops
39
+ * their live releases, so nothing approves — or goes on — on the old setting.
40
+ */
41
+ export function setReleaseApproval(store, repo, auto, by, now) {
42
+ return store.transact(() => {
43
+ const before = releaseApprovalSetting(store, repo).auto;
44
+ if (before === auto)
45
+ return { changed: false, withdrawn: [] };
46
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: RELEASE_APPROVAL_ACTION, outcome: auto ? "auto" : "ask", source: "policy",
47
+ detail: `Release approval: ${before ? RELEASE_APPROVAL_WORDS.auto : RELEASE_APPROVAL_WORDS.ask} → ${auto ? RELEASE_APPROVAL_WORDS.auto : RELEASE_APPROVAL_WORDS.ask}` });
48
+ const withdrawn = [];
49
+ if (!auto) {
50
+ const sealed = store.handle.prepare(`SELECT s.task_id FROM task_scope s JOIN task_ref r ON r.external_id = s.task_id AND r.backend = 'built-in'
51
+ WHERE r.repo = ? AND s.approved_at IS NOT NULL AND s.approval_basis IN ('auto-check', 'auto-green')`).all(repo).map(row => String(row["task_id"]));
52
+ for (const taskId of sealed) {
53
+ const found = releaseAuthorityOfTask(store, taskId);
54
+ const a = found?.authority ?? null;
55
+ if (a !== null && a.state === "merged")
56
+ continue;
57
+ if (a !== null && a.state === "merge-reserved") {
58
+ // A merge already issued (running, or its outcome unknown) is genuinely in flight: it finishes through its own
59
+ // reconciliation. One only reserved, never issued, is stopped for good here, so it can't come back.
60
+ const issued = releaseOperations(store, a.id).some(op => op.kind === "merge" && (op.state === "running" || op.state === "unknown" || op.state === "succeeded"));
61
+ if (issued) {
62
+ // In flight: it reconciles on its own (merged finishes; failed goes back to deploying) — but the seal is
63
+ // withdrawn now, so a failed merge never regains the automatic approval, even with the setting back on.
64
+ withdraw(store, taskId, now);
65
+ continue;
66
+ }
67
+ store.handle.prepare("UPDATE release_authority SET state = 'revoked', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?")
68
+ .run(now.toISOString(), `${by}: automatic release approval turned off`, a.id, a.rev);
69
+ store.recordAction({ at: now.toISOString(), actor: by, repo: a.repo, taskId, runId: null, action: "release revoked", outcome: "revoked", source: "work", detail: `release ${a.id}: automatic release approval turned off before its merge was issued` });
70
+ }
71
+ else if (a !== null && LIVE_RELEASE_STATES.includes(a.state))
72
+ revokeRelease(store, a.id, `${by}: automatic release approval turned off`, now);
73
+ unseal(store, taskId, now);
74
+ withdrawn.push(taskId);
75
+ store.enqueueNotification({ dedupeKey: `release-auto-withdrawn:${taskId}:${now.toISOString()}`, kind: "release-auto", subject: "Release needs your approval",
76
+ body: "Automatic release approval was turned off, so this release waits for a person's approval.", link: chatControlHref("task", taskId), source: { taskRef: store.lookupRef(taskId).id } }, now);
77
+ }
78
+ }
79
+ store.bumpWake();
80
+ return { changed: true, withdrawn };
81
+ });
82
+ }
83
+ /** Take back an automatic seal for good: the task asks a person again (its result, if any, is kept), and its evidence is
84
+ * marked withdrawn, so no later reading — the setting turned on again included — lets it stand. */
85
+ function unseal(store, taskId, now) {
86
+ withdraw(store, taskId, now);
87
+ store.handle.prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL, approved_profile_json = NULL,
88
+ approved_route_json = NULL, approval_basis = NULL, mode_digest = NULL WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')`).run(taskId);
89
+ }
90
+ /** Mark a task's automatic seal's evidence withdrawn for good (its seal row may stay while an issued merge reconciles). */
91
+ function withdraw(store, taskId, now) {
92
+ const sealedWith = store.handle.prepare("SELECT mode_digest FROM task_scope WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')").get(taskId)?.["mode_digest"];
93
+ if (typeof sealedWith === "string")
94
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: store.lookupRef(taskId)?.repo ?? null, taskId, runId: null, action: AUTO_WITHDRAWN_ACTION, outcome: sealedWith, source: "work" });
95
+ }
96
+ /** What every automatic seal needs before anything else: the setting on now, a release task in this project with its
97
+ * candidate and release terms signed in, the exact digest the coordinator filed, and a person who can approve here. */
98
+ function releaseTaskFacts(store, input) {
99
+ const ref = store.lookupRef(input.taskId);
100
+ const scope = store.getScope(input.taskId);
101
+ if (ref === null || ref.repo === null || scope === null)
102
+ return refuse("the release task isn't in a project Toolroll knows");
103
+ const setting = releaseApprovalSetting(store, ref.repo);
104
+ if (!setting.auto || setting.setBy === null)
105
+ return refuse("this project's releases aren't approved automatically");
106
+ if ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null)
107
+ return refuse("only a release task is approved automatically");
108
+ if (scope.candidate !== input.candidate)
109
+ return refuse(`the release task is for ${String(scope.candidate).slice(0, 7)}, not ${input.candidate.slice(0, 7)}`);
110
+ if (scope.digest !== input.digest)
111
+ return refuse("the release scope changed after it was filed");
112
+ if (scope.termsProblem != null)
113
+ return refuse("the release scope can't be read exactly");
114
+ const by = setting.setBy;
115
+ const account = store.accountOf(by);
116
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(by, ref.repo))
117
+ return refuse(`${by}, who turned on automatic release approval, can no longer approve in this project`);
118
+ const terms = scope.releaseTerms;
119
+ // A release a person or Toolroll stopped (revoked, or a CI failure after its deploy) asks a person again.
120
+ const stopped = store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE repo = ? AND version = ? AND pr = ? AND state = 'revoked' LIMIT 1").get(terms.repo, terms.packageVersion, terms.pr);
121
+ if (stopped !== undefined)
122
+ return refuse(`release ${terms.packageVersion} was stopped before, so a person approves it again`);
123
+ return { ok: true, repo: ref.repo, by, taskRef: ref.id, version: terms.packageVersion, pr: terms.pr };
124
+ }
125
+ /** A refusal inside a seal's transaction: thrown, so nothing it wrote (the evidence row) stays. */
126
+ class AutoRefused extends Error {
127
+ }
128
+ /** One evidence row, then the seal naming it, in the caller's transaction. Throws AutoRefused when the seal primitive
129
+ * refused, so the evidence row rolls back with it. */
130
+ function sealWith(store, kind, taskId, by, repo, detail, now, why) {
131
+ // Written whole (recordAction shortens details for display): the evidence is the record of what proved the seal.
132
+ const written = store.handle.prepare("INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (?,?,?,?,?,?,?,?,?)")
133
+ .run(now.toISOString(), "toolroll", repo, taskId, typeof detail["run"] === "number" ? detail["run"] : null, AUTO_EVIDENCE_ACTION, kind, "work", JSON.stringify({ v: 1, by, ...detail }));
134
+ const id = Number(written.lastInsertRowid);
135
+ if (!store.sealScopeApproval(taskId, by, now, {}, { kind, modeDigest: `auto:${id}` }, "automation"))
136
+ throw new AutoRefused(why);
137
+ }
138
+ /** Run `body` in one write transaction; an AutoRefused inside it rolls everything back and is the answer. */
139
+ function inTransaction(store, body) {
140
+ try {
141
+ return store.transact(body);
142
+ }
143
+ catch (error) {
144
+ if (error instanceof AutoRefused)
145
+ return refuse(error.message);
146
+ throw error;
147
+ }
148
+ }
149
+ /**
150
+ * The approval step with the setting on: seal `auto-check`, so the release task's build runs its check before anyone
151
+ * approves it. Idempotent: an `auto-check` already standing on this digest is the answer. A person's own approval
152
+ * already there is left alone (the coordinator then takes the person's road).
153
+ */
154
+ export function authorizeAutoCheck(store, input) {
155
+ return inTransaction(store, () => {
156
+ const facts = releaseTaskFacts(store, input);
157
+ if (!facts.ok)
158
+ return facts;
159
+ const scope = store.getScope(input.taskId);
160
+ if (scope.approvedAt !== null && scope.approvedDigest === scope.digest) {
161
+ if (scope.approvalBasis === "auto-check") {
162
+ const standing = autoReleaseApprovalOf(store, input.taskId, "auto-check");
163
+ return standing.ok ? { ok: true, already: true } : refuse(standing.why);
164
+ }
165
+ return refuse(personApprovalOf(store, input.taskId).ok ? "a person already approved it" : "it already has an approval that isn't automatic");
166
+ }
167
+ if (!/^[0-9a-f]{40}$/.test(input.base))
168
+ return refuse("GitHub named no base for this commit");
169
+ const gitDir = projectGitDir(facts.repo);
170
+ if (gitDir === null || !readVerifiedCommit(gitDir, input.base).ok)
171
+ return refuse(`the base ${input.base.slice(0, 7)} isn't in the project's own Git directory`);
172
+ sealWith(store, "auto-check", input.taskId, facts.by, facts.repo, { digest: input.digest, candidate: input.candidate, base: input.base }, input.now, "the scope can't be approved automatically (its approval rules, text or check access need a person)");
173
+ return { ok: true, already: false };
174
+ });
175
+ }
176
+ /** Test infrastructure that lives under src/ (helpers and fixtures, not tests): a person approves changes to it. */
177
+ const SRC_TEST_INFRA = /^src\/(fixtures\/|(.*\/)?(test-[^/]*|[^/]*-test-support[^/]*|[^/]*fixtures?[^/]*)\.[cm]?[jt]sx?$)/;
178
+ /** A test file (normalized path): `*.test.ts`, never inside a fixtures folder. */
179
+ const TEST_FILE = /^(?!.*(^|\/)fixtures?\/)[^]*\.test\.ts$/;
180
+ /** Build or type configuration anywhere: a person approves it. */
181
+ const BUILD_CONFIG = /(^|\/)(tsconfig[^/]*\.json|[^/]*\.config\.[cm]?[jt]s|\.npmrc|\.nvmrc|\.node-version)$/;
182
+ /**
183
+ * What an automatic approval may ship without a person, as an allowlist over each changed path (auto-approve-green.md):
184
+ * product code under `src/` (its tests included; never its test helpers or fixtures, nor build configuration), test files
185
+ * under `test/` (only `*.test.ts`: never a helper, fixture or setup file; deleting any test needs a person), docs
186
+ * (never the testing policy or plans, never agent or security policy), the changelog and README, and a version bump of
187
+ * package.json and package-lock.json. Everything else — CI, scripts (the check, the build, the harness), test/,
188
+ * dependencies, the lockfile beyond its version, config — and every path the release carry rules never carry, needs a
189
+ * person. Null when the path may ship.
190
+ */
191
+ export function autoPathProblem(path) {
192
+ const normal = rulePath(path);
193
+ const testUnderTest = TEST_FILE.test(normal) && normal.startsWith("test/");
194
+ // Every deny rule first — the carry rules' never list (minus its blanket test/ rule, for test files there only) and
195
+ // build configuration — so a test file can't slip a policy or CI path through (test/attacks/.github/a.test.ts).
196
+ for (const rule of CARRY_RULES.never) {
197
+ if (testUnderTest && rule.matches("test/__probe__.test.ts"))
198
+ continue;
199
+ if (rule.matches(normal))
200
+ return rule.why;
201
+ }
202
+ // Test files under test/ carry the same trust as src's (Alex: test authors are trusted) — only the `.test.ts` files
203
+ // themselves, never a helper, fixture, setup or config file beside them, nor one inside a fixtures folder.
204
+ if (testUnderTest)
205
+ return null;
206
+ if (BUILD_CONFIG.test(normal))
207
+ return "it configures the build or the checks";
208
+ if (normal === "package.json" || normal === "package-lock.json")
209
+ return null;
210
+ if (normal.startsWith("src/"))
211
+ return SRC_TEST_INFRA.test(normal) ? "it's a test helper or fixture" : null;
212
+ if (normal === "changelog.md" || normal === "readme.md")
213
+ return null;
214
+ if (normal.startsWith("docs/") && normal.endsWith(".md"))
215
+ return null;
216
+ return "it's outside what a release approves itself for (product code, tests, docs, the changelog and a version bump)";
217
+ }
218
+ /**
219
+ * GitHub CI on the exact head, as the plane read it (every attempt): fresh, every run GitHub Actions, and the same
220
+ * verdict every later release boundary uses (release.ts prChecksVerdict: exact head, each job by its latest attempt,
221
+ * every required check present), where an earlier failure stands only as confirmed infrastructure (GitHub's job record
222
+ * shows it ended before any workflow step ran), at most INFRA_RERUNS_PER_CANDIDATE of them. The tolerated failures are
223
+ * returned for the evidence.
224
+ */
225
+ export function ciVerdict(observation, candidate, now) {
226
+ const no = (problem) => ({ ok: false, problem });
227
+ if (observation.head !== candidate)
228
+ return no(`CI was read for ${observation.head.slice(0, 7)}, not ${candidate.slice(0, 7)}`);
229
+ const at = Date.parse(observation.observedAt);
230
+ if (!Number.isFinite(at) || at > now.getTime() + 60_000 || now.getTime() - at > CI_OBSERVATION_MS)
231
+ return no("the CI reading isn't fresh");
232
+ if (observation.complete !== true)
233
+ return no("GitHub's check history of this commit can't be read whole");
234
+ const other = observation.runs.find(run => run.app !== "github-actions");
235
+ if (other !== undefined)
236
+ return no(`${other.name} was reported by ${other.app ?? "an unknown app"}, not GitHub Actions`);
237
+ const verdict = prChecksVerdict(observation.runs, candidate, REQUIRED_PR_CHECKS, { auto: true, workflow: REQUIRED_PR_CHECKS_WORKFLOW });
238
+ if (verdict.state === "failed")
239
+ return no(verdict.failed.join(", "));
240
+ if (verdict.state === "pending")
241
+ return no(`not finished: ${verdict.waiting.join(", ")}`);
242
+ return { ok: true, failures: verdict.failures };
243
+ }
244
+ /** ciVerdict's problem, or null when CI passes. */
245
+ export function ciProblem(observation, candidate, now) {
246
+ const verdict = ciVerdict(observation, candidate, now);
247
+ return verdict.ok ? null : verdict.problem;
248
+ }
249
+ /** package.json or package-lock.json with only its version fields left out, canonical (sorted keys); undefined when it
250
+ * can't be read. */
251
+ function withoutVersion(path, bytes) {
252
+ if (bytes === undefined)
253
+ return null;
254
+ let parsed;
255
+ try {
256
+ parsed = JSON.parse(bytes.toString("utf8"));
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
262
+ return undefined;
263
+ const copy = structuredClone(parsed);
264
+ delete copy["version"];
265
+ if (path === "package-lock.json") {
266
+ const root = copy["packages"]?.[""];
267
+ if (root !== undefined && typeof root === "object" && root !== null)
268
+ delete root["version"];
269
+ }
270
+ const canonical = (value) => Array.isArray(value) ? value.map(canonical)
271
+ : value !== null && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
272
+ return JSON.stringify(canonical(copy));
273
+ }
274
+ /** The test infrastructure of one verified tree: its source files under src/ and test/ and its vitest configs, scanned. */
275
+ function infrastructureAt(gitDir, tree) {
276
+ const entries = verifiedTreeEntries(gitDir, tree);
277
+ if (!entries.ok)
278
+ return null;
279
+ const files = entries.value.filter(one => one.mode === "100644" || one.mode === "100755");
280
+ const paths = files.filter(one => (/\.(?:[cm]?[jt]sx?)$/.test(one.path) && !one.path.startsWith("node_modules/")) || one.path === "tsconfig.json" || one.path === "package.json").map(one => one.path);
281
+ const blobs = verifiedBlobs(gitDir, tree, paths);
282
+ if (!blobs.ok)
283
+ return null;
284
+ try {
285
+ return testInfrastructure(new Map([...blobs.value].map(([path, blob]) => [path, blob.bytes.toString("utf8")])), new Set(entries.value.map(one => one.path)));
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ }
291
+ /** What the candidate changes since `base` (the plane's own reading of where it left its base branch), from verified
292
+ * objects, that an automatic approval never ships. Null when every change may ship. */
293
+ function changeProblem(repo, base, candidate) {
294
+ const gitDir = projectGitDir(repo);
295
+ if (gitDir === null)
296
+ return "the project's Git directory can't be read";
297
+ if (base === null || !/^[0-9a-f]{40}$/.test(base))
298
+ return "no base was recorded to compare with";
299
+ const before = readVerifiedCommit(gitDir, base), after = readVerifiedCommit(gitDir, candidate);
300
+ if (!before.ok || !after.ok)
301
+ return "the candidate's commits can't be read";
302
+ const diff = verifiedTreeDiff(gitDir, before.value.tree, after.value.tree);
303
+ if (!diff.ok)
304
+ return `the candidate's change can't be read: ${diff.message}`;
305
+ // Test infrastructure found from the import graph (test-infra.ts), at the base — which the candidate can't rewrite —
306
+ // and at the candidate (a new helper only tests import). Unreadable: refuse.
307
+ const scans = [];
308
+ for (const tree of [before.value.tree, after.value.tree]) {
309
+ const found = infrastructureAt(gitDir, tree);
310
+ if (found === null)
311
+ return "the tests' imports can't be read";
312
+ scans.push(found);
313
+ }
314
+ const infra = new Set(scans.flatMap(one => [...one.infra]));
315
+ for (const change of diff.value) {
316
+ const modes = [change.before?.mode, change.after?.mode].filter((one) => one !== undefined);
317
+ if (modes.includes("120000") || modes.includes("160000"))
318
+ return `${change.path} is a symlink or submodule`;
319
+ // Taking a test away is never automatic, wherever it lives.
320
+ if (change.kind === "deleted" && TEST_FILE.test(rulePath(change.path)))
321
+ return `${change.path} was deleted (removing a test needs a person)`;
322
+ if (change.before !== null && TEST_FILE.test(rulePath(change.path))) {
323
+ // Counts cannot prove that an assertion survived: replacing it, commenting it out, or skipping its case can
324
+ // leave the counts unchanged. Only append-only additions preserve the existing code without interpretation.
325
+ // Both copies come from verified trees; no report or candidate-provided file list decides this permission.
326
+ const was = verifiedBlobs(gitDir, before.value.tree, [change.path]);
327
+ const now = verifiedBlobs(gitDir, after.value.tree, [change.path]);
328
+ if (!was.ok || !now.ok)
329
+ return `${change.path}'s existing test code couldn't be read (a person decides)`;
330
+ const original = was.value.get(change.path)?.bytes, revised = now.value.get(change.path)?.bytes;
331
+ if (original === undefined || revised === undefined || !revised.subarray(0, original.length).equals(original)) {
332
+ return `${change.path} changes existing test code (removing or rewriting tests needs a person)`;
333
+ }
334
+ }
335
+ const why = autoPathProblem(change.path);
336
+ if (why !== null)
337
+ return `${change.path} changed (${why})`;
338
+ if (infra.has(change.path))
339
+ return `${change.path} changed (tests import it and the product doesn't: it's test infrastructure)`;
340
+ // An import the tests make that couldn't be resolved might name this file: fail closed.
341
+ for (const scan of scans) {
342
+ const open = scan.unresolved.find(one => mightName(one, change.path, scan));
343
+ if (open !== undefined)
344
+ return `${change.path} changed, and ${open.from} imports ${open.specifier === null ? "a computed path" : open.specifier}, which couldn't be resolved and might name it`;
345
+ }
346
+ const normal = rulePath(change.path);
347
+ if (normal === "package.json" || normal === "package-lock.json") {
348
+ const read = (tree) => { const blobs = verifiedBlobs(gitDir, tree, [change.path]); return blobs.ok ? withoutVersion(normal, blobs.value.get(change.path)?.bytes) : undefined; };
349
+ const was = read(before.value.tree), now = read(after.value.tree);
350
+ if (was === undefined || now === undefined || was !== now)
351
+ return `${change.path} changed beyond its version (dependencies or scripts need a person)`;
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+ /**
357
+ * The release approval, once the check passed: seal `auto-green` over the standing `auto-check`, or refuse in words —
358
+ * and on any refusal take the `auto-check` back, so a person decides. Everything is proven again in this one write
359
+ * transaction (docs/plans/auto-approve-green.md, "What auto-green proves").
360
+ */
361
+ export function sealAutoGreen(store, input) {
362
+ const answer = inTransaction(store, () => {
363
+ const facts = releaseTaskFacts(store, input);
364
+ if (!facts.ok)
365
+ return facts;
366
+ // Head-only CI is the merged tree only when GitHub itself refuses a branch behind main (eval-harness review 1,
367
+ // finding 4): without that setting nothing approves itself.
368
+ if (input.mainUpToDate !== true)
369
+ return refuse(input.mainUpToDate === null
370
+ ? "GitHub's branch protection for main couldn't be read, so whether it refuses a branch behind main is unknown"
371
+ : "main's branch protection doesn't hold for the account that merges: it must require branches to be up to date, require the release's checks, and allow no bypass (\"Do not allow bypassing the above settings\")");
372
+ const checking = autoReleaseApprovalOf(store, input.taskId, "auto-check");
373
+ if (!checking.ok)
374
+ return refuse(`its check wasn't authorized automatically: ${checking.why}`);
375
+ // The release check: this task's build on this digest, its native receipt, a Full level and no failed check before.
376
+ const run = store.getRun(input.run);
377
+ if (run === null || run.taskRef !== facts.taskRef || run.role !== "builder" || run.finishedAt === null || !(run.outcome === "built" || run.outcome === "no-change"))
378
+ return refuse(`run ${input.run} isn't a finished build of this release task`);
379
+ if (run.scopeDigest !== input.digest)
380
+ return refuse(`run ${input.run} checked different scope text`);
381
+ const head = run.headRevision;
382
+ if (head === null)
383
+ return refuse(`run ${input.run} recorded no commit`);
384
+ if (runCheckLevel(store, run.id) !== "full")
385
+ return refuse("the release check wasn't Full");
386
+ // Earlier builds of this task that didn't finish built with a passed check (a failed check, a check that never ran,
387
+ // a build that failed or was interrupted) stand only as confirmed infrastructure the release itself reran on the
388
+ // same signed scope, at most INFRA_RERUNS_PER_CANDIDATE (check-failure.ts): a failing test, an unexplained failure
389
+ // or a real-model journey before the pass means a person decides. So does anything that passed only on a retry.
390
+ const failedRuns = store.handle.prepare(`SELECT r.id, r.scope_digest FROM run r LEFT JOIN run_check c ON c.run = r.id
391
+ WHERE r.task_ref = ? AND r.role = 'builder' AND r.id <> ?
392
+ AND NOT (COALESCE(r.outcome, '') IN ('built', 'no-change') AND COALESCE(c.status, '') = 'passed') ORDER BY r.id`).all(facts.taskRef, run.id);
393
+ if (failedRuns.some(one => one["scope_digest"] !== input.digest))
394
+ return refuse("an earlier check of this release ran on different scope text, so a person decides");
395
+ if (failedRuns.some(one => Number(one["id"]) > run.id))
396
+ return refuse(`run ${input.run} isn't this task's newest build`);
397
+ const standing = releaseCheckStanding(store, input.evidenceRoot, { taskId: input.taskId, run: run.id });
398
+ if (!standing.ok)
399
+ return refuse(standing.why);
400
+ // Unknown is never "no retries" (eval-harness review 1, finding 3).
401
+ if (standing.retried === null)
402
+ return refuse("the release check's retry record can't be read (its report or receipt is missing or damaged), so whether anything passed only on a retry is unknown and a person decides");
403
+ if (standing.retried.length > 0)
404
+ return refuse(`the release check passed only on a retry (${standing.retried.slice(0, 3).join(", ")}${standing.retried.length > 3 ? ", …" : ""}), so a person decides`);
405
+ if (failedRuns.length > INFRA_RERUNS_PER_CANDIDATE)
406
+ return refuse(`the release check failed ${failedRuns.length} times before it passed, so a person decides`);
407
+ const checkFailures = failedRuns.map(one => Number(one["id"]));
408
+ const newest = store.runsFor(facts.taskRef).find(one => one.role === "builder");
409
+ if (newest?.id !== run.id)
410
+ return refuse(`run ${input.run} isn't this task's newest build`);
411
+ const evidence = verificationEvidence(store, input.evidenceRoot, run.id);
412
+ if (!evidence.ok || evidence.bytes === null)
413
+ return refuse(`the release check's receipt can't be read${evidence.ok ? "" : `: ${evidence.problem}`}`);
414
+ let gate;
415
+ try {
416
+ gate = JSON.parse(evidence.bytes.toString());
417
+ }
418
+ catch {
419
+ return refuse("the release check's receipt can't be read");
420
+ }
421
+ const live = store.liveVerifyCommand(facts.repo);
422
+ const result = gate["result"];
423
+ const command = gate["command"];
424
+ if (gate["coverage"] !== undefined && gate["coverage"] !== "full")
425
+ return refuse(`the release check didn't cover everything (${String(gate["coverage"])})`);
426
+ // A readable, valid report and inventory of a Full attempt, the receipt's own, every obligation accounted for, and
427
+ // the retry summary sealed in the receipt: an automatic approval never stands on evidence it can't read.
428
+ const check = gate["check"];
429
+ const sealed = sealedRun(store, input.evidenceRoot, run.id);
430
+ if (check === undefined || sealed === null || sealed.attempt.mode !== "full" || check.attempt !== "full" || sealed.report === null || sealed.inventory === null || sealed.reportDigest !== check.report) {
431
+ return refuse("the release check's sealed report or inventory can't be read, so what it ran and retried is unknown");
432
+ }
433
+ const incomplete = fullCheckProblem({ report: sealed.report, inventory: sealed.inventory, exitCode: typeof result?.exitCode === "number" ? result.exitCode : null });
434
+ if (incomplete !== null)
435
+ return refuse(`the release check has no complete result: ${incomplete}`);
436
+ if (!Array.isArray(check.retried))
437
+ return refuse("the release check's receipt has no sealed retry record, so a person decides");
438
+ // Belt to the retry record (finding 6): a check that passed only on a rerun after the recovery setup never approves
439
+ // itself — its first failure isn't confirmed infrastructure.
440
+ if (result?.setupReplayed === true)
441
+ return refuse("the release check passed only when it was run again after the approved setup, so a person decides");
442
+ if (gate["run"] !== run.id || gate["head"] !== head || gate["scopeDigest"] !== input.digest || result?.ran !== true || result.exitCode !== 0 || live === null || command?.command !== live.command) {
443
+ return refuse("the release check didn't pass this candidate under the approved command");
444
+ }
445
+ const assignment = assignmentOf(store, input.taskId, input.now, { principal: "operator", repos: null, includeUnplaced: true }, input.evidenceRoot);
446
+ const receipt = assignment?.receipt ?? null;
447
+ if (receipt === null || receipt.runId !== run.id || receipt.digest !== input.receipt || receipt.head !== head || receipt.scopeDigest !== input.digest)
448
+ return refuse("the result the release read isn't this build's");
449
+ // The candidate's change, from verified objects: nothing that defines CI or the check, or steers agents.
450
+ if (head !== input.candidate) {
451
+ // A prepared candidate's build records its own commit of exactly the candidate's tree (builder.ts).
452
+ const gitDir = projectGitDir(facts.repo);
453
+ const built = gitDir === null ? null : readVerifiedCommit(gitDir, head), wanted = gitDir === null ? null : readVerifiedCommit(gitDir, input.candidate);
454
+ if (built === null || wanted === null || !built.ok || !wanted.ok || built.value.tree !== wanted.value.tree)
455
+ return refuse(`the build's commit ${head.slice(0, 7)} doesn't hold exactly ${input.candidate.slice(0, 7)}'s files`);
456
+ }
457
+ // Compared with where the candidate left its base branch, as the plane read it when the check was authorized —
458
+ // the same base for every attempt, never a build's own recorded base (a task branch can be seeded ahead of time).
459
+ const base = checking.evidence.base;
460
+ const changed = changeProblem(facts.repo, base, input.candidate);
461
+ if (changed !== null)
462
+ return refuse(changed);
463
+ // GitHub CI, as the plane's own gh saw it moments ago, on exactly this commit.
464
+ const ci = ciVerdict(input.ci, input.candidate, input.now);
465
+ if (!ci.ok)
466
+ return refuse(`CI: ${ci.problem}`);
467
+ const observed = input.ci.runs.map(one => ({ id: one.id, name: one.name, suite: one.suite ?? null }));
468
+ sealWith(store, "auto-green", input.taskId, facts.by, facts.repo, {
469
+ digest: input.digest, candidate: input.candidate, run: run.id, receipt: input.receipt, head, base, ci: observed, observedAt: input.ci.observedAt,
470
+ // What failed first on this same commit and digest (flakes), for whoever reads the record later.
471
+ ciFailures: ci.failures, checkFailures,
472
+ }, input.now, "the release terms can't be approved now (an unsettled deploy, a merge already started, or rules that need a person)");
473
+ store.enqueueNotification({ dedupeKey: `release-auto-approved:${input.taskId}:${input.digest}`, kind: "release-auto",
474
+ subject: `Release ${facts.version} approved automatically`,
475
+ body: `CI and the release check passed on ${input.candidate.slice(0, 7)}, so Toolroll approved it under your project setting. It deploys, merges and publishes next.`,
476
+ link: chatControlHref("task", input.taskId), source: { taskRef: facts.taskRef } }, input.now);
477
+ return { ok: true, by: facts.by, at: input.now.toISOString(), ci: observed };
478
+ });
479
+ if (!answer.ok)
480
+ giveUpAutoApproval(store, { taskId: input.taskId, why: answer.why, now: input.now });
481
+ return answer;
482
+ }
483
+ /** The automatic road ended without an approval (a refusal, a failed check, a CI failure after the deploy): take the
484
+ * `auto-check` back so the task asks a person, and tell the owner why. Idempotent. */
485
+ export function giveUpAutoApproval(store, input) {
486
+ store.transact(() => {
487
+ const ref = store.lookupRef(input.taskId);
488
+ if (ref === null)
489
+ return;
490
+ unseal(store, input.taskId, input.now);
491
+ const scope = store.getScope(input.taskId);
492
+ const version = scope?.releaseTerms?.packageVersion ?? null;
493
+ // Still waiting for CI (round 1, finding 4): nobody is asked to approve yet; `liftCiHold` asks once CI passed.
494
+ const ciHold = releaseCiHoldOf(store, input.taskId);
495
+ if (ciHold !== null) {
496
+ store.enqueueNotification({ dedupeKey: `release-auto-stopped:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto",
497
+ subject: `Release${version === null ? "" : ` ${version}`} won't approve itself`,
498
+ body: `Not approved automatically: ${input.why}. GitHub CI is still running on ${ciHold.candidate?.slice(0, 7) ?? "its commit"}; you're asked to approve it once CI passes.`,
499
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
500
+ return;
501
+ }
502
+ // Filed while CI runs (Alex, 2026-10-29): a person may approve now; nothing deploys until CI passes.
503
+ const running = releaseCiRunningOf(store, input.taskId) === null ? "" : ` ${RELEASE_CI_RUNNING_WORDS}`;
504
+ store.enqueueNotification({ dedupeKey: `release-auto-stopped:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto", pushClass: "attention",
505
+ subject: `Release${version === null ? "" : ` ${version}`} needs you`,
506
+ body: `Not approved automatically: ${input.why}. Approve it yourself, or fix the branch and release the new commit.${running}`,
507
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
508
+ });
509
+ }
510
+ /** A release check failed while checking automatically: tell the owner. The `auto-check` stays: a rerun the release
511
+ * itself made after confirmed infrastructure may still approve itself; any other failure means a person decides. */
512
+ export function noteAutoCheckFailed(store, input) {
513
+ const ref = store.lookupRef(input.taskId);
514
+ if (ref === null)
515
+ return;
516
+ const version = store.getScope(input.taskId)?.releaseTerms?.packageVersion ?? null;
517
+ store.enqueueNotification({ dedupeKey: `release-auto-check-failed:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto", pushClass: "attention",
518
+ subject: `Release${version === null ? "" : ` ${version}`} check failed`,
519
+ body: `${input.why}. Only a failure Toolroll confirmed as its own infrastructure is checked again automatically; for anything else, approve it yourself or fix the branch and release the new commit.`,
520
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
521
+ }
522
+ /** "Released 0.9.x automatically": the release that approved itself finished. */
523
+ export function noteAutoRelease(store, input) {
524
+ const ref = store.lookupRef(input.taskId);
525
+ if (ref === null)
526
+ return;
527
+ store.enqueueNotification({ dedupeKey: `release-auto-released:${input.taskId}:${input.tag}`, kind: "release-auto",
528
+ subject: `Released ${input.tag} automatically`, body: `${input.tag} is deployed, merged, tagged and published. CI and the release check passed, so nobody had to approve it.`,
529
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
530
+ }
531
+ /** Whether a task carries an automatic release seal (any state): it never merges or deploys outside `toolroll release`. */
532
+ export function autoReleaseSealed(store, taskId) {
533
+ return store.handle.prepare("SELECT 1 AS hit FROM task_scope WHERE task_id = ? AND approval_basis IN ('auto-check', 'auto-green')").get(taskId) !== undefined;
534
+ }
535
+ /** The plane's record that an automatic release's publication waits for a person (CI stopped passing after its merge),
536
+ * and a person's answer. The newest row for the task wins. */
537
+ export const PUBLISH_HELD_ACTION = "release publish held";
538
+ export const PUBLISH_ALLOWED_ACTION = "release publish allowed";
539
+ export function holdAutoPublish(store, input) {
540
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
541
+ action: PUBLISH_HELD_ACTION, outcome: "held", source: "work", detail: input.why });
542
+ }
543
+ /** Why the release's tag, publication and Homebrew wait for a person, or null. */
544
+ export function autoPublishHeld(store, taskId) {
545
+ const row = store.handle.prepare("SELECT action, detail FROM action_ledger WHERE task_id = ? AND action IN (?, ?) ORDER BY id DESC LIMIT 1").get(taskId, PUBLISH_HELD_ACTION, PUBLISH_ALLOWED_ACTION);
546
+ return row === undefined || row["action"] !== PUBLISH_HELD_ACTION ? null : String(row["detail"] ?? "This release's publication waits for a person");
547
+ }
548
+ /** A person lets a held publication go on: the caller proved a person (never the lead, a token or a mode). */
549
+ export function allowAutoPublish(store, input) {
550
+ if (autoPublishHeld(store, input.taskId) === null)
551
+ return refuse("nothing about this release waits for a person");
552
+ store.recordAction({ at: input.now.toISOString(), actor: input.by, repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
553
+ action: PUBLISH_ALLOWED_ACTION, outcome: "allowed", source: "request", detail: `${input.by} lets the release's tag, publication and Homebrew go on` });
554
+ return { ok: true };
555
+ }
556
+ /**
557
+ * A release filed while its pull request's CI still ran (pipeline-speed.md D2): hold every approval but the automatic
558
+ * road's until the plane reads exact-head CI passing (Codex review round 1, finding 4). Recorded before the task is
559
+ * filed, so no moment exists where the filed task is approvable with CI pending. Idempotent.
560
+ */
561
+ export function holdApprovalForCi(store, input) {
562
+ if (!/^[0-9a-f]{40}$/.test(input.candidate))
563
+ throw new Error("a CI hold needs the exact candidate commit");
564
+ const held = releaseCiHoldOf(store, input.taskId);
565
+ if (held !== null && held.candidate === input.candidate)
566
+ return;
567
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
568
+ action: RELEASE_CI_HELD_ACTION, outcome: "held", source: "work", detail: input.candidate });
569
+ }
570
+ /**
571
+ * Lift a release's CI hold (an older coordinator's) or end its "CI still running" note on the plane's own reading of
572
+ * GitHub CI (never the coordinator's word): fresh, whole, every run GitHub Actions, on exactly the held candidate, and
573
+ * every required check passed (release.ts prChecksVerdict, as a person's road judges it: a person decides about earlier
574
+ * attempts). With `ask`, the owner of a held release is told it needs them now. Nothing held or noted is a pass.
575
+ */
576
+ export function liftCiHold(store, input) {
577
+ return store.transact(() => {
578
+ const held = releaseCiHoldOf(store, input.taskId);
579
+ const waiting = held ?? releaseCiRunningOf(store, input.taskId);
580
+ if (waiting === null)
581
+ return { ok: true };
582
+ if (waiting.candidate !== input.candidate)
583
+ return refuse(`the release waits for CI on ${waiting.candidate?.slice(0, 7) ?? "an unreadable commit"}, not ${input.candidate.slice(0, 7)}`);
584
+ const ci = input.ci;
585
+ if (ci.head !== input.candidate)
586
+ return refuse(`CI was read for ${ci.head.slice(0, 7)}, not ${input.candidate.slice(0, 7)}`);
587
+ const at = Date.parse(ci.observedAt);
588
+ if (!Number.isFinite(at) || at > input.now.getTime() + 60_000 || input.now.getTime() - at > CI_OBSERVATION_MS)
589
+ return refuse("the CI reading isn't fresh");
590
+ if (ci.complete !== true)
591
+ return refuse("GitHub's check history of this commit can't be read whole");
592
+ const other = ci.runs.find(run => run.app !== "github-actions");
593
+ if (other !== undefined)
594
+ return refuse(`${other.name} was reported by ${other.app ?? "an unknown app"}, not GitHub Actions`);
595
+ const verdict = prChecksVerdict(ci.runs, input.candidate, REQUIRED_PR_CHECKS, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
596
+ if (verdict.state === "failed")
597
+ return refuse(`CI failed: ${verdict.failed.join(", ")}`);
598
+ if (verdict.state === "pending")
599
+ return refuse(`CI hasn't finished: ${verdict.waiting.join(", ")}`);
600
+ const ref = store.lookupRef(input.taskId);
601
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo: ref?.repo ?? null, taskId: input.taskId, runId: null,
602
+ action: RELEASE_CI_PASSED_ACTION, outcome: "passed", source: "work", detail: input.candidate });
603
+ if (input.ask && held !== null && ref !== null) {
604
+ const version = store.getScope(input.taskId)?.releaseTerms?.packageVersion ?? null;
605
+ store.enqueueNotification({ dedupeKey: `release-ci-passed:${input.taskId}:${input.candidate}`, kind: "release-auto", pushClass: "attention",
606
+ subject: `Release${version === null ? "" : ` ${version}`} needs you`,
607
+ body: `GitHub CI passed on ${input.candidate.slice(0, 7)}. Approve it yourself, or fix the branch and release the new commit.`,
608
+ link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
609
+ }
610
+ return { ok: true };
611
+ });
612
+ }
@@ -46,10 +46,22 @@ export type ChangeList = {
46
46
  }[] | null;
47
47
  source: "recorded" | "pull-request" | "unread";
48
48
  digest: string;
49
+ /** A release is on the record but its merge commit can't be read (eval-harness review 2, finding 4). */
50
+ baselineUnreadable?: true;
51
+ };
52
+ /**
53
+ * The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
54
+ * the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag.
55
+ * Whether the plane recorded one, and its merge commit (eval-harness review 2, finding 4):
56
+ * `recorded: false` only when no merged release is on the record at all; a recorded one whose commit isn't a full id is
57
+ * `recorded: true` with `sha: null` — something to refuse on, never "no previous release".
58
+ */
59
+ export declare function releaseBaselineRecord(store: Store, repo: string): {
60
+ recorded: false;
61
+ } | {
62
+ recorded: true;
63
+ sha: string | null;
49
64
  };
50
- /** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
51
- * the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
52
- export declare function releaseBaseline(store: Store, repo: string): string | null;
53
65
  /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
54
66
  * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
55
67
  * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own