toolroll 0.9.92 → 0.9.94
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 +87 -0
- package/dist/activity-line.d.ts +7 -4
- package/dist/activity-line.js +9 -7
- package/dist/agent-fence.d.ts +4 -0
- package/dist/agent-fence.js +4 -1
- package/dist/approval-authority.d.ts +7 -1
- package/dist/approval-authority.js +11 -2
- package/dist/approval-password.d.ts +31 -0
- package/dist/approval-password.js +26 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +3 -3
- package/dist/assignment.js +16 -3
- package/dist/boot-identity.d.ts +2 -1
- package/dist/boot-identity.js +6 -4
- package/dist/bounded-spawn.d.ts +57 -0
- package/dist/bounded-spawn.js +220 -0
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +26 -26
- package/dist/browser-crew.js +2 -2
- package/dist/browser-shell.d.ts +1 -1
- package/dist/browser-shell.js +2 -2
- package/dist/browser-workspace.d.ts +48 -7
- package/dist/browser-workspace.js +3 -3
- package/dist/builder-restart.d.ts +21 -0
- package/dist/builder-restart.js +50 -0
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +73 -25
- package/dist/chat-acceptance.js +1 -1
- package/dist/chat-actions.d.ts +12 -2
- package/dist/chat-actions.js +25 -2
- package/dist/chat-channel.js +11 -0
- package/dist/chat-decide.d.ts +7 -2
- package/dist/chat-decide.js +37 -9
- package/dist/chat-quiet.js +3 -3
- package/dist/chat-voice.d.ts +1 -1
- package/dist/chat-voice.js +2 -2
- package/dist/check-facts.d.ts +126 -0
- package/dist/check-facts.js +318 -0
- package/dist/check-failure.d.ts +22 -3
- package/dist/check-failure.js +57 -13
- package/dist/check-levels-ui.d.ts +4 -0
- package/dist/check-levels-ui.js +10 -1
- package/dist/check-load.d.ts +85 -0
- package/dist/check-load.js +147 -0
- package/dist/check-only-standing.d.ts +36 -0
- package/dist/check-only-standing.js +39 -0
- package/dist/check-only.d.ts +15 -0
- package/dist/check-only.js +99 -0
- package/dist/check-report.d.ts +7 -0
- package/dist/check-report.js +4 -3
- package/dist/check-stamp.d.ts +46 -0
- package/dist/check-stamp.js +93 -0
- package/dist/checkout-cleanup.d.ts +36 -5
- package/dist/checkout-cleanup.js +166 -14
- package/dist/cli-help.d.ts +4 -1
- package/dist/cli-help.js +47 -30
- package/dist/cli.js +16 -1
- package/dist/code-extract.d.ts +44 -0
- package/dist/code-extract.js +88 -0
- package/dist/code-map-acts.d.ts +24 -0
- package/dist/code-map-acts.js +53 -0
- package/dist/code-map-admission.d.ts +52 -0
- package/dist/code-map-admission.js +295 -0
- package/dist/code-map-build.d.ts +63 -0
- package/dist/code-map-build.js +415 -0
- package/dist/code-map-cli.d.ts +153 -0
- package/dist/code-map-cli.js +103 -0
- package/dist/code-map-main.d.ts +1 -0
- package/dist/code-map-main.js +48 -0
- package/dist/code-map-read.d.ts +106 -0
- package/dist/code-map-read.js +171 -0
- package/dist/code-map-report.d.ts +37 -0
- package/dist/code-map-report.js +95 -0
- package/dist/code-map-shape.d.ts +172 -0
- package/dist/code-map-shape.js +327 -0
- package/dist/code-map-store.d.ts +136 -0
- package/dist/code-map-store.js +233 -0
- package/dist/coding-provider.d.ts +2 -0
- package/dist/coding-provider.js +3 -2
- package/dist/coding-update.js +3 -2
- package/dist/coding-workspace.js +4 -4
- package/dist/console-chrome.js +2 -2
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +14 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +52 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +12 -5
- package/dist/contracts/console-api.js +12 -5
- package/dist/contracts/gateway-tools.d.ts +17 -0
- package/dist/contracts/gateway-tools.js +3 -1
- package/dist/contracts/lead-tools.d.ts +61 -0
- package/dist/contracts/lead-tools.js +10 -1
- package/dist/contracts/project-knowledge.d.ts +38 -5
- package/dist/contracts/project-knowledge.js +11 -5
- package/dist/contracts/project-memory.d.ts +3 -1
- package/dist/contracts/project-memory.js +3 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/contracts/storage.d.ts +14 -0
- package/dist/contracts/storage.js +3 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/youiyoux-request.d.ts +1 -1
- package/dist/contracts/youiyoux-request.js +1 -1
- package/dist/desktop-access.js +6 -2
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/dispatch.js +18 -15
- package/dist/dispose.js +8 -2
- package/dist/easy-start.js +2 -3
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/evals.js +2 -1
- package/dist/fast-start.d.ts +136 -0
- package/dist/fast-start.js +431 -0
- package/dist/fast-terms.d.ts +9 -0
- package/dist/fast-terms.js +23 -0
- package/dist/fenced-check-coverage.d.ts +62 -0
- package/dist/fenced-check-coverage.js +54 -0
- package/dist/first-run.js +1 -1
- package/dist/flaky-ci.d.ts +31 -0
- package/dist/flaky-ci.js +149 -0
- package/dist/flaky-cli.d.ts +60 -0
- package/dist/flaky-cli.js +60 -0
- package/dist/flaky.d.ts +83 -0
- package/dist/flaky.js +264 -0
- package/dist/flow-card-task.js +3 -0
- package/dist/flow-triggers.d.ts +1 -1
- package/dist/flow-triggers.js +1 -1
- package/dist/github-import.d.ts +62 -0
- package/dist/github-import.js +146 -0
- package/dist/guides.js +15 -10
- package/dist/invoke.d.ts +5 -1
- package/dist/invoke.js +49 -14
- package/dist/job-object-helper.ps1 +10 -3
- package/dist/keymap.d.ts +41 -0
- package/dist/keymap.js +51 -0
- package/dist/knowledge-cli.js +1 -1
- package/dist/knowledge-ui.d.ts +21 -4
- package/dist/knowledge-ui.js +75 -7
- package/dist/laptop-background.d.ts +16 -0
- package/dist/laptop-background.js +108 -2
- package/dist/lead-context.js +1 -1
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-progress.js +3 -0
- package/dist/lead-status.d.ts +2 -0
- package/dist/lead-status.js +6 -2
- package/dist/lead-tools.js +51 -2
- package/dist/lead.js +1 -1
- package/dist/lesson-notes.d.ts +81 -0
- package/dist/lesson-notes.js +336 -0
- package/dist/limits-ui.js +1 -1
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/machine-check.d.ts +16 -0
- package/dist/machine-check.js +26 -0
- package/dist/machine-identity.d.ts +75 -0
- package/dist/machine-identity.js +170 -0
- package/dist/mcp-core.js +11 -1
- package/dist/memory-cli.d.ts +300 -1
- package/dist/memory-cli.js +62 -9
- package/dist/memory-pass.d.ts +16 -0
- package/dist/memory-pass.js +67 -3
- package/dist/memory-tidy.d.ts +181 -0
- package/dist/memory-tidy.js +523 -0
- package/dist/memory-use.d.ts +35 -0
- package/dist/memory-use.js +109 -0
- package/dist/merge-checklist.d.ts +55 -0
- package/dist/merge-checklist.js +182 -0
- package/dist/needs-you.d.ts +32 -13
- package/dist/needs-you.js +108 -49
- package/dist/nightly-github.d.ts +2 -0
- package/dist/nightly-github.js +4 -4
- package/dist/operate-remote-arguments.js +6 -1
- package/dist/operate-remote.js +3 -1
- package/dist/operate.d.ts +6 -1
- package/dist/operate.js +540 -70
- package/dist/phase-routing.js +2 -2
- package/dist/plain-schema.d.ts +23 -0
- package/dist/plain-schema.js +94 -0
- package/dist/plain-words.d.ts +20 -0
- package/dist/plain-words.js +66 -0
- package/dist/planner.js +2 -1
- package/dist/process-liveness.d.ts +2 -0
- package/dist/process-liveness.js +61 -17
- package/dist/project-delete-ui.d.ts +7 -2
- package/dist/project-delete-ui.js +12 -1
- package/dist/project-delete.js +13 -2
- package/dist/project-knowledge.d.ts +18 -11
- package/dist/project-knowledge.js +39 -9
- package/dist/project-learning.d.ts +2 -1
- package/dist/project-learning.js +15 -45
- package/dist/project-memory.d.ts +5 -0
- package/dist/project-memory.js +22 -2
- package/dist/proof.js +30 -5
- package/dist/provider-auth.js +1 -1
- package/dist/publish.js +9 -1
- package/dist/pull-request-flow.js +5 -0
- package/dist/push.d.ts +26 -3
- package/dist/push.js +55 -15
- package/dist/release-adapters.d.ts +9 -0
- package/dist/release-adapters.js +87 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +26 -17
- package/dist/release-card.d.ts +74 -6
- package/dist/release-card.js +179 -23
- package/dist/release-carry.d.ts +7 -2
- package/dist/release-carry.js +39 -9
- package/dist/release-cli.d.ts +4 -0
- package/dist/release-cli.js +38 -23
- package/dist/release-coverage.js +1 -1
- package/dist/release-next.d.ts +48 -0
- package/dist/release-next.js +161 -0
- package/dist/release.d.ts +50 -1
- package/dist/release.js +118 -11
- package/dist/remote-destination.d.ts +36 -0
- package/dist/remote-destination.js +217 -0
- package/dist/remote-main.d.ts +48 -0
- package/dist/remote-main.js +84 -5
- package/dist/repository-context.d.ts +3 -2
- package/dist/repository-context.js +112 -70
- package/dist/restart-certification.d.ts +1 -0
- package/dist/restart-certification.js +8 -6
- package/dist/result-acts.d.ts +29 -21
- package/dist/result-acts.js +36 -25
- package/dist/result-follow-ups.d.ts +2 -2
- package/dist/result-follow-ups.js +18 -4
- package/dist/result-review.d.ts +1 -1
- package/dist/route-change-check.d.ts +4 -0
- package/dist/route-change-check.js +13 -0
- package/dist/route-change.d.ts +22 -0
- package/dist/route-change.js +89 -0
- package/dist/route-hooks.js +3 -2
- package/dist/route-results.d.ts +6 -1
- package/dist/route-results.js +14 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +7 -5
- package/dist/runner-process.js +3 -2
- package/dist/runner.js +2 -1
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +22 -9
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +24 -18
- package/dist/server/chat.js +14 -12
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +26 -35
- package/dist/server/console.css +23 -0
- package/dist/server/guards.js +4 -0
- package/dist/server/home.d.ts +6 -0
- package/dist/server/home.js +21 -13
- package/dist/server/http.js +1 -1
- package/dist/server/kit.js +1 -1
- package/dist/server/merge-ready-view.d.ts +10 -0
- package/dist/server/merge-ready-view.js +30 -0
- package/dist/server/pages-assets.d.ts +1 -0
- package/dist/server/pages-assets.js +8 -5
- package/dist/server/pages.js +103 -82
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-code-map.d.ts +30 -0
- package/dist/server/render-code-map.js +137 -0
- package/dist/server/render-flaky.d.ts +20 -0
- package/dist/server/render-flaky.js +59 -0
- package/dist/server/render-history.js +1 -1
- package/dist/server/render-new-task.d.ts +26 -1
- package/dist/server/render-new-task.js +30 -5
- package/dist/server/render-pages.d.ts +1 -175
- package/dist/server/render-pages.js +38 -313
- package/dist/server/render-projects.d.ts +2 -1
- package/dist/server/render-projects.js +9 -7
- package/dist/server/render-release.d.ts +19 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +2 -2
- package/dist/server/render-results.js +71 -73
- package/dist/server/render-tasks.d.ts +13 -2
- package/dist/server/render-tasks.js +156 -139
- package/dist/server/retired-routes.d.ts +17 -0
- package/dist/server/retired-routes.js +53 -0
- package/dist/server/route-table.d.ts +4 -1
- package/dist/server/route-table.js +52 -13
- package/dist/server/settings.js +129 -22
- package/dist/server/task-views.js +61 -26
- package/dist/server/tasks.d.ts +17 -6
- package/dist/server/tasks.js +273 -338
- package/dist/server/welcome.js +1 -1
- package/dist/settings-groups-ui.js +1 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +295 -0
- package/dist/storage-sweep-process.js +1 -1
- package/dist/storage-sweep.d.ts +5 -3
- package/dist/storage-sweep.js +30 -9
- package/dist/storage-ui.js +28 -17
- package/dist/storage.d.ts +27 -2
- package/dist/storage.js +39 -2
- package/dist/store.d.ts +148 -34
- package/dist/store.js +626 -177
- package/dist/surface.js +46 -5
- package/dist/sweep-quarantine.d.ts +1 -0
- package/dist/sweep-quarantine.js +6 -4
- package/dist/tamper-scan.d.ts +4 -0
- package/dist/tamper-scan.js +90 -0
- package/dist/task-aliases.d.ts +5 -0
- package/dist/task-aliases.js +9 -0
- package/dist/task-control.js +4 -1
- package/dist/task-outcome-cli.d.ts +2 -0
- package/dist/task-outcome-cli.js +36 -17
- package/dist/task-sizing.d.ts +5 -0
- package/dist/task-sizing.js +2 -0
- package/dist/task-source.d.ts +7 -0
- package/dist/task-source.js +14 -0
- package/dist/task-status.d.ts +9 -0
- package/dist/task-status.js +40 -49
- package/dist/teardown-git.d.ts +16 -0
- package/dist/teardown-git.js +63 -5
- package/dist/telegram-progress.js +5 -3
- package/dist/telegram-status.js +6 -4
- package/dist/test-infra.d.ts +8 -0
- package/dist/test-infra.js +18 -0
- package/dist/tidy.js +1 -1
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +18 -5
- package/dist/tui-contract.d.ts +24 -0
- package/dist/tui-source.js +67 -8
- package/dist/tui.js +51 -51
- package/dist/vanished-retry.d.ts +30 -0
- package/dist/vanished-retry.js +174 -0
- package/dist/work-index.d.ts +2 -0
- package/dist/work-index.js +82 -70
- package/dist/work-summary.d.ts +2 -2
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +28 -22
- package/dist/worktree.d.ts +12 -3
- package/dist/worktree.js +68 -13
- package/package.json +1 -1
- package/dist/server/render-next.d.ts +0 -63
- package/dist/server/render-next.js +0 -109
package/dist/release-card.js
CHANGED
|
@@ -20,13 +20,17 @@ import { createHash } from "node:crypto";
|
|
|
20
20
|
import { lstatSync, realpathSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { checkAccessWords } from "./check-access.js";
|
|
23
|
+
import { ranWords } from "./check-stamp.js";
|
|
23
24
|
import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
|
|
24
25
|
import { verifiedRange } from "./commit-extract.js";
|
|
25
|
-
import { releaseTermsSchema } from "./contracts/release-terms.js";
|
|
26
|
+
import { releaseCheckPurposeSchema, releaseTermsSchema } from "./contracts/release-terms.js";
|
|
27
|
+
import { readVerifiedCommit } from "./commit-extract.js";
|
|
28
|
+
import { cleanTerminalLine } from "./terminal-text.js";
|
|
26
29
|
import { routeFromJson } from "./phase-routing.js";
|
|
27
30
|
import { projectName } from "./project.js";
|
|
28
31
|
import { RELEASE_CI_RUNNING_WORDS, releaseCiRunningOf } from "./release-ci-hold.js";
|
|
29
32
|
import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
33
|
+
import { taskCheckStamp } from "./store.js";
|
|
30
34
|
import { nightlyReleaseLines } from "./nightly-github.js";
|
|
31
35
|
const NONE = "none";
|
|
32
36
|
/** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
|
|
@@ -91,6 +95,30 @@ export const RELEASE_TERM_LABELS = {
|
|
|
91
95
|
"policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
|
|
92
96
|
"grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
|
|
93
97
|
};
|
|
98
|
+
/** Every field of a release check's purpose (contracts/release-terms.ts releaseCheckPurposeSchema): the sealed form
|
|
99
|
+
* Toolroll's own releases take while their checks run outside the sandbox. A contract test checks every leaf has one. */
|
|
100
|
+
export const RELEASE_CHECK_LABELS = {
|
|
101
|
+
version: "Release check version", purpose: "Approved as", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request",
|
|
102
|
+
base: "Base", approvedSha: "Approved commit", approvedTree: "Approved tree", "grant.id": "Release check grant", "grant.digest": "Release check grant digest",
|
|
103
|
+
access: "Release check access",
|
|
104
|
+
};
|
|
105
|
+
export function releaseSealOf(scope) {
|
|
106
|
+
if (scope === null)
|
|
107
|
+
return null;
|
|
108
|
+
if (scope.releaseTerms != null)
|
|
109
|
+
return { kind: "terms", terms: scope.releaseTerms, identity: scope.releaseTerms };
|
|
110
|
+
if (scope.releaseCheck != null)
|
|
111
|
+
return { kind: "check", purpose: scope.releaseCheck, identity: scope.releaseCheck };
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
/** What an unsandboxed (or sign-ins) release check means, said before anyone approves it. Null in the sandbox. */
|
|
115
|
+
export function releaseAccessWords(access) {
|
|
116
|
+
if (access === "unsandboxed")
|
|
117
|
+
return "The release check runs this commit's code with full access to this computer.";
|
|
118
|
+
if (access === "sign-ins")
|
|
119
|
+
return "The release check runs this commit's code with your sign-ins on this computer.";
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
94
122
|
/** The leaf paths of a zod object schema (nested objects joined with dots). */
|
|
95
123
|
export function schemaLeaves(schema, prefix = "") {
|
|
96
124
|
const shape = schema.shape;
|
|
@@ -140,6 +168,15 @@ export function approvalTermsOf(store, scope, words) {
|
|
|
140
168
|
lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
|
|
141
169
|
}
|
|
142
170
|
const terms = scope.releaseTerms ?? null;
|
|
171
|
+
// A release check's purpose (the form Toolroll's own releases seal): every one of its fields, never "none".
|
|
172
|
+
if (terms === null && scope.releaseCheck != null) {
|
|
173
|
+
const purpose = scope.releaseCheck;
|
|
174
|
+
for (const path of schemaLeaves(releaseCheckPurposeSchema)) {
|
|
175
|
+
const value = path === "pr" ? `#${purpose.pr}` : path === "purpose" ? "a release check: every later commit asks again" : said(valueAt(purpose, path));
|
|
176
|
+
lines.push({ key: `releaseCheck.${path}`, label: RELEASE_CHECK_LABELS[path] ?? path, value });
|
|
177
|
+
}
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
143
180
|
for (const path of schemaLeaves(releaseTermsSchema)) {
|
|
144
181
|
const label = RELEASE_TERM_LABELS[path] ?? path;
|
|
145
182
|
const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
|
|
@@ -195,20 +232,120 @@ export function commitsBetween(repo, from, to) {
|
|
|
195
232
|
const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
|
|
196
233
|
if (!range.ok)
|
|
197
234
|
return null;
|
|
198
|
-
return range.value.map(one => ({ sha: one.id, subject: one.message
|
|
235
|
+
return range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) }));
|
|
199
236
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
237
|
+
/** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
|
|
238
|
+
export const PULL_REQUEST_COMMITS_MAX = 250;
|
|
239
|
+
/** A commit subject is someone else's text: one line, control-free (src/terminal-text.ts), bounded. The card digest
|
|
240
|
+
* binds this cleaned text, so every surface shows exactly what was bound (Codex round 2, finding 7). */
|
|
241
|
+
export const subjectText = (subject) => cleanTerminalLine(subject.split("\n")[0] ?? "").trim().slice(0, 200);
|
|
242
|
+
const ghRead = args => {
|
|
243
|
+
const read = spawnSync(releaseTools().gh, args, { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
244
|
+
return { status: read.status, stdout: String(read.stdout ?? "") };
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* A pull request's own commits from GitHub, for the recorded owner/name, complete and bound to the candidate (Codex
|
|
248
|
+
* round 2, finding 6): every page (`--paginate`), accepted only when the pull request's head is `head`, the newest
|
|
249
|
+
* listed commit is `head` and the count is the pull request's own count. Otherwise why, never a shortened list.
|
|
250
|
+
*/
|
|
251
|
+
export function pullRequestCommits(slug, pr, head, read = ghRead) {
|
|
252
|
+
const about = read(["api", `repos/${slug}/pulls/${pr}`, "--jq", '[.head.sha, (.commits|tostring)] | join("\\t")']);
|
|
253
|
+
if (about.status !== 0)
|
|
254
|
+
return { ok: false, why: "GitHub couldn't be read" };
|
|
255
|
+
const [prHead, countText] = about.stdout.trim().split("\t");
|
|
256
|
+
const total = Number(countText);
|
|
257
|
+
if (prHead !== head)
|
|
258
|
+
return { ok: false, why: `the pull request is at ${SHA.test(prHead ?? "") ? prHead.slice(0, 7) : "another commit"}, not ${head.slice(0, 7)}` };
|
|
259
|
+
if (!Number.isSafeInteger(total) || total < 1)
|
|
260
|
+
return { ok: false, why: "GitHub didn't say how many commits the pull request has" };
|
|
261
|
+
if (total > PULL_REQUEST_COMMITS_MAX)
|
|
262
|
+
return { ok: false, why: `incomplete: the pull request has ${total} commits and GitHub lists only ${PULL_REQUEST_COMMITS_MAX}` };
|
|
263
|
+
const listed = read(["api", "--paginate", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + ((.commit.message | split("\\n")[0]) | @json)']);
|
|
264
|
+
if (listed.status !== 0)
|
|
265
|
+
return { ok: false, why: "GitHub couldn't be read" };
|
|
266
|
+
const commits = [];
|
|
267
|
+
for (const line of listed.stdout.split("\n")) {
|
|
268
|
+
if (line === "")
|
|
269
|
+
continue;
|
|
270
|
+
const tab = line.indexOf("\t");
|
|
271
|
+
const sha = line.slice(0, tab);
|
|
272
|
+
let subject;
|
|
273
|
+
try {
|
|
274
|
+
subject = JSON.parse(line.slice(tab + 1));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return { ok: false, why: "GitHub's answer didn't parse" };
|
|
278
|
+
}
|
|
279
|
+
if (tab < 0 || !SHA.test(sha) || typeof subject !== "string")
|
|
280
|
+
return { ok: false, why: "GitHub's answer didn't parse" };
|
|
281
|
+
commits.push({ sha, subject: subjectText(subject) });
|
|
282
|
+
}
|
|
283
|
+
if (commits.length !== total)
|
|
284
|
+
return { ok: false, why: `incomplete: GitHub listed ${commits.length} of ${total} commits` };
|
|
285
|
+
if (commits[commits.length - 1].sha !== head)
|
|
286
|
+
return { ok: false, why: `the newest commit GitHub listed isn't ${head.slice(0, 7)}` };
|
|
287
|
+
if (new Set(commits.map(one => one.sha)).size !== commits.length)
|
|
288
|
+
return { ok: false, why: "GitHub listed a commit twice" };
|
|
289
|
+
return { ok: true, commits: commits.reverse() };
|
|
290
|
+
}
|
|
291
|
+
/** The ledger act that records a verified release (the merged tree proved equal to the approved one). */
|
|
292
|
+
export const RELEASE_VERIFIED_ACTION = "release verified";
|
|
293
|
+
/** The project's own Git directory: `<realpath(repo)>/.git`, a real directory. */
|
|
294
|
+
function gitDirOf(repo) {
|
|
295
|
+
try {
|
|
296
|
+
const named = join(realpathSync(repo), ".git");
|
|
297
|
+
return lstatSync(named).isDirectory() ? realpathSync(named) : null;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
205
300
|
return null;
|
|
206
|
-
|
|
207
|
-
.filter(one => SHA.test(one.sha)).reverse();
|
|
301
|
+
}
|
|
208
302
|
}
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Record a verified release (Codex round 2, finding 1: verified release history apart from carrying). The coordinator
|
|
305
|
+
* calls it once main's merge commit was proved to hold the approved tree; the plane proves it again here, from its own
|
|
306
|
+
* records only: the task is a release scope of a project, and `merge` read as a verified Git object from the project's
|
|
307
|
+
* recorded Git directory has exactly the sealed approved tree. False when anything doesn't hold (nothing written).
|
|
308
|
+
*/
|
|
309
|
+
export function recordVerifiedRelease(store, input) {
|
|
310
|
+
if (!SHA.test(input.merge))
|
|
311
|
+
return false;
|
|
312
|
+
const repo = store.lookupRef(input.taskId)?.repo ?? null;
|
|
313
|
+
const seal = releaseSealOf(store.getScope(input.taskId));
|
|
314
|
+
if (repo === null || seal === null || seal.identity.repo !== repo)
|
|
315
|
+
return false;
|
|
316
|
+
const gitDir = gitDirOf(repo);
|
|
317
|
+
if (gitDir === null)
|
|
318
|
+
return false;
|
|
319
|
+
const commit = readVerifiedCommit(gitDir, input.merge);
|
|
320
|
+
if (!commit.ok || commit.value.tree !== seal.identity.approvedTree)
|
|
321
|
+
return false;
|
|
322
|
+
if (store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id = ? AND action = ? AND detail = ?").get(input.taskId, RELEASE_VERIFIED_ACTION, input.merge) !== undefined)
|
|
323
|
+
return true;
|
|
324
|
+
store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo, taskId: input.taskId, runId: null, action: RELEASE_VERIFIED_ACTION, outcome: seal.identity.packageVersion,
|
|
325
|
+
source: "work", detail: input.merge });
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The last verified release of `repo`, from either record: a merged release authority's succeeded merge, or a verified
|
|
330
|
+
* release row (recordVerifiedRelease). The newer stands. Shaped as releaseBaselineRecord.
|
|
331
|
+
*/
|
|
332
|
+
export function verifiedReleaseBaseline(store, repo) {
|
|
333
|
+
const authority = store.handle.prepare(`SELECT o.external_ref, COALESCE(o.finished_at, '') AS at FROM release_authority a LEFT JOIN release_operation o
|
|
334
|
+
ON o.authority = a.id AND o.kind = 'merge' AND o.state = 'succeeded'
|
|
335
|
+
WHERE a.repo = ? AND a.state = 'merged' ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
|
|
336
|
+
const verified = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, RELEASE_VERIFIED_ACTION);
|
|
337
|
+
const verifiedSha = typeof verified?.["detail"] === "string" && SHA.test(verified["detail"]) ? verified["detail"] : null;
|
|
338
|
+
if (verified !== undefined && verifiedSha !== null && (authority === undefined || String(verified["at"]) >= String(authority["at"])))
|
|
339
|
+
return { recorded: true, sha: verifiedSha };
|
|
340
|
+
if (authority === undefined)
|
|
341
|
+
return verified === undefined ? { recorded: false } : { recorded: true, sha: null };
|
|
342
|
+
const ref = authority["external_ref"];
|
|
343
|
+
return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
|
|
344
|
+
}
|
|
345
|
+
/** What changed since the last verified release, for a release's sealed identity (terms or a release check's purpose),
|
|
346
|
+
* from Toolroll's records only. */
|
|
347
|
+
export function releaseChangeList(store, terms, read) {
|
|
348
|
+
const record = verifiedReleaseBaseline(store, terms.repo);
|
|
212
349
|
// A release recorded with a merge commit that can't be read: the list couldn't be read (eval-harness review 2,
|
|
213
350
|
// finding 4) — never the pull request's commits, as if there were no previous release.
|
|
214
351
|
if (record.recorded && record.sha === null)
|
|
@@ -216,6 +353,7 @@ export function releaseChangeList(store, terms) {
|
|
|
216
353
|
const baseline = record.recorded ? record.sha : null;
|
|
217
354
|
let commits;
|
|
218
355
|
let source;
|
|
356
|
+
let why;
|
|
219
357
|
if (baseline !== null) {
|
|
220
358
|
commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
|
|
221
359
|
source = commits === null ? "unread" : "recorded";
|
|
@@ -223,10 +361,14 @@ export function releaseChangeList(store, terms) {
|
|
|
223
361
|
else {
|
|
224
362
|
const remote = store.projectRemote(terms.repo);
|
|
225
363
|
const parsed = remote === null ? null : releaseRemote(remote);
|
|
226
|
-
|
|
364
|
+
const listed = parsed === null ? { ok: false, why: "the project has no GitHub remote on record" } : pullRequestCommits(parsed.slug, terms.pr, terms.approvedSha, read);
|
|
365
|
+
commits = listed.ok ? listed.commits : null;
|
|
366
|
+
if (!listed.ok)
|
|
367
|
+
why = listed.why;
|
|
227
368
|
source = commits === null ? "unread" : "pull-request";
|
|
228
369
|
}
|
|
229
|
-
return { baseline, commits, source,
|
|
370
|
+
return { baseline, commits, source, ...(why === undefined ? {} : { why }),
|
|
371
|
+
digest: sha256(`release-changes:${JSON.stringify(why === undefined ? { baseline, source, commits } : { baseline, source, commits, why })}`) };
|
|
230
372
|
}
|
|
231
373
|
/** The card digest: everything the person is shown that can change under them. */
|
|
232
374
|
export function cardDigestOf(scopeDigest, formDigest, changes) {
|
|
@@ -236,25 +378,39 @@ function changeWords(changes, terms, cap) {
|
|
|
236
378
|
const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
|
|
237
379
|
: `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
|
|
238
380
|
if (changes.commits === null)
|
|
239
|
-
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."];
|
|
381
|
+
return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read${changes.why === undefined ? "" : ` (${changes.why})`}; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
240
382
|
if (changes.commits.length === 0)
|
|
241
383
|
return [head, "No commits."];
|
|
242
384
|
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`] : [])];
|
|
243
385
|
}
|
|
244
386
|
/** The release card for a deploy gate, against the live records now. */
|
|
245
|
-
|
|
387
|
+
/** How this task's last check ran, when one did and it wasn't plainly sandboxed: the sealed level above is what will run;
|
|
388
|
+
* this is what did. Nothing for a release no check has run for. */
|
|
389
|
+
function lastCheckWords(store, repo, taskId) {
|
|
390
|
+
const ran = repo === null ? null : taskCheckStamp(store, repo, taskId);
|
|
391
|
+
const words = ran === null ? null : ranWords(ran.reading);
|
|
392
|
+
return words === null ? [] : [`Last check: ${words}.`];
|
|
393
|
+
}
|
|
394
|
+
export function releaseCardOf(store, scope, formDigest, words, read) {
|
|
246
395
|
const terms = scope.releaseTerms ?? null;
|
|
396
|
+
// Either sealed form (Codex round 2, finding 1): the identity both share, for the head lines and the change list.
|
|
397
|
+
const identity = releaseSealOf(scope)?.identity ?? null;
|
|
247
398
|
const lines = approvalTermsOf(store, scope, words);
|
|
248
399
|
const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
|
|
249
|
-
const changes =
|
|
400
|
+
const changes = identity === null
|
|
250
401
|
? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
|
|
251
|
-
: releaseChangeList(store,
|
|
252
|
-
const version =
|
|
402
|
+
: releaseChangeList(store, identity, read);
|
|
403
|
+
const version = identity?.packageVersion ?? null;
|
|
253
404
|
const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
|
|
254
405
|
const short = [
|
|
255
406
|
version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
|
|
256
|
-
|
|
257
|
-
terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits:
|
|
407
|
+
identity === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(identity.repo)} · ${identity.branch} · pull request #${identity.pr} · commit ${identity.approvedSha.slice(0, 7)}`,
|
|
408
|
+
terms === null ? identity === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: no, every later commit asks again · checks: ${value("checks")}`
|
|
409
|
+
: `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
|
|
410
|
+
// What an unsandboxed check means, before consent (Codex round 2, finding 1).
|
|
411
|
+
...(identity === null || releaseAccessWords(identity.access) === null ? [] : [releaseAccessWords(identity.access)]),
|
|
412
|
+
// A check that already ran for this task, from its stamp (H2): never read as sandboxed when it can't be shown.
|
|
413
|
+
...lastCheckWords(store, identity?.repo ?? null, scope.taskId),
|
|
258
414
|
// Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
|
|
259
415
|
...(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."] : []),
|
|
260
416
|
// Filed while CI runs (Alex, 2026-10-29): a person may approve now, and is told plainly what still waits.
|
|
@@ -262,10 +418,10 @@ export function releaseCardOf(store, scope, formDigest, words) {
|
|
|
262
418
|
// GitHub's nightly (nightly-github.ts): what holds this release, or why it can't be read. Not a signed term.
|
|
263
419
|
...(terms === null ? [] : nightlyReleaseLines(store, { repo: terms.repo, taskId: scope.taskId, candidate: scope.candidate ?? null, now: new Date() })),
|
|
264
420
|
"",
|
|
265
|
-
...changeWords(changes,
|
|
421
|
+
...changeWords(changes, identity, 5),
|
|
266
422
|
"",
|
|
267
423
|
`Every term is in ${fileName}.`,
|
|
268
424
|
].join("\n");
|
|
269
|
-
const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes,
|
|
425
|
+
const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, identity, 200)].join("\n");
|
|
270
426
|
return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
|
|
271
427
|
}
|
package/dist/release-carry.d.ts
CHANGED
|
@@ -44,8 +44,13 @@ export declare function stricterOf(a: ReleasePolicy | null, b: ReleasePolicy | n
|
|
|
44
44
|
* the rules don't rely on that).
|
|
45
45
|
*/
|
|
46
46
|
export declare function rulePath(path: string): string;
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. `fresh`: the
|
|
49
|
+
* paths that didn't exist at the approved commit. A test that was there stays exactly as it was (H4): new tests go in new
|
|
50
|
+
* files. Only "unchanged" holds up, since `vi.mock` hoists and a top-level hook reaches every case above it, so an
|
|
51
|
+
* appended line can disarm what it follows.
|
|
52
|
+
*/
|
|
53
|
+
export declare function changeRefusal(change: TreeChange, policy: ReleasePolicy, fresh?: ReadonlySet<string>): string | null;
|
|
49
54
|
/**
|
|
50
55
|
* A release scope's terms, from the plane's own records: the project's repository, the approved commit and its
|
|
51
56
|
* verified tree, the effective policy, the live release check grant and the check access (only the fence), and the
|
package/dist/release-carry.js
CHANGED
|
@@ -26,14 +26,17 @@ import { join, sep } from "node:path";
|
|
|
26
26
|
import { execFileSync } from "node:child_process";
|
|
27
27
|
import { checkLaunchRoot, fenceFor, linuxFenceAvailable, macosFenceAvailable, runFolders } from "./agent-fence.js";
|
|
28
28
|
import { touchesProtected } from "./approval-policy.js";
|
|
29
|
-
import { extractCommit, readVerifiedCommit, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
|
|
29
|
+
import { extractCommit, readVerifiedCommit, verifiedBlobs, verifiedFirstParentWalk, verifiedTreeDiff } from "./commit-extract.js";
|
|
30
|
+
import { testImportProblem } from "./test-infra.js";
|
|
30
31
|
import { projectGitDir } from "./check-report.js";
|
|
32
|
+
import { checkAccessOf } from "./check-access.js";
|
|
33
|
+
import { stampRefusal } from "./check-stamp.js";
|
|
31
34
|
import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./contracts/release-terms.js";
|
|
32
35
|
import { run } from "./exec.js";
|
|
33
36
|
import { proxyEnv, startEgressProxy } from "./egress.js";
|
|
34
37
|
import { liveReleaseOf } from "./scope.js";
|
|
35
38
|
import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
|
|
36
|
-
import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
|
|
39
|
+
import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, taskCheckStamp, } from "./store.js";
|
|
37
40
|
// ------------------------------------------------------------------ the rules
|
|
38
41
|
/** The journey scripts: tests, by Alex's decision (they carry). */
|
|
39
42
|
export const JOURNEY_SCRIPTS = Object.freeze(["scripts/app-e2e.mjs", "scripts/flows-e2e.mjs"]);
|
|
@@ -111,8 +114,15 @@ export function stricterOf(a, b) {
|
|
|
111
114
|
export function rulePath(path) {
|
|
112
115
|
return path.toLowerCase().split("/").map(part => part.replace(/[. ]+$/, "")).filter(part => part !== "" && part !== ".").join("/");
|
|
113
116
|
}
|
|
114
|
-
/**
|
|
115
|
-
|
|
117
|
+
/** Tests by the rules' classes: what the existing-test rule holds still. */
|
|
118
|
+
const carriedTest = (normal) => CARRY_RULES.classes.some(one => (one.name === "unit tests" || one.name === "journey scripts") && one.matches(normal));
|
|
119
|
+
/**
|
|
120
|
+
* Why one changed path doesn't carry, in words, or null when it does. Every rule reads the normalized path. `fresh`: the
|
|
121
|
+
* paths that didn't exist at the approved commit. A test that was there stays exactly as it was (H4): new tests go in new
|
|
122
|
+
* files. Only "unchanged" holds up, since `vi.mock` hoists and a top-level hook reaches every case above it, so an
|
|
123
|
+
* appended line can disarm what it follows.
|
|
124
|
+
*/
|
|
125
|
+
export function changeRefusal(change, policy, fresh = new Set()) {
|
|
116
126
|
const path = change.path;
|
|
117
127
|
const normal = rulePath(path);
|
|
118
128
|
if (change.kind === "deleted")
|
|
@@ -133,6 +143,8 @@ export function changeRefusal(change, policy) {
|
|
|
133
143
|
return `${path} is protected in this project`;
|
|
134
144
|
if (!CARRY_RULES.classes.some(one => one.matches(normal)))
|
|
135
145
|
return `${path} isn't a test, a doc or the changelog`;
|
|
146
|
+
if (change.kind === "modified" && carriedTest(normal) && !fresh.has(path))
|
|
147
|
+
return `${path} changes an existing test, and new tests go in a new file`;
|
|
136
148
|
return null;
|
|
137
149
|
}
|
|
138
150
|
// ------------------------------------------------------------------ release terms
|
|
@@ -211,6 +223,11 @@ export function preflightCarry(store, input) {
|
|
|
211
223
|
if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
|
|
212
224
|
return refuse("rules", "This project's checks run outside the sandbox, and carrying release approvals then wasn't on when this release was approved, or is off now.");
|
|
213
225
|
}
|
|
226
|
+
// A carry leans on the check the release already had: it must be shown to have stayed within what was approved (H2).
|
|
227
|
+
const ran = taskCheckStamp(store, a.repo, a.tipTask);
|
|
228
|
+
const unshown = ran === null ? null : stampRefusal(ran.reading, checkAccessOf(a.accessDigest));
|
|
229
|
+
if (unshown !== null)
|
|
230
|
+
return refuse("rules", `This release's approval can't carry: ${unshown}.`);
|
|
214
231
|
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
|
|
215
232
|
return refuse("ancestry", `${input.to} isn't a full commit id.`);
|
|
216
233
|
if (input.to === a.tipSha)
|
|
@@ -234,6 +251,10 @@ export function preflightCarry(store, input) {
|
|
|
234
251
|
treeOf.set(commit.id, commit.tree);
|
|
235
252
|
const commits = [];
|
|
236
253
|
let churn = 0;
|
|
254
|
+
const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
|
|
255
|
+
if (!whole.ok)
|
|
256
|
+
return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
|
|
257
|
+
const fresh = new Set(whole.value.filter(one => one.kind === "added").map(one => one.path));
|
|
237
258
|
for (const commit of ordered) {
|
|
238
259
|
const parentTree = treeOf.get(commit.parents[0]);
|
|
239
260
|
if (parentTree === undefined)
|
|
@@ -242,21 +263,30 @@ export function preflightCarry(store, input) {
|
|
|
242
263
|
if (!diff.ok)
|
|
243
264
|
return refuse("shape", `${commit.id.slice(0, 7)} couldn't be read: ${diff.message}`);
|
|
244
265
|
for (const change of diff.value) {
|
|
245
|
-
const why = changeRefusal(change, policy);
|
|
266
|
+
const why = changeRefusal(change, policy, fresh);
|
|
246
267
|
if (why !== null)
|
|
247
268
|
return refuse("rules", `${commit.id.slice(0, 7)}: ${why}.`, change.path);
|
|
248
269
|
}
|
|
249
270
|
churn += diff.value.reduce((n, one) => n + one.added + one.deleted, 0);
|
|
250
271
|
commits.push({ sha: commit.id, files: diff.value.map(one => ({ path: one.path, added: one.added, deleted: one.deleted })) });
|
|
251
272
|
}
|
|
252
|
-
const whole = verifiedTreeDiff(gitDir, a.approvedTree, chain[0].tree);
|
|
253
|
-
if (!whole.ok)
|
|
254
|
-
return refuse("shape", `The change since the approval couldn't be read: ${whole.message}`);
|
|
255
273
|
for (const change of whole.value) {
|
|
256
|
-
const why = changeRefusal(change, policy);
|
|
274
|
+
const why = changeRefusal(change, policy, fresh);
|
|
257
275
|
if (why !== null)
|
|
258
276
|
return refuse("rules", `Since the approval: ${why}.`, change.path);
|
|
259
277
|
}
|
|
278
|
+
// No new test may reach into another (H4). Read from the verified tree, never from a checkout.
|
|
279
|
+
// By the rules' own classifier, so everything that carries as a test is read (a new journey script too).
|
|
280
|
+
const newTests = whole.value.filter(one => one.kind === "added" && carriedTest(rulePath(one.path))).map(one => one.path);
|
|
281
|
+
const added = newTests.length === 0 ? null : verifiedBlobs(gitDir, chain[0].tree, newTests);
|
|
282
|
+
if (added !== null && !added.ok)
|
|
283
|
+
return refuse("shape", `The new tests couldn't be read: ${added.message}`);
|
|
284
|
+
for (const path of newTests) {
|
|
285
|
+
const bytes = added !== null && added.ok ? added.value.get(path)?.bytes : undefined;
|
|
286
|
+
const why = bytes === undefined ? `${path} couldn't be read` : testImportProblem(path, bytes.toString("utf8"));
|
|
287
|
+
if (why !== null)
|
|
288
|
+
return refuse("rules", `Since the approval: ${why}.`, path);
|
|
289
|
+
}
|
|
260
290
|
const changedLines = whole.value.reduce((n, one) => n + one.added + one.deleted, 0);
|
|
261
291
|
if (changedLines > policy.limits.lines)
|
|
262
292
|
return refuse("limits", `${changedLines} lines changed since the approval; at most ${policy.limits.lines} carry.`);
|
package/dist/release-cli.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { type ReleaseAdapters } from "./release.js";
|
|
9
9
|
import { type GateReleaseWorld } from "./release-gate.js";
|
|
10
10
|
import { type RescueWorld } from "./deploy-rescue.js";
|
|
11
|
+
import { type RunLockSeams, type SettleWorld } from "./release-next.js";
|
|
11
12
|
export declare const RELEASE_HELP: string;
|
|
12
13
|
/** A runner lease id (claim.ts mints randomUUID()). */
|
|
13
14
|
export declare const LEASE_SHAPED: RegExp;
|
|
@@ -23,6 +24,9 @@ export type ReleaseCliDeps = {
|
|
|
23
24
|
gateWorld?: GateReleaseWorld;
|
|
24
25
|
/** What --recover-deploy reads and runs (deploy-rescue.ts); the installation's own by default. */
|
|
25
26
|
rescueWorld?: RescueWorld;
|
|
27
|
+
/** What a stopped deployment left, settled before the release goes on (release-next.ts). Tests that inject adapters skip it. */
|
|
28
|
+
settle?: SettleWorld;
|
|
29
|
+
runLock?: RunLockSeams;
|
|
26
30
|
/** The plane's database for the authority commands (default: this installation's). */
|
|
27
31
|
database?: string;
|
|
28
32
|
now?: () => Date;
|
package/dist/release-cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { RELEASE_LIMITS, runRelease } from "./release.js";
|
|
|
16
16
|
import { processes, releaseAdapters } from "./release-adapters.js";
|
|
17
17
|
import { journals, releaseGate } from "./release-gate.js";
|
|
18
18
|
import { rescueDeployment } from "./deploy-rescue.js";
|
|
19
|
+
import { settleDeployment, takeReleaseRun } from "./release-next.js";
|
|
19
20
|
import { databasePath, elapsedSeconds, latestReleaseStepTask, openStore, releaseProgressFor, revokeRelease } from "./store.js";
|
|
20
21
|
import { dur } from "./design/kit.js";
|
|
21
22
|
import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
|
|
@@ -34,27 +35,22 @@ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itse
|
|
|
34
35
|
squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
|
|
35
36
|
publication; Homebrew waits for its checks to register and all pass before merging.
|
|
36
37
|
|
|
37
|
-
Run the same command to
|
|
38
|
+
Run the same command to go on after any stop. It reads where the release got to and does the one thing that's next:
|
|
39
|
+
finishes a deployment forward, puts one back, lifts a pause left before a swap, waits for another run of the release
|
|
40
|
+
(naming its computer and process), or waits for your approval. It says what it did. A branch behind main is brought up
|
|
41
|
+
to date for you; that new commit needs its own approval, CI and check.
|
|
38
42
|
|
|
39
|
-
A branch whose name is one of this command's own words (status, nightly, carry…) is named with --branch <name>
|
|
43
|
+
A branch whose name is one of this command's own words (approve, status, nightly, carry…) is named with --branch <name>
|
|
40
44
|
or after --: \`toolroll release --branch status\`.
|
|
41
45
|
|
|
46
|
+
toolroll release approve [<branch>] [--repo <path>] — approve the release waiting for you: shows the release, GitHub CI
|
|
47
|
+
now, what approving does, what changed and every term, then asks for your password (or uses your remembered login
|
|
48
|
+
where Passwordless allows it). Names the waiting releases when more than one could be meant. With --json it never
|
|
49
|
+
prompts: --as <you> --token <password> --digest <card digest>
|
|
50
|
+
|
|
42
51
|
toolroll release status [<task>] — the steps of a release (the newest one recorded, or the release task named): each
|
|
43
52
|
step's state and how long it took, and where it stopped
|
|
44
53
|
|
|
45
|
-
toolroll release --release-gate <id> [--yes] — lift the update pause a deployment left before its swap
|
|
46
|
-
|
|
47
|
-
Shows why it is safe first: the pause is that deployment's, its journal stopped before the swap, and the
|
|
48
|
-
database, service definition and running service are all still the previous build's. --yes lifts that one pause.
|
|
49
|
-
Any missing or contradicting evidence refuses and leaves the pause in place.
|
|
50
|
-
|
|
51
|
-
toolroll release --recover-deploy [--stage <dir>] [--yes] — put back a deployment that stopped half way
|
|
52
|
-
|
|
53
|
-
Finds the one unfinished deployment (or the stage named) and shows what its recovery will do; --yes does it. When
|
|
54
|
-
the old service isn't proved stopped, it boots the service out and stops what it left running, but only processes
|
|
55
|
-
proved, by exact runtime path and arguments, to be that deployment's own; then it starts the previous service
|
|
56
|
-
again, waits for it, and lifts the update pause. Anything it can't prove refuses. Safe to run again.
|
|
57
|
-
|
|
58
54
|
toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
|
|
59
55
|
|
|
60
56
|
Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
|
|
@@ -95,10 +91,6 @@ Options
|
|
|
95
91
|
--new release a new commit on the branch, setting the earlier unfinished release aside
|
|
96
92
|
--limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
|
|
97
93
|
--tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
|
|
98
|
-
--release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
|
|
99
|
-
--yes with --release-gate: lift the pause once it is proved safe; with --recover-deploy: recover
|
|
100
|
-
--recover-deploy recover the unfinished deployment (see above)
|
|
101
|
-
--stage <dir> with --recover-deploy: the deployment's staging folder, when more than one is unfinished
|
|
102
94
|
--json answer with one machine envelope
|
|
103
95
|
|
|
104
96
|
A runner's lease is returned with \`toolroll worker release <lease>\`.`;
|
|
@@ -233,6 +225,9 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
233
225
|
return refuse("usage", "`toolroll release status [<task>]` takes only --json.", 2);
|
|
234
226
|
return runStatusCommand(positional[1], write, json, deps);
|
|
235
227
|
}
|
|
228
|
+
// `release approve` is the toolroll command's own (cli.ts routes it to the operate commands); never a branch here.
|
|
229
|
+
if (!named && positional[0] === "approve")
|
|
230
|
+
return refuse("usage", "Run `toolroll release approve [<branch>]`. A branch named approve is released with `--branch approve`.", 2);
|
|
236
231
|
if (!named && positional[0] === "nightly" && positional.length === 1)
|
|
237
232
|
return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
|
|
238
233
|
if (!named && ["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
@@ -262,8 +257,23 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
262
257
|
const adapters = deps.adapters === undefined
|
|
263
258
|
? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
|
|
264
259
|
: { ...deps.adapters, say };
|
|
265
|
-
|
|
266
|
-
|
|
260
|
+
// One command: wait out another run of this release, settle what a stopped deployment left, then go on from the journal.
|
|
261
|
+
const taken = await takeReleaseRun(stateDir, branch, say, deps.runLock);
|
|
262
|
+
if (!taken.ok)
|
|
263
|
+
return refuse("running", `Another release of ${branch} is still running on ${taken.holder.host} (process ${taken.holder.pid}). Nothing was changed.`, 3);
|
|
264
|
+
try {
|
|
265
|
+
const world = deps.settle ?? (deps.adapters === undefined ? { rescue: installedRescueWorld(checkout), gate: installedGateWorld() } : null);
|
|
266
|
+
if (world !== null) {
|
|
267
|
+
const settled = await settleDeployment(world, say);
|
|
268
|
+
if (!settled.ok)
|
|
269
|
+
return refuse("deployment", settled.message, 3);
|
|
270
|
+
}
|
|
271
|
+
const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: bare.has("--new"), noCarry: bare.has("--no-carry") }, adapters);
|
|
272
|
+
return report(outcome, write, json, lines);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
taken.release();
|
|
276
|
+
}
|
|
267
277
|
}
|
|
268
278
|
function report(outcome, write, json, lines) {
|
|
269
279
|
const release = outcome.journal === null ? null : {
|
|
@@ -582,10 +592,15 @@ async function runStatusCommand(task, write, json, deps) {
|
|
|
582
592
|
}
|
|
583
593
|
const word = { running: "running", waiting: "waiting", done: "done", failed: "failed" };
|
|
584
594
|
write(`Release ${progress.version} (${progress.tag})${progress.pr === null ? "" : ` · pull request #${progress.pr}`} · task ${taskId}`);
|
|
585
|
-
for (const one of steps)
|
|
586
|
-
|
|
595
|
+
for (const one of steps) {
|
|
596
|
+
// A failure the release has gone on from is history, as on the task page.
|
|
597
|
+
const passed = one.state === "failed" && progress.stopped === null;
|
|
598
|
+
write(` ${one.step.padEnd(9)} ${(passed ? "went on" : word[one.state]).padEnd(8)} ${one.seconds === null ? "–" : dur(one.seconds)}${one.detail === null ? "" : ` ${one.detail}`}`);
|
|
599
|
+
}
|
|
587
600
|
if (progress.stopped !== null)
|
|
588
601
|
write(`Stopped at ${progress.stopped.step}: ${progress.stopped.message}`);
|
|
602
|
+
if (progress.building !== null)
|
|
603
|
+
write(`Build #${progress.building.run} is running the check.`);
|
|
589
604
|
return 0;
|
|
590
605
|
}
|
|
591
606
|
finally {
|
package/dist/release-coverage.js
CHANGED
|
@@ -26,7 +26,7 @@ export const gitIsAncestor = (repo, ancestor, descendant) => {
|
|
|
26
26
|
let answer;
|
|
27
27
|
try {
|
|
28
28
|
// Every git version through a frozen read view, bounded (eval-harness review 1, finding 7).
|
|
29
|
-
const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000 }).status;
|
|
29
|
+
const status = viewedGitSync(repo, ["merge-base", "--is-ancestor", ancestor, descendant], { timeoutMs: 5_000, binding: { repo } }).status;
|
|
30
30
|
if (status !== 0)
|
|
31
31
|
throw Object.assign(new Error("git merge-base"), { status });
|
|
32
32
|
answer = true;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type RescueWorld } from "./deploy-rescue.js";
|
|
2
|
+
import { type GateReleaseWorld } from "./release-gate.js";
|
|
3
|
+
import { type ReleaseJournal } from "./release.js";
|
|
4
|
+
import type { Store } from "./store.js";
|
|
5
|
+
/** How long a run waits for another before it says so and stops. */
|
|
6
|
+
export declare const RELEASE_WAIT_MS: number;
|
|
7
|
+
export type RunHolder = {
|
|
8
|
+
host: string;
|
|
9
|
+
pid: number;
|
|
10
|
+
};
|
|
11
|
+
export type RunLockSeams = {
|
|
12
|
+
host?: string;
|
|
13
|
+
pid?: number;
|
|
14
|
+
alive?: (pid: number) => boolean;
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
waitMs?: number;
|
|
18
|
+
pollMs?: number;
|
|
19
|
+
};
|
|
20
|
+
/** Take this branch's run lock, waiting for a live holder. Answers how to give it back, or who kept it. */
|
|
21
|
+
export declare function takeReleaseRun(stateDir: string, branch: string, say: (line: string) => void, seams?: RunLockSeams): Promise<{
|
|
22
|
+
ok: true;
|
|
23
|
+
release: () => void;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
holder: RunHolder;
|
|
27
|
+
}>;
|
|
28
|
+
export type SettleWorld = {
|
|
29
|
+
rescue: RescueWorld;
|
|
30
|
+
gate: GateReleaseWorld;
|
|
31
|
+
};
|
|
32
|
+
export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
|
|
33
|
+
/** The one action a deployment's journal calls for. */
|
|
34
|
+
export declare function deployActionOf(phase: unknown): DeployAction;
|
|
35
|
+
/** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
|
|
36
|
+
export declare function settleDeployment(world: SettleWorld, say: (line: string) => void): Promise<{
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
message: string;
|
|
41
|
+
}>;
|
|
42
|
+
export declare const RELEASE_RESUMED_ACTION = "release resumed";
|
|
43
|
+
/**
|
|
44
|
+
* A person let a held release go on (release-auto.ts allowAutoPublish; the Release sheet's button or `publish-ok`): each
|
|
45
|
+
* sweep, its journal goes on from where it stopped, once per that person's act, under the same run lock. It was merged
|
|
46
|
+
* already, so only the tag, publication and Homebrew are left, and each re-proves itself.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resumeAllowedReleases(store: Store, stateDir: string, run: (journal: ReleaseJournal) => Promise<unknown>, now: Date): Promise<number>;
|