toolroll 0.9.77 → 0.9.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/agent-fence.js +18 -2
  3. package/dist/backup-ui.d.ts +9 -1
  4. package/dist/backup-ui.js +28 -3
  5. package/dist/backup.js +4 -3
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +18 -18
  8. package/dist/browser-workspace.d.ts +2 -0
  9. package/dist/builder.d.ts +4 -3
  10. package/dist/builder.js +14 -8
  11. package/dist/check-failure.js +31 -3
  12. package/dist/check-report.d.ts +18 -3
  13. package/dist/check-report.js +67 -7
  14. package/dist/claim.d.ts +9 -0
  15. package/dist/claim.js +9 -1
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -1
  18. package/dist/contracts/release-terms.d.ts +41 -0
  19. package/dist/contracts/release-terms.js +53 -0
  20. package/dist/controller-service.js +4 -1
  21. package/dist/deploy-rescue.d.ts +41 -0
  22. package/dist/deploy-rescue.js +94 -0
  23. package/dist/dispose.js +4 -3
  24. package/dist/evidence.d.ts +9 -0
  25. package/dist/evidence.js +15 -3
  26. package/dist/exec.d.ts +2 -0
  27. package/dist/exec.js +15 -1
  28. package/dist/git-views.d.ts +1 -1
  29. package/dist/git-views.js +2 -2
  30. package/dist/guides.js +18 -8
  31. package/dist/latency-timeline.d.ts +46 -0
  32. package/dist/latency-timeline.js +120 -0
  33. package/dist/live-backup.d.ts +39 -0
  34. package/dist/live-backup.js +271 -0
  35. package/dist/operate.js +203 -16
  36. package/dist/planner.js +2 -2
  37. package/dist/project-knowledge.d.ts +7 -7
  38. package/dist/release-adapters.d.ts +41 -3
  39. package/dist/release-adapters.js +275 -73
  40. package/dist/release-carry.d.ts +21 -1
  41. package/dist/release-carry.js +22 -0
  42. package/dist/release-cli.d.ts +3 -0
  43. package/dist/release-cli.js +74 -5
  44. package/dist/release-gate.d.ts +5 -0
  45. package/dist/release-gate.js +1 -1
  46. package/dist/release-net.d.ts +90 -0
  47. package/dist/release-net.js +253 -0
  48. package/dist/release-recheck.js +3 -2
  49. package/dist/release.d.ts +40 -5
  50. package/dist/release.js +142 -52
  51. package/dist/runner-process.d.ts +67 -0
  52. package/dist/runner-process.js +219 -0
  53. package/dist/runner.d.ts +48 -6
  54. package/dist/runner.js +71 -8
  55. package/dist/scope.d.ts +19 -1
  56. package/dist/scope.js +19 -2
  57. package/dist/scout.js +2 -2
  58. package/dist/serve.js +11 -0
  59. package/dist/server/http.d.ts +2 -0
  60. package/dist/server/pages.js +7 -3
  61. package/dist/server/remote-hooks.d.ts +1 -1
  62. package/dist/server/remote-hooks.js +5 -3
  63. package/dist/server/render-results.d.ts +2 -0
  64. package/dist/server/render-results.js +1 -0
  65. package/dist/server/request-context.d.ts +2 -0
  66. package/dist/server/settings.js +22 -2
  67. package/dist/server/tasks.js +4 -1
  68. package/dist/storage-sweep.js +1 -1
  69. package/dist/store.d.ts +125 -2
  70. package/dist/store.js +250 -12
  71. package/dist/surface.js +3 -1
  72. package/dist/teardown-git.d.ts +1 -1
  73. package/dist/teardown-git.js +12 -12
  74. package/dist/toolroll-update.js +2 -2
  75. package/dist/up-role-child.js +6 -2
  76. package/dist/workspace-revision.js +3 -1
  77. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { basename, delimiter, dirname, join } from "node:path";
12
12
  import { createRequire } from "node:module";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { sleep } from "./sleep.js";
15
+ import { callLabel, readOnlyCall, retryCall, retryingExec, writeWithRecheck } from "./release-net.js";
15
16
  // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
16
17
  const sqlite = () => createRequire(import.meta.url)("node:sqlite");
17
18
  import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./release.js";
@@ -28,7 +29,7 @@ import { proveCarry } from "./release-carry.js";
28
29
  import { randomBytes, randomUUID } from "node:crypto";
29
30
  import { deploymentGate } from "./deploy-gate.js";
30
31
  import { extractCommit } from "./commit-extract.js";
31
- import { checkoutIncludes } from "./teardown-git.js";
32
+ import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
32
33
  import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
33
34
  export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
34
35
  /** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
@@ -51,10 +52,12 @@ const cleanEnv = (extra = {}) => {
51
52
  delete env["NODE_OPTIONS"];
52
53
  return env;
53
54
  };
55
+ /** A failed call, in the words every release stop uses: what, its exit code and the last lines it printed. */
56
+ const failed = (result, what) => new Error(`${what} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split("\n").slice(-3).join(" ")}`);
54
57
  const must = async (run, what) => {
55
58
  const result = await run;
56
59
  if (result.code !== 0)
57
- throw new Error(`${what} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split("\n").slice(-3).join(" ")}`);
60
+ throw failed(result, what);
58
61
  return result.stdout;
59
62
  };
60
63
  /** owner/name from an https or ssh GitHub remote. */
