toolroll 0.9.76 → 0.9.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +273 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +46 -10
- package/dist/assignment.js +3 -0
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +18 -3
- package/dist/builder.js +152 -47
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +295 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +67 -6
- package/dist/check-report.js +254 -9
- package/dist/checkout-cleanup.js +2 -2
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-service.js +4 -1
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.js +5 -0
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +9 -5
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +19 -4
- package/dist/exec.d.ts +9 -0
- package/dist/exec.js +22 -2
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/grant.js +5 -1
- package/dist/guides.js +22 -11
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +969 -328
- package/dist/planner.js +2 -2
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +2 -2
- package/dist/proof.js +6 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +80 -2
- package/dist/release-adapters.js +441 -16
- package/dist/release-authority.js +6 -2
- package/dist/release-auto.d.ts +38 -7
- package/dist/release-auto.js +131 -15
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +95 -17
- package/dist/release-coverage.js +5 -2
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +8 -2
- package/dist/release.d.ts +144 -14
- package/dist/release.js +421 -81
- package/dist/review-context.js +8 -6
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +73 -9
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +26 -3
- package/dist/scout.js +2 -2
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +112 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/request-context.d.ts +8 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/task-views.js +7 -2
- package/dist/server/tasks.js +4 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-sweep.js +1 -1
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +135 -2
- package/dist/store.js +275 -13
- package/dist/surface.js +3 -1
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +52 -3
- package/dist/teardown-git.js +145 -33
- package/dist/test-infra.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +81 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/workspace-revision.js +3 -1
- package/package.json +2 -1
package/dist/release-auto.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export type ObservedCheckRun = {
|
|
|
19
19
|
suite?: number | null;
|
|
20
20
|
head?: string | null;
|
|
21
21
|
workflow?: number | null;
|
|
22
|
+
/** The workflow's file (GitHub's workflow-run `path`): a required check counts only from ci.yml. */
|
|
23
|
+
workflowPath?: string | null;
|
|
24
|
+
/** GitHub's job record shows this unsuccessful attempt ended before any workflow step ran (release-adapters.ts). */
|
|
25
|
+
beforeTests?: boolean | null;
|
|
22
26
|
};
|
|
23
27
|
/** The plane's own look at GitHub CI for one commit (release-adapters.ts makes it, right before the seal). */
|
|
24
28
|
export type CiObservation = {
|
|
@@ -68,15 +72,15 @@ export declare function authorizeAutoCheck(store: Store, input: {
|
|
|
68
72
|
* person. Null when the path may ship.
|
|
69
73
|
*/
|
|
70
74
|
export declare function autoPathProblem(path: string): string | null;
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
export declare const MAX_FAILED_ATTEMPTS = 2;
|
|
74
|
-
/** One CI attempt that failed before its job's latest attempt passed, as the evidence records it. */
|
|
75
|
+
/** One CI attempt that failed before its job's latest attempt passed (GitHub showed its runner failing before the
|
|
76
|
+
* tests ran), as the evidence records it. */
|
|
75
77
|
export type CiFailure = CheckFailure;
|
|
76
78
|
/**
|
|
77
79
|
* GitHub CI on the exact head, as the plane read it (every attempt): fresh, every run GitHub Actions, and the same
|
|
78
80
|
* verdict every later release boundary uses (release.ts prChecksVerdict: exact head, each job by its latest attempt,
|
|
79
|
-
* every required check present),
|
|
81
|
+
* every required check present), where an earlier failure stands only as confirmed infrastructure (GitHub's job record
|
|
82
|
+
* shows it ended before any workflow step ran), at most INFRA_RERUNS_PER_CANDIDATE of them. The tolerated failures are
|
|
83
|
+
* returned for the evidence.
|
|
80
84
|
*/
|
|
81
85
|
export declare function ciVerdict(observation: CiObservation, candidate: string, now: Date): {
|
|
82
86
|
ok: true;
|
|
@@ -101,6 +105,8 @@ export declare function sealAutoGreen(store: Store, input: {
|
|
|
101
105
|
ci: CiObservation;
|
|
102
106
|
evidenceRoot: string;
|
|
103
107
|
now: Date;
|
|
108
|
+
/** Whether main's protection holds for the account that merges (release-adapters.ts readMainProtection), as the plane's gh read it just now (null: unreadable). */
|
|
109
|
+
mainUpToDate: boolean | null;
|
|
104
110
|
}): {
|
|
105
111
|
ok: true;
|
|
106
112
|
by: string;
|
|
@@ -117,8 +123,8 @@ export declare function giveUpAutoApproval(store: Store, input: {
|
|
|
117
123
|
why: string;
|
|
118
124
|
now: Date;
|
|
119
125
|
}): void;
|
|
120
|
-
/** A release check failed while checking automatically: tell the owner. The `auto-check` stays
|
|
121
|
-
*
|
|
126
|
+
/** A release check failed while checking automatically: tell the owner. The `auto-check` stays: a rerun the release
|
|
127
|
+
* itself made after confirmed infrastructure may still approve itself; any other failure means a person decides. */
|
|
122
128
|
export declare function noteAutoCheckFailed(store: Store, input: {
|
|
123
129
|
taskId: string;
|
|
124
130
|
why: string;
|
|
@@ -151,3 +157,28 @@ export declare function allowAutoPublish(store: Store, input: {
|
|
|
151
157
|
}): {
|
|
152
158
|
ok: true;
|
|
153
159
|
} | Refusal;
|
|
160
|
+
/**
|
|
161
|
+
* A release filed while its pull request's CI still ran (pipeline-speed.md D2): hold every approval but the automatic
|
|
162
|
+
* road's until the plane reads exact-head CI passing (Codex review round 1, finding 4). Recorded before the task is
|
|
163
|
+
* filed, so no moment exists where the filed task is approvable with CI pending. Idempotent.
|
|
164
|
+
*/
|
|
165
|
+
export declare function holdApprovalForCi(store: Store, input: {
|
|
166
|
+
taskId: string;
|
|
167
|
+
candidate: string;
|
|
168
|
+
now: Date;
|
|
169
|
+
}): void;
|
|
170
|
+
/**
|
|
171
|
+
* Lift a release's CI hold (an older coordinator's) or end its "CI still running" note on the plane's own reading of
|
|
172
|
+
* GitHub CI (never the coordinator's word): fresh, whole, every run GitHub Actions, on exactly the held candidate, and
|
|
173
|
+
* every required check passed (release.ts prChecksVerdict, as a person's road judges it: a person decides about earlier
|
|
174
|
+
* attempts). With `ask`, the owner of a held release is told it needs them now. Nothing held or noted is a pass.
|
|
175
|
+
*/
|
|
176
|
+
export declare function liftCiHold(store: Store, input: {
|
|
177
|
+
taskId: string;
|
|
178
|
+
candidate: string;
|
|
179
|
+
ci: CiObservation;
|
|
180
|
+
ask: boolean;
|
|
181
|
+
now: Date;
|
|
182
|
+
}): {
|
|
183
|
+
ok: true;
|
|
184
|
+
} | Refusal;
|
package/dist/release-auto.js
CHANGED
|
@@ -13,16 +13,19 @@
|
|
|
13
13
|
* adapter. Any refusal unseals `auto-check`, so the task asks a person, its passing check kept.
|
|
14
14
|
*/
|
|
15
15
|
import { assignmentOf } from "./assignment.js";
|
|
16
|
-
import { projectGitDir } from "./check-report.js";
|
|
16
|
+
import { fullCheckProblem, projectGitDir } from "./check-report.js";
|
|
17
|
+
import { sealedRun } from "./release-recheck.js";
|
|
17
18
|
import { runCheckLevel } from "./check-levels.js";
|
|
18
19
|
import { readVerifiedCommit, verifiedBlobs, verifiedTreeDiff, verifiedTreeEntries } from "./commit-extract.js";
|
|
19
20
|
import { mightName, testInfrastructure } from "./test-infra.js";
|
|
20
21
|
import { chatControlHref } from "./chat-controls.js";
|
|
21
|
-
import {
|
|
22
|
+
import { INFRA_RERUNS_PER_CANDIDATE, REQUIRED_PR_CHECKS, REQUIRED_PR_CHECKS_WORKFLOW, prChecksVerdict } from "./release.js";
|
|
23
|
+
import { releaseCheckStanding } from "./check-failure.js";
|
|
22
24
|
import { CARRY_RULES, rulePath } from "./release-carry.js";
|
|
23
25
|
import { AUTO_EVIDENCE_ACTION, AUTO_WITHDRAWN_ACTION, LIVE_RELEASE_STATES, RELEASE_APPROVAL_ACTION, releaseAuthorityOfTask, releaseAutoApproves, releaseApprovalSetting } from "./release-authority.js";
|
|
24
26
|
import { autoReleaseApprovalOf, personApprovalOf } from "./scope.js";
|
|
25
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";
|
|
26
29
|
import { verificationEvidence } from "./verification-evidence.js";
|
|
27
30
|
export { RELEASE_APPROVAL_ACTION, releaseApprovalSetting, releaseAutoApproves };
|
|
28
31
|
/** The setting's words, one place. */
|
|
@@ -212,13 +215,12 @@ export function autoPathProblem(path) {
|
|
|
212
215
|
return null;
|
|
213
216
|
return "it's outside what a release approves itself for (product code, tests, docs, the changelog and a version bump)";
|
|
214
217
|
}
|
|
215
|
-
/** Flakes: how many failed attempts one CI job, and how many unsuccessful release-check runs, may have before a person
|
|
216
|
-
* decides. The head and digest are pinned, so between attempts only nondeterminism differs. */
|
|
217
|
-
export const MAX_FAILED_ATTEMPTS = AUTO_FLAKE_CAP;
|
|
218
218
|
/**
|
|
219
219
|
* GitHub CI on the exact head, as the plane read it (every attempt): fresh, every run GitHub Actions, and the same
|
|
220
220
|
* verdict every later release boundary uses (release.ts prChecksVerdict: exact head, each job by its latest attempt,
|
|
221
|
-
* every required check present),
|
|
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.
|
|
222
224
|
*/
|
|
223
225
|
export function ciVerdict(observation, candidate, now) {
|
|
224
226
|
const no = (problem) => ({ ok: false, problem });
|
|
@@ -232,7 +234,7 @@ export function ciVerdict(observation, candidate, now) {
|
|
|
232
234
|
const other = observation.runs.find(run => run.app !== "github-actions");
|
|
233
235
|
if (other !== undefined)
|
|
234
236
|
return no(`${other.name} was reported by ${other.app ?? "an unknown app"}, not GitHub Actions`);
|
|
235
|
-
const verdict = prChecksVerdict(observation.runs, candidate, REQUIRED_PR_CHECKS, {
|
|
237
|
+
const verdict = prChecksVerdict(observation.runs, candidate, REQUIRED_PR_CHECKS, { auto: true, workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
236
238
|
if (verdict.state === "failed")
|
|
237
239
|
return no(verdict.failed.join(", "));
|
|
238
240
|
if (verdict.state === "pending")
|
|
@@ -317,6 +319,19 @@ function changeProblem(repo, base, candidate) {
|
|
|
317
319
|
// Taking a test away is never automatic, wherever it lives.
|
|
318
320
|
if (change.kind === "deleted" && TEST_FILE.test(rulePath(change.path)))
|
|
319
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
|
+
}
|
|
320
335
|
const why = autoPathProblem(change.path);
|
|
321
336
|
if (why !== null)
|
|
322
337
|
return `${change.path} changed (${why})`;
|
|
@@ -348,6 +363,12 @@ export function sealAutoGreen(store, input) {
|
|
|
348
363
|
const facts = releaseTaskFacts(store, input);
|
|
349
364
|
if (!facts.ok)
|
|
350
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\")");
|
|
351
372
|
const checking = autoReleaseApprovalOf(store, input.taskId, "auto-check");
|
|
352
373
|
if (!checking.ok)
|
|
353
374
|
return refuse(`its check wasn't authorized automatically: ${checking.why}`);
|
|
@@ -362,15 +383,26 @@ export function sealAutoGreen(store, input) {
|
|
|
362
383
|
return refuse(`run ${input.run} recorded no commit`);
|
|
363
384
|
if (runCheckLevel(store, run.id) !== "full")
|
|
364
385
|
return refuse("the release check wasn't Full");
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// same signed scope
|
|
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.
|
|
368
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
|
|
369
391
|
WHERE r.task_ref = ? AND r.role = 'builder' AND r.id <> ?
|
|
370
392
|
AND NOT (COALESCE(r.outcome, '') IN ('built', 'no-change') AND COALESCE(c.status, '') = 'passed') ORDER BY r.id`).all(facts.taskRef, run.id);
|
|
371
393
|
if (failedRuns.some(one => one["scope_digest"] !== input.digest))
|
|
372
394
|
return refuse("an earlier check of this release ran on different scope text, so a person decides");
|
|
373
|
-
if (failedRuns.
|
|
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)
|
|
374
406
|
return refuse(`the release check failed ${failedRuns.length} times before it passed, so a person decides`);
|
|
375
407
|
const checkFailures = failedRuns.map(one => Number(one["id"]));
|
|
376
408
|
const newest = store.runsFor(facts.taskRef).find(one => one.role === "builder");
|
|
@@ -391,6 +423,22 @@ export function sealAutoGreen(store, input) {
|
|
|
391
423
|
const command = gate["command"];
|
|
392
424
|
if (gate["coverage"] !== undefined && gate["coverage"] !== "full")
|
|
393
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");
|
|
394
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) {
|
|
395
443
|
return refuse("the release check didn't pass this candidate under the approved command");
|
|
396
444
|
}
|
|
@@ -442,14 +490,25 @@ export function giveUpAutoApproval(store, input) {
|
|
|
442
490
|
unseal(store, input.taskId, input.now);
|
|
443
491
|
const scope = store.getScope(input.taskId);
|
|
444
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}`;
|
|
445
504
|
store.enqueueNotification({ dedupeKey: `release-auto-stopped:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto", pushClass: "attention",
|
|
446
505
|
subject: `Release${version === null ? "" : ` ${version}`} needs you`,
|
|
447
|
-
body: `Not approved automatically: ${input.why}. Approve it yourself, or fix the branch and release the new commit
|
|
506
|
+
body: `Not approved automatically: ${input.why}. Approve it yourself, or fix the branch and release the new commit.${running}`,
|
|
448
507
|
link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
|
|
449
508
|
});
|
|
450
509
|
}
|
|
451
|
-
/** A release check failed while checking automatically: tell the owner. The `auto-check` stays
|
|
452
|
-
*
|
|
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. */
|
|
453
512
|
export function noteAutoCheckFailed(store, input) {
|
|
454
513
|
const ref = store.lookupRef(input.taskId);
|
|
455
514
|
if (ref === null)
|
|
@@ -457,7 +516,7 @@ export function noteAutoCheckFailed(store, input) {
|
|
|
457
516
|
const version = store.getScope(input.taskId)?.releaseTerms?.packageVersion ?? null;
|
|
458
517
|
store.enqueueNotification({ dedupeKey: `release-auto-check-failed:${input.taskId}:${input.why.slice(0, 120)}`, kind: "release-auto", pushClass: "attention",
|
|
459
518
|
subject: `Release${version === null ? "" : ` ${version}`} check failed`,
|
|
460
|
-
body: `${input.why}.
|
|
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.`,
|
|
461
520
|
link: chatControlHref("task", input.taskId), source: { taskRef: ref.id } }, input.now);
|
|
462
521
|
}
|
|
463
522
|
/** "Released 0.9.x automatically": the release that approved itself finished. */
|
|
@@ -494,3 +553,60 @@ export function allowAutoPublish(store, input) {
|
|
|
494
553
|
action: PUBLISH_ALLOWED_ACTION, outcome: "allowed", source: "request", detail: `${input.by} lets the release's tag, publication and Homebrew go on` });
|
|
495
554
|
return { ok: true };
|
|
496
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
|
+
}
|
package/dist/release-card.d.ts
CHANGED
|
@@ -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
|
package/dist/release-card.js
CHANGED
|
@@ -25,6 +25,7 @@ import { verifiedRange } from "./commit-extract.js";
|
|
|
25
25
|
import { releaseTermsSchema } from "./contracts/release-terms.js";
|
|
26
26
|
import { routeFromJson } from "./phase-routing.js";
|
|
27
27
|
import { projectName } from "./project.js";
|
|
28
|
+
import { RELEASE_CI_RUNNING_WORDS, releaseCiRunningOf } from "./release-ci-hold.js";
|
|
28
29
|
import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
29
30
|
const NONE = "none";
|
|
30
31
|
/** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
|
|
@@ -152,13 +153,21 @@ export function approvalTermsOf(store, scope, words) {
|
|
|
152
153
|
export const termsText = (lines) => lines.map(line => `${line.label}: ${line.value}`).join("\n");
|
|
153
154
|
const SHA = /^[0-9a-f]{40}$/;
|
|
154
155
|
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
155
|
-
/**
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
|
|
158
|
+
* the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag.
|
|
159
|
+
* Whether the plane recorded one, and its merge commit (eval-harness review 2, finding 4):
|
|
160
|
+
* `recorded: false` only when no merged release is on the record at all; a recorded one whose commit isn't a full id is
|
|
161
|
+
* `recorded: true` with `sha: null` — something to refuse on, never "no previous release".
|
|
162
|
+
*/
|
|
163
|
+
export function releaseBaselineRecord(store, repo) {
|
|
164
|
+
const row = store.handle.prepare(`SELECT o.external_ref FROM release_authority a LEFT JOIN release_operation o
|
|
165
|
+
ON o.authority = a.id AND o.kind = 'merge' AND o.state = 'succeeded'
|
|
166
|
+
WHERE a.repo = ? AND a.state = 'merged' ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
|
|
167
|
+
if (row === undefined)
|
|
168
|
+
return { recorded: false };
|
|
169
|
+
const ref = row["external_ref"];
|
|
170
|
+
return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
|
|
162
171
|
}
|
|
163
172
|
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
164
173
|
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
@@ -198,7 +207,12 @@ function pullRequestCommits(slug, pr) {
|
|
|
198
207
|
}
|
|
199
208
|
/** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
|
|
200
209
|
export function releaseChangeList(store, terms) {
|
|
201
|
-
const
|
|
210
|
+
const record = releaseBaselineRecord(store, terms.repo);
|
|
211
|
+
// A release recorded with a merge commit that can't be read: the list couldn't be read (eval-harness review 2,
|
|
212
|
+
// finding 4) — never the pull request's commits, as if there were no previous release.
|
|
213
|
+
if (record.recorded && record.sha === null)
|
|
214
|
+
return { baseline: null, commits: null, source: "unread", baselineUnreadable: true, digest: sha256(`release-changes:${JSON.stringify({ baseline: null, source: "unread", commits: null, recorded: true })}`) };
|
|
215
|
+
const baseline = record.recorded ? record.sha : null;
|
|
202
216
|
let commits;
|
|
203
217
|
let source;
|
|
204
218
|
if (baseline !== null) {
|
|
@@ -221,7 +235,7 @@ function changeWords(changes, terms, cap) {
|
|
|
221
235
|
const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
|
|
222
236
|
: `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
|
|
223
237
|
if (changes.commits === null)
|
|
224
|
-
return [head, changes.baseline === null ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
238
|
+
return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
225
239
|
if (changes.commits.length === 0)
|
|
226
240
|
return [head, "No commits."];
|
|
227
241
|
return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
|
|
@@ -242,6 +256,8 @@ export function releaseCardOf(store, scope, formDigest, words) {
|
|
|
242
256
|
terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
|
|
243
257
|
// Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
|
|
244
258
|
...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
|
|
259
|
+
// Filed while CI runs (Alex, 2026-10-29): a person may approve now, and is told plainly what still waits.
|
|
260
|
+
...(releaseCiRunningOf(store, scope.taskId) === null ? [] : [RELEASE_CI_RUNNING_WORDS]),
|
|
245
261
|
"",
|
|
246
262
|
...changeWords(changes, terms, 5),
|
|
247
263
|
"",
|
package/dist/release-carry.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TreeChange, type VerifiedCommit } from "./commit-extract.js";
|
|
2
|
-
import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
2
|
+
import { type ReleaseCheckPurpose, type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
3
3
|
import { type ExecResult } from "./exec.js";
|
|
4
4
|
import { type ReleaseAuthority, type ReleaseCarry, type Store } from "./store.js";
|
|
5
5
|
/** The journey scripts: tests, by Alex's decision (they carry). */
|
|
@@ -66,6 +66,26 @@ export declare function releaseTermsFor(store: Store, input: {
|
|
|
66
66
|
ok: false;
|
|
67
67
|
why: string;
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
|
|
71
|
+
* outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
|
|
72
|
+
* and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
|
|
73
|
+
* this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
|
|
74
|
+
* infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
|
|
75
|
+
*/
|
|
76
|
+
export declare function releaseCheckPurposeFor(store: Store, input: {
|
|
77
|
+
repo: string;
|
|
78
|
+
version: string;
|
|
79
|
+
branch: string;
|
|
80
|
+
pr: number;
|
|
81
|
+
candidate: string;
|
|
82
|
+
}): {
|
|
83
|
+
ok: true;
|
|
84
|
+
purpose: ReleaseCheckPurpose;
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
why: string;
|
|
88
|
+
};
|
|
69
89
|
/** One carried commit, as the summary and the merge message list it. */
|
|
70
90
|
export type LandedCommit = {
|
|
71
91
|
sha: string;
|
package/dist/release-carry.js
CHANGED
|
@@ -165,6 +165,28 @@ export function releaseTermsFor(store, input) {
|
|
|
165
165
|
expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
|
|
166
166
|
} };
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
|
|
170
|
+
* outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
|
|
171
|
+
* and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
|
|
172
|
+
* this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
|
|
173
|
+
* infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
|
|
174
|
+
*/
|
|
175
|
+
export function releaseCheckPurposeFor(store, input) {
|
|
176
|
+
const gitDir = projectGitDir(input.repo);
|
|
177
|
+
if (gitDir === null)
|
|
178
|
+
return { ok: false, why: "the project's Git directory can't be read" };
|
|
179
|
+
const commit = readVerifiedCommit(gitDir, input.candidate);
|
|
180
|
+
if (!commit.ok)
|
|
181
|
+
return { ok: false, why: commit.message };
|
|
182
|
+
const grant = store.liveVerifyCommand(input.repo);
|
|
183
|
+
if (grant === null)
|
|
184
|
+
return { ok: false, why: "the project has no approved release check" };
|
|
185
|
+
return { ok: true, purpose: {
|
|
186
|
+
version: 1, purpose: "release-check", repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
|
|
187
|
+
approvedSha: input.candidate, approvedTree: commit.value.tree, grant: { id: grant.id, digest: grant.digest }, access: store.projectCheckAccess(input.repo),
|
|
188
|
+
} };
|
|
189
|
+
}
|
|
168
190
|
/**
|
|
169
191
|
* Step 2, read-only: nothing from the candidate is installed, built or run here. Objects come from the project's own
|
|
170
192
|
* Git directory as Toolroll records it, hashed here; the authority is read live.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** A release's approval held for its pull request's CI (Codex review round 1, finding 4). A leaf module: the seal
|
|
2
|
+
* primitive (store.ts), the person's ceremony (scope.ts), chat and release-auto.ts all read the same row. */
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* A release filed while its pull request's CI was still running (pipeline-speed.md D2) waits for CI before anyone but
|
|
6
|
+
* the automatic road may approve it (Codex review round 1, finding 4): `release-auto.ts holdApprovalForCi` records the
|
|
7
|
+
* hold before the task is filed, and only the plane's own reading of exact-head CI passing lifts it
|
|
8
|
+
* (`liftCiHold`). The newest row for the task wins; the hold names the candidate commit it waits on.
|
|
9
|
+
* Since Alex's decision of 2026-10-29 (a person may approve during CI) the coordinator records a "CI still running"
|
|
10
|
+
* note instead (below); a hold an older coordinator placed still holds until lifted.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RELEASE_CI_HELD_ACTION = "release approval waits for CI";
|
|
13
|
+
export declare const RELEASE_CI_PASSED_ACTION = "release approval CI passed";
|
|
14
|
+
/** The candidate whose CI a release task's approval waits for, or null when nothing holds it. Fail closed: a hold row
|
|
15
|
+
* whose candidate can't be read still holds. */
|
|
16
|
+
export declare function releaseCiHoldOf(store: Store, taskId: string): {
|
|
17
|
+
candidate: string | null;
|
|
18
|
+
} | null;
|
|
19
|
+
/** Why a person can't approve a release waiting for CI yet, in words. */
|
|
20
|
+
export declare function releaseCiHoldWords(hold: {
|
|
21
|
+
candidate: string | null;
|
|
22
|
+
}): string;
|
|
23
|
+
/**
|
|
24
|
+
* A person's release asks for approval while its pull request's CI still runs (Alex, 2026-10-29; pipeline-speed.md,
|
|
25
|
+
* "Decision: a person's release asks during CI"). This row never holds approval: it lets the approval card say so, until
|
|
26
|
+
* the plane reads exact-head CI passing (`liftCiHold` records RELEASE_CI_PASSED_ACTION, which ends it as it ends a
|
|
27
|
+
* hold). Deployment gates and operation issuance also refuse while it remains, so a direct deploy cannot bypass
|
|
28
|
+
* the coordinator's CI wait. Its absence is not a CI pass: the coordinator still reads exact-head checks itself.
|
|
29
|
+
*/
|
|
30
|
+
export declare const RELEASE_CI_RUNNING_ACTION = "release asked while CI runs";
|
|
31
|
+
export declare const RELEASE_CI_RUNNING_WORDS = "GitHub's checks are still running; nothing deploys until they pass.";
|
|
32
|
+
/** The candidate whose CI was still running when the release task was filed, or null once the plane read it passing. */
|
|
33
|
+
export declare function releaseCiRunningOf(store: Store, taskId: string): {
|
|
34
|
+
candidate: string | null;
|
|
35
|
+
} | null;
|
|
36
|
+
/** Record that the release task is filed while CI runs on `candidate` (before the task exists). Idempotent. */
|
|
37
|
+
export declare function noteCiRunning(store: Store, input: {
|
|
38
|
+
taskId: string;
|
|
39
|
+
candidate: string;
|
|
40
|
+
now: Date;
|
|
41
|
+
}): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A release filed while its pull request's CI was still running (pipeline-speed.md D2) waits for CI before anyone but
|
|
3
|
+
* the automatic road may approve it (Codex review round 1, finding 4): `release-auto.ts holdApprovalForCi` records the
|
|
4
|
+
* hold before the task is filed, and only the plane's own reading of exact-head CI passing lifts it
|
|
5
|
+
* (`liftCiHold`). The newest row for the task wins; the hold names the candidate commit it waits on.
|
|
6
|
+
* Since Alex's decision of 2026-10-29 (a person may approve during CI) the coordinator records a "CI still running"
|
|
7
|
+
* note instead (below); a hold an older coordinator placed still holds until lifted.
|
|
8
|
+
*/
|
|
9
|
+
export const RELEASE_CI_HELD_ACTION = "release approval waits for CI";
|
|
10
|
+
export const RELEASE_CI_PASSED_ACTION = "release approval CI passed";
|
|
11
|
+
/** The candidate whose CI a release task's approval waits for, or null when nothing holds it. Fail closed: a hold row
|
|
12
|
+
* whose candidate can't be read still holds. */
|
|
13
|
+
export function releaseCiHoldOf(store, taskId) {
|
|
14
|
+
const row = store.handle.prepare("SELECT action, detail FROM action_ledger WHERE task_id = ? AND action IN (?, ?) ORDER BY id DESC LIMIT 1")
|
|
15
|
+
.get(taskId, RELEASE_CI_HELD_ACTION, RELEASE_CI_PASSED_ACTION);
|
|
16
|
+
if (row === undefined || row["action"] !== RELEASE_CI_HELD_ACTION)
|
|
17
|
+
return null;
|
|
18
|
+
const detail = typeof row["detail"] === "string" ? row["detail"] : "";
|
|
19
|
+
return { candidate: /^[0-9a-f]{40}$/.test(detail) ? detail : null };
|
|
20
|
+
}
|
|
21
|
+
/** Why a person can't approve a release waiting for CI yet, in words. */
|
|
22
|
+
export function releaseCiHoldWords(hold) {
|
|
23
|
+
const commit = hold.candidate === null ? "this release's commit" : hold.candidate.slice(0, 7);
|
|
24
|
+
return `GitHub CI is still running on ${commit}. This release can be approved once every pull request check passes; \`toolroll release\` asks you then.`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A person's release asks for approval while its pull request's CI still runs (Alex, 2026-10-29; pipeline-speed.md,
|
|
28
|
+
* "Decision: a person's release asks during CI"). This row never holds approval: it lets the approval card say so, until
|
|
29
|
+
* the plane reads exact-head CI passing (`liftCiHold` records RELEASE_CI_PASSED_ACTION, which ends it as it ends a
|
|
30
|
+
* hold). Deployment gates and operation issuance also refuse while it remains, so a direct deploy cannot bypass
|
|
31
|
+
* the coordinator's CI wait. Its absence is not a CI pass: the coordinator still reads exact-head checks itself.
|
|
32
|
+
*/
|
|
33
|
+
export const RELEASE_CI_RUNNING_ACTION = "release asked while CI runs";
|
|
34
|
+
export const RELEASE_CI_RUNNING_WORDS = "GitHub's checks are still running; nothing deploys until they pass.";
|
|
35
|
+
/** The candidate whose CI was still running when the release task was filed, or null once the plane read it passing. */
|
|
36
|
+
export function releaseCiRunningOf(store, taskId) {
|
|
37
|
+
const row = store.handle.prepare("SELECT action, detail FROM action_ledger WHERE task_id = ? AND action IN (?, ?) ORDER BY id DESC LIMIT 1")
|
|
38
|
+
.get(taskId, RELEASE_CI_RUNNING_ACTION, RELEASE_CI_PASSED_ACTION);
|
|
39
|
+
if (row === undefined || row["action"] !== RELEASE_CI_RUNNING_ACTION)
|
|
40
|
+
return null;
|
|
41
|
+
const detail = typeof row["detail"] === "string" ? row["detail"] : "";
|
|
42
|
+
return { candidate: /^[0-9a-f]{40}$/.test(detail) ? detail : null };
|
|
43
|
+
}
|
|
44
|
+
/** Record that the release task is filed while CI runs on `candidate` (before the task exists). Idempotent. */
|
|
45
|
+
export function noteCiRunning(store, input) {
|
|
46
|
+
if (!/^[0-9a-f]{40}$/.test(input.candidate))
|
|
47
|
+
throw new Error("a CI note needs the exact candidate commit");
|
|
48
|
+
const running = releaseCiRunningOf(store, input.taskId);
|
|
49
|
+
if (running !== null && running.candidate === input.candidate)
|
|
50
|
+
return;
|
|
51
|
+
store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
|
|
52
|
+
action: RELEASE_CI_RUNNING_ACTION, outcome: "running", source: "work", detail: input.candidate });
|
|
53
|
+
}
|
package/dist/release-cli.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type ReleaseAdapters } from "./release.js";
|
|
9
9
|
import { type GateReleaseWorld } from "./release-gate.js";
|
|
10
|
+
import { type RescueWorld } from "./deploy-rescue.js";
|
|
10
11
|
export declare const RELEASE_HELP: string;
|
|
11
12
|
/** A runner lease id (claim.ts mints randomUUID()). */
|
|
12
13
|
export declare const LEASE_SHAPED: RegExp;
|
|
@@ -20,6 +21,8 @@ export type ReleaseCliDeps = {
|
|
|
20
21
|
warn?: (line: string) => void;
|
|
21
22
|
/** What --release-gate reads and changes (release-gate.ts); the installation's own by default. */
|
|
22
23
|
gateWorld?: GateReleaseWorld;
|
|
24
|
+
/** What --recover-deploy reads and runs (deploy-rescue.ts); the installation's own by default. */
|
|
25
|
+
rescueWorld?: RescueWorld;
|
|
23
26
|
/** The plane's database for the authority commands (default: this installation's). */
|
|
24
27
|
database?: string;
|
|
25
28
|
now?: () => Date;
|