toolroll 0.9.71 → 0.9.73
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 +155 -0
- package/dist/agent-fence.d.ts +10 -2
- package/dist/agent-fence.js +47 -9
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +28 -28
- package/dist/builder.js +48 -6
- package/dist/chat-actions.js +13 -8
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +186 -5
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +205 -0
- package/dist/claim.js +1 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +111 -0
- package/dist/commit-extract.js +514 -0
- package/dist/contracts/console-api.d.ts +4 -4
- package/dist/contracts/console-api.js +5 -4
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +66 -0
- package/dist/contracts/release-terms.js +74 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +7 -3
- package/dist/flow-engine.js +5 -1
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/flows-ui.js +7 -1
- package/dist/guides.js +10 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/project-delete-ui.js +1 -1
- package/dist/project-tools.js +3 -1
- package/dist/proof.js +2 -0
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +16 -1
- package/dist/release-adapters.js +292 -29
- package/dist/release-authority.d.ts +118 -0
- package/dist/release-authority.js +135 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +236 -0
- package/dist/release-carry.js +664 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +158 -6
- package/dist/release-coverage.js +3 -0
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +139 -3
- package/dist/release.js +641 -250
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +122 -5
- package/dist/scope.js +236 -7
- package/dist/server/flows.js +40 -10
- package/dist/server/render-settings.js +1 -1
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +26 -5
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +230 -5
- package/dist/store.js +733 -37
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/task-outcome-cli.js +7 -3
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
package/dist/release-adapters.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { execFile, spawnSync } from "node:child_process";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
9
|
+
import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
11
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { parse as parseYaml } from "yaml";
|
|
@@ -17,14 +17,29 @@ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
|
17
17
|
import { activeUpdateWork } from "./desktop-update-gate.js";
|
|
18
18
|
import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
|
|
19
19
|
import { findCliLinks, switchCliLinks } from "./release-links.js";
|
|
20
|
-
import { databasePath } from "./store.js";
|
|
20
|
+
import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, releaseIssuerGone, takeReleaseLease, } from "./store.js";
|
|
21
|
+
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
22
|
+
import { liveReleaseOf } from "./scope.js";
|
|
23
|
+
import { proveCarry } from "./release-carry.js";
|
|
24
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
25
|
+
import { deploymentGate } from "./deploy-gate.js";
|
|
26
|
+
import { extractCommit } from "./commit-extract.js";
|
|
27
|
+
import { checkoutIncludes } from "./teardown-git.js";
|
|
28
|
+
import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
29
|
+
export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
21
30
|
/** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
|
|
22
31
|
export const exec = (command, args, options = {}) => new Promise(done => {
|
|
23
|
-
execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
32
|
+
const child = execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
24
33
|
const failure = error;
|
|
25
34
|
const code = failure === null ? 0 : failure.code === "ENOENT" ? 127 : failure.killed === true ? 124 : typeof failure.code === "number" ? failure.code : 1;
|
|
26
35
|
done({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
|
|
27
36
|
});
|
|
37
|
+
// `input`: written on the child's stdin, then closed (a secret that must never ride in argv).
|
|
38
|
+
child.stdin?.on("error", () => { });
|
|
39
|
+
if (options.input !== undefined)
|
|
40
|
+
child.stdin?.end(options.input);
|
|
41
|
+
else
|
|
42
|
+
child.stdin?.end();
|
|
28
43
|
});
|
|
29
44
|
/** Without NODE_OPTIONS, which a test runner or debugger may have left for this process. */
|
|
30
45
|
const cleanEnv = (extra = {}) => {
|
|
@@ -107,6 +122,7 @@ export function gateStateOf(answer, accounts = []) {
|
|
|
107
122
|
approval: approvedAt === null || approvedBy === null || approvedDigest === null ? null : {
|
|
108
123
|
by: approvedBy, at: approvedAt, digest: approvedDigest, basis: textOr(scope["approvalBasis"]),
|
|
109
124
|
role: textOr(account?.["role"]), active: account?.["revokedAt"] === null,
|
|
125
|
+
...personAnswerOf(answer["personApproval"]),
|
|
110
126
|
},
|
|
111
127
|
result: resultRun === null || textOr(result["digest"]) === null ? null : {
|
|
112
128
|
run: resultRun, head: textOr(result["head"]), receipt: String(result["digest"]), worktree: textOr(worktree),
|
|
@@ -149,6 +165,48 @@ export function stageGate(stage, owner) {
|
|
|
149
165
|
const id = textOr(journal["id"]), phase = textOr(journal["phase"]);
|
|
150
166
|
return id !== null && phase !== null && BEFORE_SWAP.includes(phase) && owner() === id ? { id, phase } : null;
|
|
151
167
|
}
|
|
168
|
+
/** The recorded repository for a gate checkout, from the plane's database; throws in plain words when there is none. */
|
|
169
|
+
export function recordedReleaseRepo(database, checkout) {
|
|
170
|
+
const real = (path) => { try {
|
|
171
|
+
return realpathSync(path);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
} };
|
|
176
|
+
const at = real(checkout);
|
|
177
|
+
if (at === null)
|
|
178
|
+
throw new Error(`${checkout} isn't there.`);
|
|
179
|
+
if (!existsSync(database))
|
|
180
|
+
throw new Error("The plane's database isn't here, so the release's repository can't be read from its records.");
|
|
181
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
182
|
+
let project = null, remote = null;
|
|
183
|
+
try {
|
|
184
|
+
const store = new Store(db);
|
|
185
|
+
project = store.listProjects().map(one => one.path).find(path => real(path) === at) ?? null;
|
|
186
|
+
remote = project === null ? null : store.projectRemote(project);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
db.close();
|
|
190
|
+
}
|
|
191
|
+
if (project === null)
|
|
192
|
+
throw new Error(`${checkout} isn't one of the projects Toolroll records, so it can't be released from here.`);
|
|
193
|
+
if (remote === null)
|
|
194
|
+
throw new Error(`${project} has no recorded remote. Record it with \`toolroll repos add ${project} --remote <url>\` first.`);
|
|
195
|
+
const parsed = releaseRemote(remote);
|
|
196
|
+
if (parsed === null)
|
|
197
|
+
throw new Error(`${project}'s recorded remote (${remote}) isn't a GitHub repository over https or ssh.`);
|
|
198
|
+
const named = join(at, ".git");
|
|
199
|
+
let gitDir;
|
|
200
|
+
try {
|
|
201
|
+
if (!lstatSync(named).isDirectory())
|
|
202
|
+
throw new Error("not a folder");
|
|
203
|
+
gitDir = realpathSync(named);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new Error(`${project}'s Git directory isn't a plain folder, so the release won't read it.`);
|
|
207
|
+
}
|
|
208
|
+
return { checkout: at, gitDir, remote, ...parsed };
|
|
209
|
+
}
|
|
152
210
|
/** How many times, and how far apart, a merged branch's deletion is looked for after the push lost a race. */
|
|
153
211
|
export const BRANCH_GONE_LOOKS = 5;
|
|
154
212
|
export const BRANCH_GONE_PAUSE_MS = 2_000;
|
|
@@ -158,9 +216,11 @@ export function releaseAdapters(options) {
|
|
|
158
216
|
const { checkout } = options;
|
|
159
217
|
const tap = options.tap ?? "ap9000/homebrew-toolroll";
|
|
160
218
|
const formula = options.formula ?? "Formula/toolroll.rb";
|
|
161
|
-
const git = (...args) => run("git", ["-C", checkout, ...args]);
|
|
162
219
|
const pause = options.sleep ?? ((ms) => new Promise(done => setTimeout(done, ms)));
|
|
163
|
-
const
|
|
220
|
+
const tools = options.tools ?? releaseTools();
|
|
221
|
+
const gitEnv = releaseGitEnv();
|
|
222
|
+
// Never a PATH lookup or the checkout's Git config: gh by its absolute path, from a neutral folder, naming the repo.
|
|
223
|
+
const gh = (...args) => run(tools.gh, args, { cwd: "/", env: gitEnv });
|
|
164
224
|
const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
|
|
165
225
|
const toolroll = async (...args) => {
|
|
166
226
|
const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
|
|
@@ -173,6 +233,29 @@ export function releaseAdapters(options) {
|
|
|
173
233
|
};
|
|
174
234
|
const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
|
|
175
235
|
const database = options.database ?? databasePath(process.env, homedir());
|
|
236
|
+
let recorded = options.releaseRepo ?? null;
|
|
237
|
+
/** The recorded repository, read once (chat-management.md §0a.2). */
|
|
238
|
+
const releaseRepo = () => recorded ??= recordedReleaseRepo(database, checkout);
|
|
239
|
+
/**
|
|
240
|
+
* The coordinator's one way to run Git (§0a.2): on the recorded Git directory, hardened, with an allowlisted
|
|
241
|
+
* environment and the absolute Git. Before every command the checkout's own config is read with includes off and
|
|
242
|
+
* as plain files; one that includes a file, names a command or redirects a remote stops the release, naming it.
|
|
243
|
+
*/
|
|
244
|
+
const git = async (...args) => {
|
|
245
|
+
const repo = releaseRepo();
|
|
246
|
+
const stop = (why) => new Error(`Toolroll won't run Git in ${repo.checkout} for a release: ${why}. Remove it from the project's Git config, then rerun.`);
|
|
247
|
+
if (options.releaseRepo === undefined && checkoutIncludes(repo.checkout))
|
|
248
|
+
throw stop("its Git config includes another file, or couldn't be read as a plain file");
|
|
249
|
+
const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv });
|
|
250
|
+
if (listing.code !== 0)
|
|
251
|
+
throw stop("its Git config couldn't be read");
|
|
252
|
+
const setting = releaseConfigProblem(listing.stdout);
|
|
253
|
+
if (setting !== null)
|
|
254
|
+
throw stop(`its Git config sets ${setting}, which a release doesn't run with (it could run a command, or change where Git connects or what it sends)`);
|
|
255
|
+
return run(tools.git, [...releaseGitArgs(repo, tools), ...args], { cwd: repo.checkout, env: gitEnv });
|
|
256
|
+
};
|
|
257
|
+
/** The remote every fetch, look and push names: the recorded URL, defined only on the command line. */
|
|
258
|
+
const remoteUrl = () => RELEASE_REMOTE;
|
|
176
259
|
const liveOwner = () => {
|
|
177
260
|
if (!existsSync(database))
|
|
178
261
|
return null;
|
|
@@ -190,24 +273,102 @@ export function releaseAdapters(options) {
|
|
|
190
273
|
const gate = stageGate(stage, liveOwner);
|
|
191
274
|
return gate === null ? { ok: false, message } : { ok: false, message, gate };
|
|
192
275
|
};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
276
|
+
/** The deployment gate in this runtime (deploy-gate.ts): a person's approval, the passing check, the exact completed
|
|
277
|
+
* result. Its answer names the approved commit and the project it belongs to, from the plane's own records. */
|
|
278
|
+
const proveCandidate = (runId) => {
|
|
279
|
+
if (!existsSync(database))
|
|
280
|
+
return { ok: false, message: "The plane's database isn't here, so the candidate can't be proven." };
|
|
281
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
282
|
+
try {
|
|
283
|
+
return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))() });
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
return { ok: false, message: `The candidate can't be proven: ${error.message}` };
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
db.close();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/** The run's recorded commit and project, for recovery (which needs no approval). */
|
|
293
|
+
const recordedCommit = (runId) => {
|
|
294
|
+
if (!existsSync(database))
|
|
295
|
+
return null;
|
|
296
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
297
|
+
try {
|
|
298
|
+
const store = new Store(db), run = store.getRun(runId), repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
|
|
299
|
+
return run?.headRevision != null && repo != null ? { head: run.headRevision, repo } : null;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
db.close();
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
/**
|
|
309
|
+
* The approved commit, written by this runtime into a fresh folder it owns (commit-extract.ts): every regular file of
|
|
310
|
+
* that full SHA, read byte for byte from the PROJECT's own Git directory as Toolroll records it (realpath, a real
|
|
311
|
+
* directory — never the candidate worktree's `.git` pointer or anything in that worktree), with no filter, attribute,
|
|
312
|
+
* replacement object, lazy fetch, hook or outside config, and no symlink or submodule. The deployment script runs
|
|
313
|
+
* from here, so nothing changed, flagged, swapped or untracked in the worktree, nor its Git pointer, changes what runs.
|
|
314
|
+
*/
|
|
315
|
+
const extractApproved = async (repo, head) => {
|
|
316
|
+
let gitDir;
|
|
317
|
+
try {
|
|
318
|
+
const named = join(realpathSync(repo), ".git");
|
|
319
|
+
if (!lstatSync(named).isDirectory())
|
|
320
|
+
return { ok: false, message: `${repo}'s Git directory isn't a plain directory, so the approved commit can't be read from it.` };
|
|
321
|
+
gitDir = realpathSync(named);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return { ok: false, message: `${repo} has no Git directory to read the approved commit from.` };
|
|
325
|
+
}
|
|
326
|
+
const dir = realpathSync(mkdtempSync(join(resolved(tmpdir()), "toolroll-deployer-")));
|
|
327
|
+
const extracted = extractCommit(gitDir, head, dir);
|
|
328
|
+
if (!extracted.ok) {
|
|
329
|
+
rmSync(dir, { recursive: true, force: true });
|
|
330
|
+
return { ok: false, message: `The approved commit couldn't be prepared: ${extracted.message}` };
|
|
331
|
+
}
|
|
332
|
+
return { ok: true, dir, gitDir };
|
|
333
|
+
};
|
|
334
|
+
/** The approved commit's own deployment script, from the pristine extraction; the worktree is only its --source. */
|
|
335
|
+
const deployScript = async (worktree, stage, runId, commit, extra, input) => {
|
|
336
|
+
const extracted = await extractApproved(commit.repo, commit.head);
|
|
337
|
+
if (!extracted.ok)
|
|
338
|
+
return { code: 1, stdout: "", stderr: extracted.message };
|
|
339
|
+
try {
|
|
340
|
+
// The script node runs is the extraction's own regular file, by its real path inside the folder.
|
|
341
|
+
let script;
|
|
342
|
+
try {
|
|
343
|
+
script = realpathSync(join(extracted.dir, "scripts", "deploy-browser.mjs"));
|
|
344
|
+
if (!script.startsWith(extracted.dir + "/") || !lstatSync(script).isFile())
|
|
345
|
+
throw new Error("outside");
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return { code: 1, stdout: "", stderr: "The approved commit has no deployment script of its own." };
|
|
349
|
+
}
|
|
350
|
+
return await run(process.execPath, [script, "--run", String(runId), "--stage", stage, "--source", worktree,
|
|
351
|
+
"--head", commit.head, "--git-dir", extracted.gitDir, ...extra], {
|
|
352
|
+
cwd: extracted.dir, env: cleanEnv({ TMPDIR: resolved(process.env["TMPDIR"] ?? "/tmp") }), timeoutMs: (options.deployMinutes ?? 30) * 60_000, ...(input === undefined ? {} : { input }),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
rmSync(extracted.dir, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()));
|
|
196
360
|
return {
|
|
361
|
+
...(existsSync(database) ? { release } : {}),
|
|
197
362
|
now: options.now ?? (() => new Date()),
|
|
198
363
|
sleep: options.sleep ?? sleep,
|
|
199
364
|
say: options.say ?? (line => process.stdout.write(`${line}\n`)),
|
|
200
365
|
repo: {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (slug === null)
|
|
204
|
-
throw new Error(`${checkout}'s origin is not a GitHub repository`);
|
|
205
|
-
return slug;
|
|
206
|
-
},
|
|
366
|
+
// The recorded owner/name, never the checkout's origin.
|
|
367
|
+
async github() { return releaseRepo().slug; },
|
|
207
368
|
async sync(branch) {
|
|
208
|
-
await must(git("fetch", "--quiet", "
|
|
369
|
+
await must(git("fetch", "--quiet", "--no-tags", remoteUrl(), "+refs/heads/main:refs/remotes/origin/main"), "git fetch main");
|
|
209
370
|
// A merged branch may be gone from GitHub; its commit is already here.
|
|
210
|
-
await git("fetch", "--quiet", "
|
|
371
|
+
await git("fetch", "--quiet", "--no-tags", remoteUrl(), `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
|
|
211
372
|
const current = (await git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim();
|
|
212
373
|
if (current === "main") {
|
|
213
374
|
await must(git("merge", "--ff-only", "--quiet", "origin/main"), "git merge --ff-only origin/main");
|
|
@@ -221,17 +382,17 @@ export function releaseAdapters(options) {
|
|
|
221
382
|
throw new Error(`${checkout}'s local main has commits origin/main doesn't; the gate diffs from it, so fix it first`);
|
|
222
383
|
},
|
|
223
384
|
async remoteHead(branch) {
|
|
224
|
-
const out = await must(git("ls-remote",
|
|
385
|
+
const out = await must(git("ls-remote", remoteUrl(), `refs/heads/${branch}`), "git ls-remote");
|
|
225
386
|
return out.trim().split(/\s+/)[0] || null;
|
|
226
387
|
},
|
|
227
388
|
async deleteBranch(branch, head) {
|
|
228
389
|
const ref = `refs/heads/${branch}`;
|
|
229
|
-
const current = (await must(git("ls-remote",
|
|
390
|
+
const current = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
|
|
230
391
|
if (current === null)
|
|
231
392
|
return;
|
|
232
393
|
if (current !== head)
|
|
233
394
|
throw new Error(`${branch} moved after merging; its new head was not deleted.`);
|
|
234
|
-
const pushed = await git("push", `--force-with-lease=${ref}:${head}`,
|
|
395
|
+
const pushed = await git("push", "--no-verify", `--force-with-lease=${ref}:${head}`, remoteUrl(), `:${ref}`);
|
|
235
396
|
if (pushed.code === 0)
|
|
236
397
|
return;
|
|
237
398
|
// GitHub may delete a merged branch itself between the look and the push; gone is what was wanted. Its refs can
|
|
@@ -239,7 +400,7 @@ export function releaseAdapters(options) {
|
|
|
239
400
|
for (let look = 0; look < BRANCH_GONE_LOOKS; look++) {
|
|
240
401
|
if (look > 0)
|
|
241
402
|
await pause(BRANCH_GONE_PAUSE_MS);
|
|
242
|
-
const after = (await must(git("ls-remote",
|
|
403
|
+
const after = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
|
|
243
404
|
if (after === null)
|
|
244
405
|
return;
|
|
245
406
|
}
|
|
@@ -278,7 +439,7 @@ export function releaseAdapters(options) {
|
|
|
278
439
|
const added = await toolroll("task", "add", input.title, "--id", input.taskId, "--repo", input.checkout, "--checks", "full");
|
|
279
440
|
if (added["ok"] !== true)
|
|
280
441
|
throw new Error(`task add: ${refused(added)}`);
|
|
281
|
-
const scoped = await toolroll("task", "scope", input.taskId, "--candidate", input.candidate, "--goal", input.goal, "--acceptance", input.acceptance);
|
|
442
|
+
const scoped = await toolroll("task", "scope", input.taskId, "--candidate", input.candidate, "--goal", input.goal, "--acceptance", input.acceptance, ...(input.release === undefined ? [] : ["--release", `${input.release.version}:${input.release.pr}:${input.release.branch}`, ...(input.release.noCarry ? ["--no-carry"] : [])]));
|
|
282
443
|
if (scoped["ok"] !== true)
|
|
283
444
|
throw new Error(`task scope: ${refused(scoped)}`);
|
|
284
445
|
const digest = gateStateOf(await toolroll("task", "show", input.taskId)).scopeDigest;
|
|
@@ -308,10 +469,17 @@ export function releaseAdapters(options) {
|
|
|
308
469
|
db.close();
|
|
309
470
|
}
|
|
310
471
|
},
|
|
311
|
-
async deploy({ run: runId, worktree, stage }) {
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
472
|
+
async deploy({ run: runId, worktree, stage, operation }) {
|
|
473
|
+
// Approval policy §1.7: this runtime proves the candidate FIRST — a person approved it, its exact result passed
|
|
474
|
+
// the approved check and is complete, and the checkout is exactly that commit — before anything from the
|
|
475
|
+
// candidate's checkout runs. The candidate's own script never decides its own deployment.
|
|
476
|
+
const proven = proveCandidate(runId);
|
|
477
|
+
if (!proven.ok)
|
|
478
|
+
return stopped(stage, proven.message);
|
|
479
|
+
// The approved commit's own deployment, extracted by this runtime: drain, backup, rehearsal, swap, health. Its
|
|
480
|
+
// last JSON line names the runtime it installed; the CLI names are moved to it by the release's link step.
|
|
481
|
+
// The operation the coordinator reserved, adopted by the deploy run with its secret on stdin (never in argv).
|
|
482
|
+
const result = await deployScript(worktree, stage, runId, proven, ["--yes", ...(operation === undefined ? [] : ["--operation", operation.id])], operation?.secret);
|
|
315
483
|
const runtime = deployedRuntime(result.stdout);
|
|
316
484
|
if (result.code !== 0 || runtime === null)
|
|
317
485
|
return stopped(stage, tail(result));
|
|
@@ -325,7 +493,11 @@ export function releaseAdapters(options) {
|
|
|
325
493
|
const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
|
|
326
494
|
if (running.length > 0)
|
|
327
495
|
return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
|
|
328
|
-
|
|
496
|
+
// Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
|
|
497
|
+
const recorded = recordedCommit(runId);
|
|
498
|
+
if (recorded === null)
|
|
499
|
+
return stopped(stage, `Run ${runId} names no commit to recover with.`);
|
|
500
|
+
const result = await deployScript(worktree, stage, runId, recorded, ["--phase", "recover"]);
|
|
329
501
|
if (result.code === 0)
|
|
330
502
|
return { ok: true };
|
|
331
503
|
// Killed while preparing, before it paused anything: nothing to undo (its journal is kept).
|
|
@@ -339,6 +511,34 @@ export function releaseAdapters(options) {
|
|
|
339
511
|
return { ok: true };
|
|
340
512
|
return stopped(stage, `${tail(result)} (the deployment journal is at ${String(journal["phase"] ?? "an unreadable phase")}; ${join(stage, "deployment.json")})`);
|
|
341
513
|
},
|
|
514
|
+
async deploymentIdle(stage) {
|
|
515
|
+
// Whatever the stage: no deployment is running, and no update pause is held.
|
|
516
|
+
if ((options.processes ?? processes)().some(one => one.command.includes("deploy-browser.mjs")))
|
|
517
|
+
return false;
|
|
518
|
+
if (liveOwner() !== null)
|
|
519
|
+
return false;
|
|
520
|
+
if (stage === null)
|
|
521
|
+
return false;
|
|
522
|
+
const journalFile = join(stage, "deployment.json");
|
|
523
|
+
// Never prepared: staging touched neither the service nor the update pause.
|
|
524
|
+
if (!existsSync(journalFile))
|
|
525
|
+
return true;
|
|
526
|
+
// Its journal lock is free (nothing holds the journal), and it stopped where nothing changed, or finished.
|
|
527
|
+
try {
|
|
528
|
+
acquireDeploymentLock(journalFile, 0).release();
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
let phase;
|
|
534
|
+
try {
|
|
535
|
+
phase = object(JSON.parse(readFileSync(journalFile, "utf8")))["phase"];
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
return phase === "preparing" || phase === "released" || phase === "deployed";
|
|
541
|
+
},
|
|
342
542
|
async cliLinks() { return findCliLinks(); },
|
|
343
543
|
async switchLinks(links, bin, version) {
|
|
344
544
|
const answers = (path) => spawnSync(path, ["--version"], { encoding: "utf8", env: cleanEnv(), timeout: 30_000 }).stdout?.trim() === version;
|
|
@@ -361,8 +561,9 @@ export function releaseAdapters(options) {
|
|
|
361
561
|
// Oldest first, so a rerun's attempt is the last one listed for its name.
|
|
362
562
|
return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]), app: textOr(one["app"]) }));
|
|
363
563
|
},
|
|
364
|
-
async merge(repo, pr, head) {
|
|
365
|
-
|
|
564
|
+
async merge(repo, pr, head, message) {
|
|
565
|
+
// The squash commit's message lists every commit that landed after the approval (release-once.md).
|
|
566
|
+
const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`, ...(message === undefined ? [] : ["-f", `commit_message=${message}`])));
|
|
366
567
|
if (typeof merged["sha"] !== "string")
|
|
367
568
|
throw new Error(`GitHub did not merge #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
|
|
368
569
|
return merged["sha"];
|
|
@@ -510,3 +711,65 @@ export function bumpFormula(text, url, sha256) {
|
|
|
510
711
|
throw new Error("the formula has no url and sha256 lines to update");
|
|
511
712
|
return text.replace(/^(\s*url ")[^"]*(")/m, `$1${url}$2`).replace(/^(\s*sha256 ")[0-9a-f]{64}(")/m, `$1${sha256}$2`);
|
|
512
713
|
}
|
|
714
|
+
/** The plane's person-approval answer from `task show --json`, read strictly: anything malformed is no person. */
|
|
715
|
+
function personAnswerOf(value) {
|
|
716
|
+
if (value === undefined)
|
|
717
|
+
return {};
|
|
718
|
+
const read = object(value);
|
|
719
|
+
if (read["ok"] === true && typeof read["method"] === "string")
|
|
720
|
+
return { person: { ok: true, method: read["method"] } };
|
|
721
|
+
return { person: { ok: false, why: typeof read["why"] === "string" ? read["why"] : "the plane's answer about who approved it can't be read" } };
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
|
|
725
|
+
* lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
|
|
726
|
+
*/
|
|
727
|
+
export function storeReleaseAdapter(open, done, now, runner) {
|
|
728
|
+
const withStore = (body) => { const store = open(); try {
|
|
729
|
+
return body(store);
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
done(store);
|
|
733
|
+
} };
|
|
734
|
+
return {
|
|
735
|
+
async authority(taskId) {
|
|
736
|
+
return withStore(store => {
|
|
737
|
+
const found = releaseAuthorityOfTask(store, taskId);
|
|
738
|
+
if (found === null)
|
|
739
|
+
return null;
|
|
740
|
+
const a = found.authority;
|
|
741
|
+
return { id: a.id, generation: a.generation, state: a.state, tipSha: a.tipSha, tipTask: a.tipTask, version: a.version, pr: a.pr,
|
|
742
|
+
landed: releaseCarries(store, a.id).flatMap(carry => carry.commits) };
|
|
743
|
+
});
|
|
744
|
+
},
|
|
745
|
+
async live(taskId, action) {
|
|
746
|
+
return withStore(store => { const live = liveReleaseOf(store, taskId, action, now()); return live.applies ? { applies: true, ok: live.ok, why: live.ok ? null : live.why } : { applies: false, ok: false, why: null }; });
|
|
747
|
+
},
|
|
748
|
+
async lease(authority, owner) { return withStore(store => takeReleaseLease(store, authority, owner, now())); },
|
|
749
|
+
async renew(authority, owner, epoch) { return withStore(store => renewReleaseLease(store, authority, owner, epoch, now())); },
|
|
750
|
+
async carry(authority, to, requestId, epoch) {
|
|
751
|
+
const store = open();
|
|
752
|
+
try {
|
|
753
|
+
const result = await proveCarry(store, { authority, to, requestId, epoch, now: now(), ...(runner === undefined ? {} : { runner }) });
|
|
754
|
+
return result.ok ? { ok: true, taskId: result.taskId } : { ok: false, message: result.message };
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
done(store);
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
async reserve(input) {
|
|
761
|
+
return withStore(store => {
|
|
762
|
+
const secret = randomBytes(32).toString("hex");
|
|
763
|
+
const result = reserveRelease(store, { ...input, operationId: randomUUID(), now: now(), secret });
|
|
764
|
+
return result.ok ? { ok: true, operation: result.operation.id, secret } : result;
|
|
765
|
+
});
|
|
766
|
+
},
|
|
767
|
+
async start(operation, epoch) { return withStore(store => runReleaseOperation(store, operation, epoch, now())); },
|
|
768
|
+
async finish(operation, outcome, facts, epoch) { return withStore(store => finishReleaseOperation(store, operation, outcome, facts, now(), epoch)); },
|
|
769
|
+
async needsApproval(authority, reason, epoch) { return withStore(store => requireFreshReleaseApproval(store, authority, reason, epoch, now())); },
|
|
770
|
+
async operations(authority) {
|
|
771
|
+
return withStore(store => releaseOperations(store, authority).map(op => ({ id: op.id, kind: op.kind, state: op.state, candidate: op.candidate, externalRef: op.externalRef, reservedAt: op.reservedAt,
|
|
772
|
+
direct: op.direct, issuerGone: releaseIssuerGone(op), stage: op.stage })));
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release authority record, read (docs/plans/release-once.md, schema v135): one generation of a release a person
|
|
3
|
+
* approved, its carried commits and its deploy and merge operations. Kept apart from store.ts so the one live-release
|
|
4
|
+
* reader (scope.ts liveReleaseOf) can read it without a module cycle; every write is in store.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
export type ReleaseState = "open" | "deploying" | "merge-reserved" | "merged" | "revoked" | "expired" | "superseded";
|
|
9
|
+
export declare const LIVE_RELEASE_STATES: readonly ReleaseState[];
|
|
10
|
+
/** One generation of a release a person approved: the single source of truth for it. */
|
|
11
|
+
export type ReleaseAuthority = {
|
|
12
|
+
id: number;
|
|
13
|
+
repo: string;
|
|
14
|
+
generation: number;
|
|
15
|
+
previous: number | null;
|
|
16
|
+
version: string;
|
|
17
|
+
pr: number;
|
|
18
|
+
branch: string;
|
|
19
|
+
base: string;
|
|
20
|
+
anchorTask: string;
|
|
21
|
+
anchorDigest: string;
|
|
22
|
+
approvedSha: string;
|
|
23
|
+
approvedTree: string;
|
|
24
|
+
artifactDigest: string | null;
|
|
25
|
+
tipSha: string;
|
|
26
|
+
tipTask: string;
|
|
27
|
+
policyJson: string;
|
|
28
|
+
policy: ReleasePolicy | null;
|
|
29
|
+
policyDigest: string;
|
|
30
|
+
verifyCommand: number;
|
|
31
|
+
verifyDigest: string;
|
|
32
|
+
accessDigest: string;
|
|
33
|
+
state: ReleaseState;
|
|
34
|
+
rev: number;
|
|
35
|
+
leaseOwner: string | null;
|
|
36
|
+
leaseEpoch: number;
|
|
37
|
+
leaseExpires: string | null;
|
|
38
|
+
expiresAt: string;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
endedAt: string | null;
|
|
41
|
+
endedBy: string | null;
|
|
42
|
+
};
|
|
43
|
+
/** One later commit carried under an authority. */
|
|
44
|
+
export type ReleaseCarry = {
|
|
45
|
+
id: number;
|
|
46
|
+
authority: number;
|
|
47
|
+
taskId: string;
|
|
48
|
+
scopeDigest: string;
|
|
49
|
+
requestId: string;
|
|
50
|
+
fromSha: string;
|
|
51
|
+
toSha: string;
|
|
52
|
+
toTree: string;
|
|
53
|
+
artifactDigest: string;
|
|
54
|
+
commits: {
|
|
55
|
+
sha: string;
|
|
56
|
+
files: {
|
|
57
|
+
path: string;
|
|
58
|
+
added: number;
|
|
59
|
+
deleted: number;
|
|
60
|
+
}[];
|
|
61
|
+
}[];
|
|
62
|
+
changedLines: number;
|
|
63
|
+
files: number;
|
|
64
|
+
churn: number;
|
|
65
|
+
runtime: string;
|
|
66
|
+
provedAt: string;
|
|
67
|
+
};
|
|
68
|
+
/** A deploy or merge that can't be undone, fenced by the lease epoch that reserved it. */
|
|
69
|
+
export type ReleaseOperation = {
|
|
70
|
+
id: string;
|
|
71
|
+
authority: number;
|
|
72
|
+
kind: "deploy" | "merge";
|
|
73
|
+
taskId: string;
|
|
74
|
+
candidate: string;
|
|
75
|
+
epoch: number;
|
|
76
|
+
state: "reserved" | "running" | "succeeded" | "failed" | "unknown";
|
|
77
|
+
externalRef: string | null;
|
|
78
|
+
reservedAt: string;
|
|
79
|
+
finishedAt: string | null;
|
|
80
|
+
detail: string | null;
|
|
81
|
+
/** Reserved by a person's own deploy run, with no coordinator holding the release. */
|
|
82
|
+
direct: boolean;
|
|
83
|
+
/** The process that reserved, then issued, it (store.ts currentReleaseIssuer); null when none was recorded. */
|
|
84
|
+
issuer: {
|
|
85
|
+
pid: number;
|
|
86
|
+
started: string | null;
|
|
87
|
+
at: string;
|
|
88
|
+
} | null;
|
|
89
|
+
/** A direct deploy's staging folder. */
|
|
90
|
+
stage: string | null;
|
|
91
|
+
};
|
|
92
|
+
export declare function releaseAuthority(store: Store, id: number): ReleaseAuthority | null;
|
|
93
|
+
/** The newest generation of a release, whatever its state. */
|
|
94
|
+
export declare function newestReleaseGeneration(store: Store, repo: string, version: string, pr: number): ReleaseAuthority | null;
|
|
95
|
+
/** The release authority a task belongs to — as an anchor (any generation) or as a carried commit — or null. */
|
|
96
|
+
export declare function releaseAuthorityOfTask(store: Store, taskId: string): {
|
|
97
|
+
authority: ReleaseAuthority;
|
|
98
|
+
carry: ReleaseCarry | null;
|
|
99
|
+
} | null;
|
|
100
|
+
/** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
|
|
101
|
+
/** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
|
|
102
|
+
* off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
|
|
103
|
+
export declare const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
|
|
104
|
+
export declare function carriesUnsandboxed(store: Store, repo: string): boolean;
|
|
105
|
+
/** How far a check reaches, narrowest first. */
|
|
106
|
+
export declare const CHECK_ACCESS_WIDTH: Readonly<Record<string, number>>;
|
|
107
|
+
export declare function belongsToRelease(store: Store, taskId: string): boolean;
|
|
108
|
+
export declare function releaseCarries(store: Store, authority: number): ReleaseCarry[];
|
|
109
|
+
export declare function releaseCarryTo(store: Store, authority: number, toSha: string): ReleaseCarry | null;
|
|
110
|
+
export declare function releaseOperations(store: Store, authority: number): ReleaseOperation[];
|
|
111
|
+
export declare function releaseOperation(store: Store, id: string): ReleaseOperation | null;
|
|
112
|
+
/** Operations nobody has settled yet: until each is reconciled, nothing new starts. */
|
|
113
|
+
export declare function outstandingReleaseOperations(store: Store, authority: number): ReleaseOperation[];
|
|
114
|
+
/**
|
|
115
|
+
* Why a person's seal of these release terms can't open a new generation now, in words, or null. A live generation is
|
|
116
|
+
* superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
|
|
117
|
+
*/
|
|
118
|
+
export declare function releaseSealProblem(store: Store, terms: ReleaseTerms): string | null;
|