@@ -227,8 +230,16 @@ export function releaseAdapters(options) {
227
230
  const pause = options.sleep ?? ((ms) => new Promise(done => setTimeout(done, ms)));
228
231
  const tools = options.tools ?? releaseTools();
229
232
  const gitEnv = releaseGitEnv();
233
+ const say = options.say ?? ((line) => { process.stdout.write(`${line}\n`); });
234
+ /** A short network failure on the way to GitHub (release-net.ts): reads are asked again for up to ~3 minutes; a
235
+ * write never is, unless GitHub says it didn't happen (writeWithRecheck). */
236
+ const net = { say, sleep: options.sleep ?? sleep, ...(options.now === undefined ? {} : { now: options.now }) };
237
+ const reads = retryingExec(run, net);
230
238
  // Never a PATH lookup or the checkout's Git config: gh by its absolute path, from a neutral folder, naming the repo.
231
- const gh = (...args) => run(tools.gh, args, { cwd: "/", env: gitEnv });
239
+ // Reads ride out a short network failure; anything that isn't provably a read passes straight through (readOnlyCall).
240
+ const gh = (...args) => reads(tools.gh, args, { cwd: "/", env: gitEnv });
241
+ /** A write by gh, never retried by itself. */
242
+ const ghWrite = (remainingMs, ...args) => run(tools.gh, args, { cwd: "/", env: gitEnv, timeoutMs: Math.min(120_000, remainingMs) });
232
243
  const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
233
244
  const toolroll = async (...args) => {
234
245
  const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
@@ -245,9 +256,9 @@ export function releaseAdapters(options) {
245
256
  const ciGh = options.tools?.gh ?? releaseTools("").gh;
246
257
  const observeCi = async (sha) => {
247
258
  const observedAt = (options.now ?? (() => new Date()))().toISOString();
248
- const runs = (await readCheckRuns(run, ciGh, releaseRepo().slug, sha)).map(one => ({ id: one.id ?? null, name: one.name, status: one.status, conclusion: one.conclusion, app: one.app ?? null, suite: one.suite ?? null, head: one.head ?? null, workflow: one.workflow ?? null, workflowPath: one.workflowPath ?? null,
259
+ const runs = (await readCheckRuns(reads, ciGh, releaseRepo().slug, sha)).map(one => ({ id: one.id ?? null, name: one.name, status: one.status, conclusion: one.conclusion, app: one.app ?? null, suite: one.suite ?? null, head: one.head ?? null, workflow: one.workflow ?? null, workflowPath: one.workflowPath ?? null,
249
260
  ...(one.beforeTests === undefined ? {} : { beforeTests: one.beforeTests }) }));
250
- return { head: sha, observedAt, runs, complete: await readSuitesComplete(run, ciGh, releaseRepo().slug, sha) };
261
+ return { head: sha, observedAt, runs, complete: await readSuitesComplete(reads, ciGh, releaseRepo().slug, sha) };
251
262
  };
252
263
  const database = options.database ?? databasePath(process.env, homedir());
253
264
  let recorded = options.releaseRepo ?? null;
@@ -255,21 +266,55 @@ export function releaseAdapters(options) {
255
266
  const releaseRepo = () => recorded ??= recordedReleaseRepo(database, checkout);
256
267
  /**
257
268
  * The coordinator's one way to run Git (§0a.2): on the recorded Git directory, hardened, with an allowlisted
258
- * environment and the absolute Git. Before every command the checkout's own config is read with includes off and
259
- * as plain files; one that includes a file, names a command or redirects a remote stops the release, naming it.
269
+ * environment and the absolute Git. Every attempt uses the shared runner's private view: the audited config is
270
+ * the exact copy Git reads, so a build cannot redirect a command after its config was checked.
260
271
  */
261
272
  const git = async (...args) => {
262
273
  const repo = releaseRepo();
274
+ const full = [...releaseGitArgs(repo, tools), ...args];
263
275
  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.`);
264
- if (options.releaseRepo === undefined && checkoutIncludes(repo.checkout))
265
- throw stop("its Git config includes another file, or couldn't be read as a plain file");
266
- const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv });
267
- if (listing.code !== 0)
268
- throw stop("its Git config couldn't be read");
269
- const setting = releaseConfigProblem(listing.stdout);
270
- if (setting !== null)
271
- 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)`);
272
- return run(tools.git, [...releaseGitArgs(repo, tools), ...args], { cwd: repo.checkout, env: gitEnv });
276
+ // The checkout's config is read again right before EVERY attempt, a retried one included (checked at the moment of use).
277
+ const once = async (remainingMs = 120_000) => {
278
+ const now = () => (options.now ?? (() => new Date()))().getTime();
279
+ const started = now();
280
+ const left = () => Math.min(120_000, remainingMs - Math.max(0, now() - started));
281
+ const checkConfig = (listing) => {
282
+ if (listing.code !== 0)
283
+ throw stop("its Git config couldn't be read");
284
+ const setting = releaseConfigProblem(listing.stdout);
285
+ if (setting !== null)
286
+ 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)`);
287
+ };
288
+ if (options.releaseRepo === undefined) {
289
+ // The shared runner pins both Git directories and freezes config/info in the plane's fenced view folder.
290
+ // Its private paths replace releaseGitArgs' first two directory flags; the recorded remote stays fixed.
291
+ const viewed = teardownGit(async (_command, argv, runOptions) => {
292
+ const timeoutMs = left();
293
+ if (timeoutMs <= 0)
294
+ return { code: 124, stdout: "", stderr: "the network retry budget ran out", timedOut: true, notFound: false };
295
+ const env = { ...gitEnv, ...Object.fromEntries(["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"].map(key => [key, runOptions?.env?.[key]])) };
296
+ const result = await run(tools.git, argv, { cwd: repo.checkout, env, timeoutMs });
297
+ if (argv.includes("config"))
298
+ checkConfig(result);
299
+ return { ...result, timedOut: result.code === 124, notFound: result.code === 127 };
300
+ }, { repo: repo.checkout }, Math.max(0, left()), tools.git);
301
+ const result = await viewed(repo.checkout, full.slice(2));
302
+ const why = gitRefusal(result);
303
+ if (why !== null)
304
+ throw stop(why);
305
+ return result;
306
+ }
307
+ // Injected repository/exec fixtures have no on-disk checkout, but exercise the same config policy per attempt.
308
+ if (left() <= 0)
309
+ return { code: 124, stdout: "", stderr: "the network retry budget ran out" };
310
+ const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv, timeoutMs: left() });
311
+ checkConfig(listing);
312
+ if (left() <= 0)
313
+ return { code: 124, stdout: "", stderr: "the network retry budget ran out" };
314
+ return run(tools.git, full, { cwd: repo.checkout, env: gitEnv, timeoutMs: left() });
315
+ };
316
+ // ls-remote and fetch ride out a short network failure; a push or a local command is never retried (readOnlyCall).
317
+ return readOnlyCall(tools.git, full) === null ? once() : retryCall(once, callLabel(tools.git, full), net);
273
318
  };
274
319
  /** The remote every fetch, look and push names: the recorded URL, defined only on the command line. */
275
320
  const remoteUrl = () => RELEASE_REMOTE;
@@ -373,8 +418,8 @@ export function releaseAdapters(options) {
373
418
  rmSync(extracted.dir, { recursive: true, force: true });
374
419
  }
375
420
  };
376
- const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(run, ciGh, releaseRepo().slug, base, sha),
377
- upToDateRequired: () => readUpToDateRequired(run, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
421
+ const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(reads, ciGh, releaseRepo().slug, base, sha),
422
+ upToDateRequired: () => readUpToDateRequired(reads, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
378
423
  // A successful Actions job authenticates the job, not the candidate-writable JSON it uploads. Until the
379
424
  // executor protects that reporting channel, production releases must take the person's approval road.
380
425
  reportsProtected: false });
@@ -382,7 +427,7 @@ export function releaseAdapters(options) {
382
427
  ...(existsSync(database) ? { release } : {}),
383
428
  now: options.now ?? (() => new Date()),
384
429
  sleep: options.sleep ?? sleep,
385
- say: options.say ?? (line => process.stdout.write(`${line}\n`)),
430
+ say,
386
431
  repo: {
387
432
  // The recorded owner/name, never the checkout's origin.
388
433
  async github() { return releaseRepo().slug; },
@@ -575,33 +620,38 @@ export function releaseAdapters(options) {
575
620
  return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
576
621
  },
577
622
  // Every attempt (filter=all), with its app, check suite and commit: the release judges each job by its latest attempt.
578
- async checks(repo, sha) { return readCheckRuns(run, ciGh, repo, sha); },
579
- async complete(repo, sha) { return readSuitesComplete(run, ciGh, repo, sha); },
580
- async mainHead(repo) { return readMainHead(run, ciGh, repo); },
581
- async contains(repo, sha, ancestor) { return readContains(run, ciGh, repo, sha, ancestor); },
582
- async upToDateRequired(repo) { return readMainProtection(run, ciGh, repo); },
623
+ async checks(repo, sha, options) { return readCheckRuns(reads, ciGh, repo, sha, options); },
624
+ async complete(repo, sha) { return readSuitesComplete(reads, ciGh, repo, sha); },
625
+ async mainHead(repo) { return readMainHead(reads, ciGh, repo); },
626
+ async contains(repo, sha, ancestor) { return readContains(reads, ciGh, repo, sha, ancestor); },
627
+ async upToDateRequired(repo) { return readMainProtection(reads, ciGh, repo); },
583
628
  async merge(repo, pr, head, message) {
584
- // The squash commit's message lists every commit that landed after the approval (release-once.md).
585
- 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}`])));
586
- if (typeof merged["sha"] !== "string")
587
- throw new Error(`GitHub did not merge #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
588
- return merged["sha"];
589
- },
590
- async tag(repo, tag) {
591
- const result = await gh("api", `repos/${repo}/git/ref/tags/${tag}`);
592
- if (result.code !== 0) {
593
- if (/Not Found|HTTP 404/.test(result.stderr + result.stdout))
594
- return null;
595
- throw new Error(`gh api git/ref/tags/${tag} failed: ${result.stderr.trim()}`);
596
- }
597
- const target = object(object(JSON.parse(result.stdout))["object"]);
598
- if (target["type"] !== "tag")
599
- return String(target["sha"]);
600
- // An annotated tag: the commit it names.
601
- return String(object(object(await ghJson("gh api git/tags", "api", `repos/${repo}/git/tags/${String(target["sha"])}`))["object"])["sha"]);
629
+ // The squash commit's message lists every commit that landed after the approval (release-once.md). Never asked
630
+ // again under stale approval/lease/CI checks: reconcile a lost answer, but another write needs a fresh
631
+ // coordinator pass through those guards. A PR still being open proves none of them.
632
+ return writeWithRecheck(`merging #${pr}`, { ...net, maxAttempts: 1 }, {
633
+ write: remaining => ghWrite(remaining, "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`, ...(message === undefined ? [] : ["-f", `commit_message=${message}`])),
634
+ accept: result => {
635
+ const merged = object(JSON.parse(result.stdout));
636
+ if (merged["merged"] !== true || typeof merged["sha"] !== "string" || !/^[0-9a-f]{40}$/.test(merged["sha"]))
637
+ throw new Error(`GitHub did not merge #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
638
+ return merged["sha"];
639
+ },
640
+ refuse: result => { throw failed(result, "gh api pulls merge"); },
641
+ recheck: bounded => pullMergedAt(repo, pr, head, "main", bounded, message),
642
+ });
602
643
  },
644
+ async tag(repo, tag) { return readTag(repo, tag); },
603
645
  async createTag(repo, tag, sha) {
604
- await must(gh("api", "-X", "POST", `repos/${repo}/git/refs`, "-f", `ref=refs/tags/${tag}`, "-f", `sha=${sha}`), `creating ${tag}`);
646
+ await writeWithRecheck(`creating ${tag}`, net, {
647
+ write: remaining => ghWrite(remaining, "api", "-X", "POST", `repos/${repo}/git/refs`, "-f", `ref=refs/tags/${tag}`, "-f", `sha=${sha}`),
648
+ accept: () => undefined,
649
+ refuse: result => { throw failed(result, `creating ${tag}`); },
650
+ recheck: async (bounded) => {
651
+ const at = await readTag(repo, tag, bounded);
652
+ return at === sha ? { state: "done", value: undefined } : at === null ? { state: "not-done" } : { state: "other", why: `${tag} now points at ${at.slice(0, 7)}` };
653
+ },
654
+ });
605
655
  },
606
656
  async release(repo, tag) { return (await gh("release", "view", tag, "--repo", repo, "--json", "tagName")).code === 0; },
607
657
  },
@@ -653,8 +703,16 @@ export function releaseAdapters(options) {
653
703
  const current = await formulaAt(branch);
654
704
  if (current.text.includes(`-${version}.tgz`))
655
705
  return { kind: "current" };
656
- const open = (await ghJson("gh pr list", "pr", "list", "--repo", tap, "--head", brewBranch(version), "--state", "open", "--json", "number,headRefOid"));
657
- const pr = open[0];
706
+ // Ordering (pipeline-speed.md, P1): a tap already on a later version is never moved back by an older release.
707
+ const named = formulaVersion(current.text);
708
+ if (named !== null && versionAfter(named, version))
709
+ return { kind: "newer", version: named };
710
+ // Only a change from the tap's own branch counts: a fork's pull request of the same branch name never does.
711
+ const open = (await ghJson("gh pr list", "pr", "list", "--repo", tap, "--head", brewBranch(version), "--state", "open", "--json", "number,headRefOid,headRepository,headRepositoryOwner,isCrossRepository"));
712
+ const own = open.filter(one => tapOwnHead(one, tap));
713
+ if (own.length > 1)
714
+ throw new Error(`${tap} has ${own.length} open changes for ${brewBranch(version)}`);
715
+ const pr = own[0];
658
716
  return pr === undefined ? { kind: "missing" } : { kind: "proposed", pr: Number(pr["number"]), head: String(pr["headRefOid"]) };
659
717
  },
660
718
  async propose(version) {
@@ -664,14 +722,20 @@ export function releaseAdapters(options) {
664
722
  throw new Error(`downloading ${tarball} answered ${response.status}`);
665
723
  const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
666
724
  const base = await defaultBranch();
667
- const current = await formulaAt(base);
725
+ const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
726
+ const current = await formulaAt(baseSha);
727
+ const baseVersion = formulaVersion(current.text);
728
+ if (baseVersion === null || versionAfter(baseVersion, version))
729
+ throw new Error("The tap current version is unreadable or newer; no downgrade was proposed.");
668
730
  const next = bumpFormula(current.text, tarball, sha256);
669
731
  const branch = brewBranch(version);
670
- const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
671
732
  // A branch left by an earlier attempt is reused.
672
733
  const made = await gh("api", "-X", "POST", `repos/${tap}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`);
673
734
  if (made.code !== 0 && !/Reference already exists/.test(made.stderr + made.stdout))
674
735
  throw new Error(`creating ${tap} ${branch}: ${made.stderr.trim()}`);
736
+ // A branch left by an earlier attempt starts again from the base (security scan): none of its history is kept.
737
+ if (made.code !== 0)
738
+ await must(gh("api", "-X", "PATCH", `repos/${tap}/git/refs/heads/${branch}`, "-f", `sha=${baseSha}`, "-F", "force=true"), `resetting ${tap} ${branch}`);
675
739
  const onBranch = await formulaAt(branch);
676
740
  if (onBranch.text !== next) {
677
741
  await must(gh("api", "-X", "PUT", `repos/${tap}/contents/${formula}`, "-f", `message=toolroll ${version}`, "-f", `branch=${branch}`, "-f", `sha=${onBranch.sha}`, "-f", `content=${Buffer.from(next).toString("base64")}`), `updating ${formula}`);
@@ -680,13 +744,76 @@ export function releaseAdapters(options) {
680
744
  const opened = (await ghJson("gh pr view", "pr", "view", branch, "--repo", tap, "--json", "number,headRefOid"));
681
745
  return { pr: Number(opened["number"]), head: String(opened["headRefOid"]) };
682
746
  },
747
+ async verified(pr, head, version) {
748
+ // The change at exactly `head` is the tap's own, and its formula is exactly today's formula with npm's published
749
+ // tarball and that tarball's own sha256: nothing else is ever merged.
750
+ const view = object(await ghJson("gh pr view", "pr", "view", String(pr), "--repo", tap, "--json", "number,headRefOid,headRefName,baseRefName,headRepository,headRepositoryOwner,isCrossRepository,state"));
751
+ const base = await defaultBranch();
752
+ const baseHead = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
753
+ // The whole change against the tap's default branch head: its merge base, its commits and every file it touches.
754
+ const compare = object(await ghJson("gh api compare", "api", `repos/${tap}/compare/${encodeURIComponent(baseHead)}...${encodeURIComponent(head)}`));
755
+ const tarball = `https://registry.npmjs.org/toolroll/-/toolroll-${version}.tgz`;
756
+ const response = await get(tarball, { signal: AbortSignal.timeout(60_000) });
757
+ if (!response.ok)
758
+ return { ok: false, why: `downloading ${tarball} answered ${response.status}` };
759
+ const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
760
+ const current = (await formulaAt(baseHead)).text;
761
+ const expected = bumpFormula(current, tarball, sha256);
762
+ const problem = tapChangeProblem({ tap, pr, head, version, formula, defaultBranch: base, baseHead, baseVersion: formulaVersion(current), view, compare, expected, atHead: (await formulaAt(head)).text, sha256 });
763
+ return problem === null ? { ok: true } : { ok: false, why: problem };
764
+ },
683
765
  async merge(pr, head) {
684
- const merged = object(await ghJson(`merging ${tap} #${pr}`, "api", "-X", "PUT", `repos/${tap}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
685
- if (merged["merged"] !== true || typeof merged["sha"] !== "string")
686
- throw new Error(`GitHub did not merge ${tap} #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
766
+ const base = await defaultBranch();
767
+ await writeWithRecheck(`merging ${tap} #${pr}`, { ...net, maxAttempts: 1 }, {
768
+ write: remaining => ghWrite(remaining, "api", "-X", "PUT", `repos/${tap}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`),
769
+ accept: result => {
770
+ const merged = object(JSON.parse(result.stdout));
771
+ if (merged["merged"] !== true || typeof merged["sha"] !== "string" || !/^[0-9a-f]{40}$/.test(merged["sha"]))
772
+ throw new Error(`GitHub did not merge ${tap} #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
773
+ },
774
+ refuse: result => { throw failed(result, `merging ${tap} #${pr}`); },
775
+ recheck: async (bounded) => { const state = await pullMergedAt(tap, pr, head, base, bounded); return state.state === "done" ? { state: "done", value: undefined } : state; },
776
+ });
687
777
  },
688
778
  },
689
779
  };
780
+ /** A tag's commit as GitHub answers now (an annotated tag: the commit it names), or null when there is no such tag. */
781
+ async function readTag(repo, tag, bounded = net) {
782
+ const read = (path) => retryingExec(run, bounded)(tools.gh, ["api", path], { cwd: "/", env: gitEnv });
783
+ const result = await read(`repos/${repo}/git/ref/tags/${tag}`);
784
+ if (result.code !== 0) {
785
+ if (/Not Found|HTTP 404/.test(result.stderr + result.stdout))
786
+ return null;
787
+ throw new Error(`gh api git/ref/tags/${tag} failed: ${result.stderr.trim()}`);
788
+ }
789
+ const target = object(object(JSON.parse(result.stdout))["object"]);
790
+ if (target["type"] !== "tag")
791
+ return String(target["sha"]);
792
+ return String(object(object(JSON.parse(await must(read(`repos/${repo}/git/tags/${String(target["sha"])}`), "gh api git/tags")))["object"])["sha"]);
793
+ }
794
+ /** Whether a pull request is merged from exactly `head` (its merge commit), still open at `head` (not merged), or
795
+ * anything else — read after a merge's answer was lost. */
796
+ async function pullMergedAt(repo, pr, head, base, bounded, message) {
797
+ const read = async (path) => object(JSON.parse(await must(retryingExec(run, bounded)(tools.gh, ["api", path], { cwd: "/", env: gitEnv }), "gh api merge recovery")));
798
+ const pull = await read(`repos/${repo}/pulls/${pr}`);
799
+ const at = object(pull["head"])["sha"], commit = pull["merge_commit_sha"];
800
+ if (at !== head || object(pull["base"])["ref"] !== base)
801
+ return { state: "other", why: `#${pr} is no longer open at ${head.slice(0, 7)} into ${base}` };
802
+ if (pull["merged"] === true && pull["state"] === "closed" && typeof commit === "string" && /^[0-9a-f]{40}$/.test(commit)) {
803
+ // Another caller may have merged it. Only the same squash result counts, never merely "merged=true".
804
+ const source = await read(`repos/${repo}/git/commits/${head}`);
805
+ const merged = await read(`repos/${repo}/git/commits/${commit}`);
806
+ const tree = object(source["tree"])["sha"], parents = merged["parents"];
807
+ if (typeof tree === "string" && /^[0-9a-f]{40}$/.test(tree) && object(merged["tree"])["sha"] === tree
808
+ && Array.isArray(parents) && parents.length === 1
809
+ && (message === undefined || (typeof merged["message"] === "string" && merged["message"].endsWith(`\n\n${message}`))))
810
+ return { state: "done", value: commit };
811
+ return { state: "other", why: `#${pr}'s merge is not the approved squash result` };
812
+ }
813
+ if (pull["merged"] === false && pull["state"] === "open")
814
+ return { state: "not-done" };
815
+ return { state: "other", why: `#${pr} is no longer open at ${head.slice(0, 7)}` };
816
+ }
690
817
  async function defaultBranch() {
691
818
  return (await must(gh("api", `repos/${tap}`, "--jq", ".default_branch"), `gh api repos/${tap}`)).trim();
692
819
  }
@@ -696,6 +823,58 @@ export function releaseAdapters(options) {
696
823
  }
697
824
  }
698
825
  const brewBranch = (version) => `toolroll-${version}`;
826
+ /**
827
+ * Why a tap change isn't exactly the formula bump this release proposed (security scan), or null when it is: the tap's
828
+ * own open pull request number `pr` at exactly `head`, from its version branch into the default branch; compared with
829
+ * the default branch's head it is one commit on top of exactly that head, changing exactly one file, the formula; and
830
+ * the formula there is exactly today's with npm's published tarball and that tarball's own sha256.
831
+ */
832
+ export function tapChangeProblem(input) {
833
+ const { tap, pr, head, version, view, compare } = input;
834
+ // State may have changed since the coordinator checked it. Judge the immutable base used for this comparison.
835
+ if (input.baseVersion === null)
836
+ return `${tap} current version cannot be read`;
837
+ if (versionAfter(input.baseVersion, version))
838
+ return `${tap} already names newer ${input.baseVersion}; refusing ${version}`;
839
+ if (Number(view["number"]) !== pr || view["headRefOid"] !== head || view["headRefName"] !== brewBranch(version) || view["state"] !== "OPEN" || !tapOwnHead(view, tap)) {
840
+ return `${tap} #${pr} isn't the tap's own open change at ${head.slice(0, 7)}`;
841
+ }
842
+ if (view["baseRefName"] !== input.defaultBranch)
843
+ return `${tap} #${pr} merges into ${String(view["baseRefName"])}, not ${input.defaultBranch}`;
844
+ const mergeBase = compare["merge_base_commit"] !== null && typeof compare["merge_base_commit"] === "object" ? compare["merge_base_commit"]["sha"] : undefined;
845
+ if (mergeBase !== input.baseHead)
846
+ return `${tap} #${pr} isn't on ${input.defaultBranch}'s head ${input.baseHead.slice(0, 7)}`;
847
+ if (compare["status"] !== "ahead" || compare["ahead_by"] !== 1 || compare["behind_by"] !== 0)
848
+ return `${tap} #${pr} isn't exactly one commit on ${input.defaultBranch}'s head`;
849
+ const files = Array.isArray(compare["files"]) ? compare["files"] : null;
850
+ if (files === null || files.length !== 1 || files[0]["filename"] !== input.formula || files[0]["status"] !== "modified")
851
+ return `${tap} #${pr} changes more than ${input.formula}`;
852
+ if (input.atHead !== input.expected)
853
+ return `${tap} #${pr}'s formula isn't npm's ${version} tarball with its sha256 ${input.sha256.slice(0, 12)}`;
854
+ return null;
855
+ }
856
+ /** Whether a pull request's head is a branch of the tap itself (never a fork's). */
857
+ export function tapOwnHead(pr, tap) {
858
+ const [owner, name] = tap.split("/");
859
+ const repo = pr["headRepository"], who = pr["headRepositoryOwner"];
860
+ const repoName = repo !== null && typeof repo === "object" ? repo["name"] : undefined;
861
+ const ownerLogin = who !== null && typeof who === "object" ? who["login"] : undefined;
862
+ return pr["isCrossRepository"] === false && typeof repoName === "string" && typeof ownerLogin === "string"
863
+ && repoName.toLowerCase() === (name ?? "").toLowerCase() && ownerLogin.toLowerCase() === (owner ?? "").toLowerCase();
864
+ }
865
+ /** The version a formula's tarball url names (`…/toolroll-<x.y.z>.tgz`), or null. */
866
+ export function formulaVersion(text) {
867
+ const found = /^\s*url "[^"]*-(\d+\.\d+\.\d+)\.tgz"/m.exec(text);
868
+ return found === null ? null : found[1];
869
+ }
870
+ /** Whether version `a` comes after `b` (both x.y.z). */
871
+ export function versionAfter(a, b) {
872
+ const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
873
+ for (let i = 0; i < 3; i++)
874
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
875
+ return (pa[i] ?? 0) > (pb[i] ?? 0);
876
+ return false;
877
+ }
699
878
  /** The events that put checks on a change. */
700
879
  const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
701
880
  /**
@@ -918,7 +1097,11 @@ function ciEnv() {
918
1097
  * lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
919
1098
  * first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
920
1099
  */
921
- export async function readCheckRuns(run, gh, repo, sha) {
1100
+ export async function readCheckRuns(run, gh, repo, sha,
1101
+ /** A release tag (pipeline-speed.md, item 3): a push of exactly this tag binds its workflow file too. */
1102
+ options = {}) {
1103
+ if (options.tag !== undefined && !/^v\d+\.\d+\.\d+$/.test(options.tag))
1104
+ throw new Error("not a release tag");
922
1105
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
923
1106
  throw new Error("not a repository and commit GitHub can be asked about");
924
1107
  const out = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/commits/${sha}/check-runs?filter=all&per_page=100`,
@@ -964,7 +1147,7 @@ export async function readCheckRuns(run, gh, repo, sha) {
964
1147
  const prs = (Array.isArray(one["prs"]) ? one["prs"] : []).map(pr => (pr !== null && typeof pr === "object" ? pr : {}));
965
1148
  const ours = prs.length > 0 && prs.every(pr => Number.isSafeInteger(pr["number"]) && pr["repo"] === `https://api.github.com/repos/${repo}`);
966
1149
  const untouched = one["event"] !== "pull_request" || (ours && !(await Promise.all(prs.map(pr => everRetargeted(Number(pr["number"]))))).some(Boolean));
967
- if (mainTriggered(one) && untouched)
1150
+ if ((options.tag === undefined ? mainTriggered(one) : tagTriggered(one, options.tag)) && untouched)
968
1151
  pathOf.set(Number(one["suite"]), String(one["path"]));
969
1152
  }
970
1153
  const runs = out.split("\n").filter(Boolean).map(line => {
@@ -1085,15 +1268,19 @@ export async function readContains(run, gh, repo, sha, ancestor) {
1085
1268
  * can't be bypassed by that account:
1086
1269
  * classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
1087
1270
  * bypass list reads whole, names no role, team or organization admin, and says this account never bypasses it. Read
1088
- * only — Toolroll never changes a repository's settings. A layer that can't be read never holds; null only when nothing
1089
- * could be read at all.
1271
+ * only — Toolroll never changes a repository's settings. A layer that can't be read never holds. When no layer holds
1272
+ * and one couldn't be read (gh failed for a reason other than "not protected", or its answer didn't parse), the read
1273
+ * throws ProtectionUnreadable: "couldn't read GitHub" is never reported as "protection doesn't hold", and never as
1274
+ * holding. Null only for a repository or branch name GitHub can't be asked about.
1090
1275
  */
1091
1276
  export async function readMainProtection(run, gh, repo, branch = "main") {
1092
1277
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
1093
1278
  return null;
1094
1279
  const missing = [];
1095
1280
  const held = [];
1096
- let read = 0;
1281
+ /** What couldn't be read at all, and the words gh gave. */
1282
+ const unread = [];
1283
+ const words = (out) => (out.stderr || out.stdout).trim().split("\n").slice(-1)[0]?.slice(0, 160) || `exit ${out.code}`;
1097
1284
  const judge = (where, strict, checks, bound, bypass) => {
1098
1285
  const own = [];
1099
1286
  if (!strict)
@@ -1139,46 +1326,41 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1139
1326
  catch { /* unreadable below */ }
1140
1327
  const p = object(answer), checks = p["required_status_checks"];
1141
1328
  if (answer === null || typeof answer !== "object" || Array.isArray(answer))
1142
- missing.push("classic protection couldn't be read");
1143
- else if (checks === undefined || checks === null) {
1144
- read++;
1329
+ unread.push("classic protection (its answer didn't parse)");
1330
+ else if (checks === undefined || checks === null)
1145
1331
  missing.push("classic protection: \"Require status checks to pass before merging\" is off");
1146
- }
1147
1332
  else {
1148
1333
  const rsc = object(checks), contexts = names(rsc["contexts"] ?? [], ""), apps = names(rsc["checks"] ?? [], "context");
1149
1334
  const admins = p["enforce_admins"];
1335
+ // GitHub answered, but without the settings this judges: read, and not holding.
1150
1336
  if (contexts === null || apps === null || typeof rsc["strict"] !== "boolean" || admins === undefined || admins === null || typeof object(admins)["enabled"] !== "boolean")
1151
- missing.push("classic protection couldn't be read");
1337
+ missing.push("classic protection: GitHub's answer doesn't say which checks it requires or whether administrators can bypass it");
1152
1338
  else {
1153
- read++;
1154
1339
  judge("classic protection", rsc["strict"] === true, [...new Set([...contexts, ...apps])], actionsChecks(rsc["checks"], "app_id"), object(admins)["enabled"] === true ? [] : ["\"Do not allow bypassing the above settings\" is off, so administrators can merge past it"]);
1155
1340
  }
1156
1341
  }
1157
1342
  }
1158
- else if (notProtected(classic))
1159
- read++;
1160
- else
1161
- missing.push("classic protection couldn't be read");
1343
+ else if (!notProtected(classic))
1344
+ unread.push(`classic protection (${words(classic)})`);
1162
1345
  // Rulesets: every active rule requiring status checks on the branch, then its ruleset's enforcement and bypass list.
1163
1346
  const rules = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/rules/branches/${branch}`,
1164
1347
  "--jq", ".[] | select(.type == \"required_status_checks\") | @json"], { cwd: "/", env: ciEnv() });
1165
1348
  if (rules.code !== 0)
1166
- missing.push("main's rulesets couldn't be read");
1349
+ unread.push(`main's rulesets (${words(rules)})`);
1167
1350
  else {
1168
- read++;
1169
1351
  for (const line of rules.stdout.split("\n").filter(one => one.trim() !== "")) {
1170
1352
  let rule;
1171
1353
  try {
1172
1354
  rule = object(JSON.parse(line));
1173
1355
  }
1174
1356
  catch {
1175
- missing.push("a ruleset rule couldn't be read");
1357
+ unread.push("a ruleset rule (its answer didn't parse)");
1176
1358
  continue;
1177
1359
  }
1178
1360
  const id = rule["ruleset_id"], params = object(rule["parameters"]);
1179
1361
  const checks = names(params["required_status_checks"], "context");
1180
1362
  if (!Number.isSafeInteger(id) || Number(id) <= 0 || checks === null) {
1181
- missing.push("a ruleset rule couldn't be read");
1363
+ missing.push("a ruleset rule doesn't name its ruleset or required checks");
1182
1364
  continue;
1183
1365
  }
1184
1366
  const where = `ruleset ${id}`;
@@ -1192,7 +1374,7 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1192
1374
  set = null;
1193
1375
  }
1194
1376
  if (set === null) {
1195
- missing.push(`${where}: its settings couldn't be read`);
1377
+ unread.push(`${where}: its settings (${got.code === 0 ? "its answer didn't parse" : words(got)})`);
1196
1378
  continue;
1197
1379
  }
1198
1380
  const bypass = [];
@@ -1216,17 +1398,37 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1216
1398
  const required = new Set(held.flat());
1217
1399
  return { effective: true, missing: [], notYetRequired: PROTECTION_REPORT_ONLY_CHECKS.filter(name => !required.has(name)) };
1218
1400
  }
1219
- if (read === 0)
1220
- return null;
1401
+ if (unread.length > 0)
1402
+ throw new ProtectionUnreadable(unread);
1221
1403
  if (missing.length === 0)
1222
1404
  missing.push("main has no branch protection or ruleset requiring status checks");
1223
1405
  return { effective: false, missing, notYetRequired: [] };
1224
1406
  }
1407
+ /** Main's protection couldn't be read from GitHub (after the network retries): not "doesn't hold", and never "holds". */
1408
+ export class ProtectionUnreadable extends Error {
1409
+ unread;
1410
+ constructor(unread) {
1411
+ super(`couldn't read GitHub's branch protection for main: ${unread.join("; ")}`);
1412
+ this.unread = unread;
1413
+ }
1414
+ }
1225
1415
  /** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
1226
1416
  export async function readUpToDateRequired(run, gh, repo, branch = "main") {
1227
- const protection = await readMainProtection(run, gh, repo, branch);
1417
+ let protection;
1418
+ try {
1419
+ protection = await readMainProtection(run, gh, repo, branch);
1420
+ }
1421
+ catch (error) {
1422
+ if (error instanceof ProtectionUnreadable)
1423
+ return null;
1424
+ throw error;
1425
+ }
1228
1426
  return protection === null ? null : protection.effective;
1229
1427
  }
1428
+ /** Whether a workflow run is the push of exactly this release tag (its workflow file is the tagged commit's own, on main). */
1429
+ function tagTriggered(run, tag) {
1430
+ return tag !== undefined && run["event"] === "push" && run["branch"] === tag;
1431
+ }
1230
1432
  /** Whether a workflow run was triggered on main: a push to main, or a pull request whose every base is main. */
1231
1433
  function mainTriggered(run) {
1232
1434
  const bases = Array.isArray(run["bases"]) ? run["bases"] : [];
@@ -1,5 +1,5 @@
1
1
  import { type TreeChange, type VerifiedCommit } from "./commit-extract.js";
2
- import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
2
+ import { type ReleaseCheckPurpose, type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
3
3
  import { type ExecResult } from "./exec.js";
4
4
  import { type ReleaseAuthority, type ReleaseCarry, type Store } from "./store.js";
5
5
  /** The journey scripts: tests, by Alex's decision (they carry). */
@@ -66,6 +66,26 @@ export declare function releaseTermsFor(store: Store, input: {
66
66
  ok: false;
67
67
  why: string;
68
68
  };
69
+ /**
70
+ * A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
71
+ * outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
72
+ * and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
73
+ * this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
74
+ * infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
75
+ */
76
+ export declare function releaseCheckPurposeFor(store: Store, input: {
77
+ repo: string;
78
+ version: string;
79
+ branch: string;
80
+ pr: number;
81
+ candidate: string;
82
+ }): {
83
+ ok: true;
84
+ purpose: ReleaseCheckPurpose;
85
+ } | {
86
+ ok: false;
87
+ why: string;
88
+ };
69
89
  /** One carried commit, as the summary and the merge message list it. */
70
90
  export type LandedCommit = {
71
91
  sha: string;
@@ -165,6 +165,28 @@ export function releaseTermsFor(store, input) {
165
165
  expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
166
166
  } };
167
167
  }
168
+ /**
169
+ * A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
170
+ * outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
171
+ * and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
172
+ * this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
173
+ * infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
174
+ */
175
+ export function releaseCheckPurposeFor(store, input) {
176
+ const gitDir = projectGitDir(input.repo);
177
+ if (gitDir === null)
178
+ return { ok: false, why: "the project's Git directory can't be read" };
179
+ const commit = readVerifiedCommit(gitDir, input.candidate);
180
+ if (!commit.ok)
181
+ return { ok: false, why: commit.message };
182
+ const grant = store.liveVerifyCommand(input.repo);
183
+ if (grant === null)
184
+ return { ok: false, why: "the project has no approved release check" };
185
+ return { ok: true, purpose: {
186
+ version: 1, purpose: "release-check", repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
187
+ approvedSha: input.candidate, approvedTree: commit.value.tree, grant: { id: grant.id, digest: grant.digest }, access: store.projectCheckAccess(input.repo),
188
+ } };
189
+ }
168
190
  /**
169
191
  * Step 2, read-only: nothing from the candidate is installed, built or run here. Objects come from the project's own
170
192
  * Git directory as Toolroll records it, hashed here; the authority is read live.