toolroll 0.9.78 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.79 — 2026-10-29
4
+
5
+ - **`toolroll release` rides out a short network failure instead of
6
+ stopping.** A read from GitHub or git (`gh api`, `gh pr list`,
7
+ `git ls-remote`, `git fetch`) that fails for a passing reason (no
8
+ connection, a name that didn't resolve, a timeout, a 5xx, an answer cut
9
+ short, GitHub's secondary rate limit) is asked again with backoff for up
10
+ to about three minutes, saying why once; then it stops with the original
11
+ error. A definitive answer (404, 401/403) is never asked again. A merge or
12
+ a tag is never sent twice blindly: after a lost answer the release reads
13
+ GitHub to learn whether it happened, and asks again only when it didn't.
14
+ When main's branch protection can't be read, the release now says
15
+ "Couldn't read GitHub's branch protection" instead of "main's protection
16
+ doesn't hold", and still stops.
17
+
3
18
  ## 0.9.78 — 2026-10-29
4
19
 
5
20
  - **Toolroll says when it is restarting.** The worker and console now record
@@ -97,7 +97,6 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
- version?: never;
101
100
  active: boolean;
102
101
  phase: string;
103
102
  detail: string;
@@ -111,6 +110,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
111
110
  wasRunning?: never;
112
111
  backupPath?: never;
113
112
  updateId?: never;
113
+ version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
@@ -17,4 +17,4 @@ export declare function lockCheckout(home: string, checkout: string, timeoutMs?:
17
17
  * another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
18
18
  * under the lock its caller already holds instead of waiting on itself.
19
19
  */
20
- export declare function withCheckoutLock<T>(home: string, checkout: string, work: () => Promise<T>): Promise<T>;
20
+ export declare function withCheckoutLock<T>(home: string, checkout: string, work: () => Promise<T>, timeoutMs?: number): Promise<T>;
package/dist/git-views.js CHANGED
@@ -106,11 +106,11 @@ const heldHere = new AsyncLocalStorage();
106
106
  * another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
107
107
  * under the lock its caller already holds instead of waiting on itself.
108
108
  */
109
- export async function withCheckoutLock(home, checkout, work) {
109
+ export async function withCheckoutLock(home, checkout, work, timeoutMs) {
110
110
  const held = heldHere.getStore() ?? new Set();
111
111
  if (held.has(checkout))
112
112
  return work();
113
- const release = await lockCheckout(home, checkout);
113
+ const release = await lockCheckout(home, checkout, timeoutMs);
114
114
  try {
115
115
  return await heldHere.run(new Set([...held, checkout]), work);
116
116
  }
@@ -22,17 +22,20 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
+ title?: never;
26
+ history?: never;
25
27
  revision: number;
26
28
  stale: boolean;
27
29
  notice: string;
28
30
  decision?: never;
29
- title?: never;
30
31
  content?: never;
31
- history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
+ title?: never;
37
+ revision?: never;
38
+ history?: never;
36
39
  stale?: never;
37
40
  decision: {
38
41
  repo: string;
@@ -49,28 +52,25 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
49
52
  id: number;
50
53
  };
51
54
  notice: string;
52
- title?: never;
53
55
  content?: never;
54
- revision?: never;
55
- history?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
59
59
  } | {
60
+ history?: never;
60
61
  stale?: never;
61
62
  decision?: never;
62
63
  revision: number;
63
64
  title: string;
64
65
  content: string;
65
66
  notice: string;
66
- history?: never;
67
67
  instructions?: never;
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
+ title?: never;
71
72
  stale?: never;
72
73
  decision?: never;
73
- title?: never;
74
74
  content?: never;
75
75
  revision: number;
76
76
  history: {
@@ -176,9 +176,16 @@ export declare function readContains(run: Exec, gh: string, repo: string, sha: s
176
176
  * can't be bypassed by that account:
177
177
  * classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
178
178
  * bypass list reads whole, names no role, team or organization admin, and says this account never bypasses it. Read
179
- * only — Toolroll never changes a repository's settings. A layer that can't be read never holds; null only when nothing
180
- * could be read at all.
179
+ * only — Toolroll never changes a repository's settings. A layer that can't be read never holds. When no layer holds
180
+ * and one couldn't be read (gh failed for a reason other than "not protected", or its answer didn't parse), the read
181
+ * throws ProtectionUnreadable: "couldn't read GitHub" is never reported as "protection doesn't hold", and never as
182
+ * holding. Null only for a repository or branch name GitHub can't be asked about.
181
183
  */
182
184
  export declare function readMainProtection(run: Exec, gh: string, repo: string, branch?: string): Promise<MainProtection | null>;
185
+ /** Main's protection couldn't be read from GitHub (after the network retries): not "doesn't hold", and never "holds". */
186
+ export declare class ProtectionUnreadable extends Error {
187
+ readonly unread: readonly string[];
188
+ constructor(unread: readonly string[]);
189
+ }
183
190
  /** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
184
191
  export declare function readUpToDateRequired(run: Exec, gh: string, repo: string, branch?: string): Promise<boolean | null>;
@@ -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, options) { return readCheckRuns(run, ciGh, repo, sha, options); },
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
  },
@@ -713,12 +763,57 @@ export function releaseAdapters(options) {
713
763
  return problem === null ? { ok: true } : { ok: false, why: problem };
714
764
  },
715
765
  async merge(pr, head) {
716
- const merged = object(await ghJson(`merging ${tap} #${pr}`, "api", "-X", "PUT", `repos/${tap}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
717
- if (merged["merged"] !== true || typeof merged["sha"] !== "string")
718
- 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
+ });
719
777
  },
720
778
  },
721
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
+ }
722
817
  async function defaultBranch() {
723
818
  return (await must(gh("api", `repos/${tap}`, "--jq", ".default_branch"), `gh api repos/${tap}`)).trim();
724
819
  }
@@ -1173,15 +1268,19 @@ export async function readContains(run, gh, repo, sha, ancestor) {
1173
1268
  * can't be bypassed by that account:
1174
1269
  * classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
1175
1270
  * bypass list reads whole, names no role, team or organization admin, and says this account never bypasses it. Read
1176
- * only — Toolroll never changes a repository's settings. A layer that can't be read never holds; null only when nothing
1177
- * 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.
1178
1275
  */
1179
1276
  export async function readMainProtection(run, gh, repo, branch = "main") {
1180
1277
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
1181
1278
  return null;
1182
1279
  const missing = [];
1183
1280
  const held = [];
1184
- 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}`;
1185
1284
  const judge = (where, strict, checks, bound, bypass) => {
1186
1285
  const own = [];
1187
1286
  if (!strict)
@@ -1227,46 +1326,41 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1227
1326
  catch { /* unreadable below */ }
1228
1327
  const p = object(answer), checks = p["required_status_checks"];
1229
1328
  if (answer === null || typeof answer !== "object" || Array.isArray(answer))
1230
- missing.push("classic protection couldn't be read");
1231
- else if (checks === undefined || checks === null) {
1232
- read++;
1329
+ unread.push("classic protection (its answer didn't parse)");
1330
+ else if (checks === undefined || checks === null)
1233
1331
  missing.push("classic protection: \"Require status checks to pass before merging\" is off");
1234
- }
1235
1332
  else {
1236
1333
  const rsc = object(checks), contexts = names(rsc["contexts"] ?? [], ""), apps = names(rsc["checks"] ?? [], "context");
1237
1334
  const admins = p["enforce_admins"];
1335
+ // GitHub answered, but without the settings this judges: read, and not holding.
1238
1336
  if (contexts === null || apps === null || typeof rsc["strict"] !== "boolean" || admins === undefined || admins === null || typeof object(admins)["enabled"] !== "boolean")
1239
- 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");
1240
1338
  else {
1241
- read++;
1242
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"]);
1243
1340
  }
1244
1341
  }
1245
1342
  }
1246
- else if (notProtected(classic))
1247
- read++;
1248
- else
1249
- missing.push("classic protection couldn't be read");
1343
+ else if (!notProtected(classic))
1344
+ unread.push(`classic protection (${words(classic)})`);
1250
1345
  // Rulesets: every active rule requiring status checks on the branch, then its ruleset's enforcement and bypass list.
1251
1346
  const rules = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/rules/branches/${branch}`,
1252
1347
  "--jq", ".[] | select(.type == \"required_status_checks\") | @json"], { cwd: "/", env: ciEnv() });
1253
1348
  if (rules.code !== 0)
1254
- missing.push("main's rulesets couldn't be read");
1349
+ unread.push(`main's rulesets (${words(rules)})`);
1255
1350
  else {
1256
- read++;
1257
1351
  for (const line of rules.stdout.split("\n").filter(one => one.trim() !== "")) {
1258
1352
  let rule;
1259
1353
  try {
1260
1354
  rule = object(JSON.parse(line));
1261
1355
  }
1262
1356
  catch {
1263
- missing.push("a ruleset rule couldn't be read");
1357
+ unread.push("a ruleset rule (its answer didn't parse)");
1264
1358
  continue;
1265
1359
  }
1266
1360
  const id = rule["ruleset_id"], params = object(rule["parameters"]);
1267
1361
  const checks = names(params["required_status_checks"], "context");
1268
1362
  if (!Number.isSafeInteger(id) || Number(id) <= 0 || checks === null) {
1269
- missing.push("a ruleset rule couldn't be read");
1363
+ missing.push("a ruleset rule doesn't name its ruleset or required checks");
1270
1364
  continue;
1271
1365
  }
1272
1366
  const where = `ruleset ${id}`;
@@ -1280,7 +1374,7 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1280
1374
  set = null;
1281
1375
  }
1282
1376
  if (set === null) {
1283
- 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)})`);
1284
1378
  continue;
1285
1379
  }
1286
1380
  const bypass = [];
@@ -1304,15 +1398,31 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1304
1398
  const required = new Set(held.flat());
1305
1399
  return { effective: true, missing: [], notYetRequired: PROTECTION_REPORT_ONLY_CHECKS.filter(name => !required.has(name)) };
1306
1400
  }
1307
- if (read === 0)
1308
- return null;
1401
+ if (unread.length > 0)
1402
+ throw new ProtectionUnreadable(unread);
1309
1403
  if (missing.length === 0)
1310
1404
  missing.push("main has no branch protection or ruleset requiring status checks");
1311
1405
  return { effective: false, missing, notYetRequired: [] };
1312
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
+ }
1313
1415
  /** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
1314
1416
  export async function readUpToDateRequired(run, gh, repo, branch = "main") {
1315
- 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
+ }
1316
1426
  return protection === null ? null : protection.effective;
1317
1427
  }
1318
1428
  /** Whether a workflow run is the push of exactly this release tag (its workflow file is the tagged commit's own, on main). */
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The release tool's one way through a short network failure on the way to GitHub (git or gh): a read that failed for a
3
+ * transient reason — a connection refused or reset, a name that didn't resolve, a time limit, a 5xx, a truncated or
4
+ * unparseable answer, GitHub's secondary rate limit — is asked again with backoff for up to about three minutes, then
5
+ * fails with its original error. A definitive answer (404, 401/403, bad credentials, a missing program) is never asked
6
+ * again. Nothing here ever turns "couldn't read" into an answer: the caller still sees the failure and fails closed.
7
+ *
8
+ * Only a read is retried blindly. A call that changes something (a merge, a tag, a branch push, a release, a tap
9
+ * change) goes through `writeWithRecheck`: after a transient failure it re-reads GitHub to learn whether the write
10
+ * happened, and asks again only when it provably didn't.
11
+ */
12
+ import type { Exec } from "./release-adapters.js";
13
+ export type ExecResult = {
14
+ code: number;
15
+ stdout: string;
16
+ stderr: string;
17
+ };
18
+ /** How long one call may keep asking, and how far apart. */
19
+ export declare const NET_RETRY_BUDGET_MS = 180000;
20
+ export declare const NET_RETRY_MAX_ATTEMPTS = 12;
21
+ export declare const netBackoff: (retry: number) => number;
22
+ type RetryBudget = {
23
+ remaining: () => number;
24
+ sleep: (ms: number) => Promise<void>;
25
+ };
26
+ declare const sharedBudget: unique symbol;
27
+ export type RetryOptions = {
28
+ say: (line: string) => void;
29
+ sleep: (ms: number) => Promise<void>;
30
+ now?: () => Date;
31
+ budgetMs?: number;
32
+ maxAttempts?: number;
33
+ /** Recovery reads inherit their write's budget, including under an injected clock. */
34
+ [sharedBudget]?: RetryBudget;
35
+ };
36
+ /**
37
+ * Whether a failure (its words, and the exit code when it came from a program) is transient: the phrase worth saying,
38
+ * and how long GitHub asked to wait (Retry-After) when it said. Null for a definitive answer or anything unknown —
39
+ * an unknown failure is never retried.
40
+ */
41
+ export declare function transientFailure(text: string, code?: number): {
42
+ words: string;
43
+ retryAfterMs: number | null;
44
+ } | null;
45
+ /** Whether one exec result failed transiently (exit 0 never did, unless its answer doesn't parse; see readOnlyCall). */
46
+ export declare function transientResult(result: ExecResult): ReturnType<typeof transientFailure>;
47
+ /**
48
+ * Whether a call only reads (and so may be asked again blindly): `git ls-remote` and `git fetch` (which only moves the
49
+ * coordinator's own remote-tracking refs); REST repository `gh api` reads with only the flags below (gh turns
50
+ * fields or input into a POST); `gh pr list|view|checks`, `gh release view`, `gh repo view`. Anything else is a write, or
51
+ * unknown, and is never retried by `retryingExec`.
52
+ */
53
+ export declare function readOnlyCall(command: string, args: readonly string[]): {
54
+ kind: "git" | "gh-api" | "gh";
55
+ jsonBody: boolean;
56
+ } | null;
57
+ /** A short name for a call, said once when it is asked again: `gh api repos/…/check-runs`, `git ls-remote`. */
58
+ export declare function callLabel(command: string, args: readonly string[]): string;
59
+ /**
60
+ * An Exec that asks a read again after a transient failure, with backoff, for up to `budgetMs` (about three minutes)
61
+ * and `maxAttempts` calls; it says why once per call. When the budget runs out it answers the FIRST failure, exactly as
62
+ * the program gave it, so the caller stops with the original error. A definitive answer or a success is returned at
63
+ * once. A call that isn't provably a read (readOnlyCall) is passed straight through, never retried.
64
+ */
65
+ export declare function retryingExec(run: Exec, options: RetryOptions): Exec;
66
+ /** One read, asked again as retryingExec describes. `call` may do more than the read itself (the release's Git
67
+ * re-checks the checkout's config first); anything it throws ends the read at once. */
68
+ export declare function retryCall(call: (remainingMs: number) => Promise<ExecResult>, label: string, options: RetryOptions, jsonBody?: boolean): Promise<ExecResult>;
69
+ /**
70
+ * A write that may be asked again only once GitHub says it didn't happen (idempotency). `write` makes the call;
71
+ * `accept` reads a successful answer into the value (a SyntaxError there is an answer cut short: transient);
72
+ * `refuse` throws for a failure. After a transient failure, `recheck` reads the state (its own reads retried): `done`
73
+ * with the value when the write happened, `not-done` when it provably didn't (only then is it asked again), and
74
+ * anything else — or a recheck that can't read — stops with the write's original error.
75
+ */
76
+ export declare function writeWithRecheck<T>(label: string, options: RetryOptions, steps: {
77
+ write: (remainingMs: number) => Promise<ExecResult>;
78
+ accept: (result: ExecResult) => T;
79
+ refuse: (result: ExecResult) => never;
80
+ recheck: (options: RetryOptions) => Promise<{
81
+ state: "done";
82
+ value: T;
83
+ } | {
84
+ state: "not-done";
85
+ } | {
86
+ state: "other";
87
+ why: string;
88
+ }>;
89
+ }): Promise<T>;
90
+ export {};
@@ -0,0 +1,253 @@
1
+ /** How long one call may keep asking, and how far apart. */
2
+ export const NET_RETRY_BUDGET_MS = 180_000;
3
+ export const NET_RETRY_MAX_ATTEMPTS = 12;
4
+ const BACKOFF_MS = [2_000, 4_000, 8_000, 16_000, 30_000];
5
+ export const netBackoff = (retry) => BACKOFF_MS[Math.min(retry, BACKOFF_MS.length - 1)];
6
+ const sharedBudget = Symbol("release retry budget");
7
+ function retryBudget(options) {
8
+ if (options[sharedBudget] !== undefined)
9
+ return options[sharedBudget];
10
+ const now = () => (options.now ?? (() => new Date()))().getTime();
11
+ let last = now(), left = Math.max(0, options.budgetMs ?? NET_RETRY_BUDGET_MS);
12
+ const remaining = () => {
13
+ const at = now();
14
+ left = Math.max(0, left - Math.max(0, at - last));
15
+ last = at;
16
+ return left;
17
+ };
18
+ return { remaining, sleep: async (ms) => {
19
+ const before = remaining();
20
+ await options.sleep(ms);
21
+ // A frozen/backward fake clock must not grant more backoff than a real clock.
22
+ const elapsed = before - remaining();
23
+ left = Math.max(0, left - Math.max(0, ms - elapsed));
24
+ } };
25
+ }
26
+ const exhausted = () => ({ code: 124, stdout: "", stderr: "the network retry budget ran out" });
27
+ /** Secondary rate limit or 429: transient, and GitHub may say how long to wait. */
28
+ const RATE_LIMITED = /secondary rate limit|abuse detection|HTTP 429|Too Many Requests/i;
29
+ /** A definitive answer: GitHub (or git) answered, and the answer is no. Checked before the transient phrases. */
30
+ const DEFINITIVE = /Toolroll won't run Git|Not Found|Unauthorized|Forbidden|Bad Request|Bad credentials|Requires authentication|authentication required|Authentication failed|Permission denied|Repository not found|Resource not accessible|Must have admin rights|could not read Username|not protected|not enabled|Reference already exists|Validation Failed|Head branch was modified|not mergeable|API rate limit exceeded|GH00[136]|protected branch update failed|\[rejected\]|updates were rejected|stale info|required status check|repository rule violations/i;
31
+ /** Transport failures and server-side trouble: nothing was answered yet. */
32
+ const TRANSIENT = new RegExp([
33
+ "error connecting to", "Could not resolve host", "no such host", "Temporary failure in name resolution", "nodename nor servname",
34
+ "Connection refused", "Connection reset", "connection reset by peer", "Failed to connect to", "Couldn't connect to server", "Network is unreachable", "No route to host",
35
+ "Recv failure", "Send failure", "TLS handshake timeout", "SSL_ERROR_SYSCALL", "SSL_read", "gnutls_handshake", "Operation timed out", "Connection timed out", "i/o timeout",
36
+ "timeout awaiting", "context deadline exceeded", "Client\\.Timeout", "unexpected EOF", "early EOF", "The remote end hung up unexpectedly", "RPC failed", "broken pipe",
37
+ "unexpected end of JSON input", "invalid character .{1,12} looking for beginning of value", "Unexpected end of JSON input",
38
+ "HTTP 5\\d\\d", "returned error: 5\\d\\d", "\\b50[0234] (?:Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)", "Server Error", "Bad Gateway", "Service Unavailable", "Gateway Timeout",
39
+ ].join("|"), "i");
40
+ /**
41
+ * Whether a failure (its words, and the exit code when it came from a program) is transient: the phrase worth saying,
42
+ * and how long GitHub asked to wait (Retry-After) when it said. Null for a definitive answer or anything unknown —
43
+ * an unknown failure is never retried.
44
+ */
45
+ export function transientFailure(text, code) {
46
+ if (code === 127)
47
+ return null;
48
+ if (DEFINITIVE.test(text))
49
+ return null;
50
+ const limited = RATE_LIMITED.exec(text);
51
+ // A rate-limit hint never overrides an independent refusal. Only 403/429 may
52
+ // describe a secondary limit; git prints HTTP errors differently from gh.
53
+ const statuses = [...text.matchAll(/(?:HTTP(?:\/[\d.]+)?\s+|returned error:\s*)(4\d\d)\b/gi)].map(match => Number(match[1]));
54
+ if (statuses.some(status => limited === null || (status !== 403 && status !== 429)))
55
+ return null;
56
+ if (limited !== null) {
57
+ const after = /Retry-After:\s*(\d{1,4})/i.exec(text) ?? /(?:try again|retry) in (\d{1,4}) ?s(?:econds?)?/i.exec(text);
58
+ return { words: limited[0], retryAfterMs: after === null ? null : Number(after[1]) * 1000 };
59
+ }
60
+ const found = TRANSIENT.exec(text);
61
+ // The phrase and the rest of its line ("Could not resolve host: github.com"), kept short.
62
+ if (found !== null)
63
+ return { words: text.slice(found.index).split("\n")[0].trim().slice(0, 80), retryAfterMs: null };
64
+ // The coordinator's own time limit on the child (exec answers 124 when it killed it).
65
+ if (code === 124)
66
+ return { words: "the call timed out", retryAfterMs: null };
67
+ return null;
68
+ }
69
+ const failureText = (result) => `${result.stderr}\n${result.stdout}`;
70
+ /** Whether one exec result failed transiently (exit 0 never did, unless its answer doesn't parse; see readOnlyCall). */
71
+ export function transientResult(result) {
72
+ return result.code === 0 ? null : transientFailure(failureText(result), result.code);
73
+ }
74
+ /** The git subcommand among the arguments: the first that isn't an option or an option's value. */
75
+ function gitSubcommand(args) {
76
+ for (let at = 0; at < args.length; at++) {
77
+ const arg = args[at];
78
+ if (["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--config-env"].includes(arg)) {
79
+ if (args[++at] === undefined)
80
+ return null;
81
+ continue;
82
+ }
83
+ if (/^-[cC]./.test(arg) || /^--(?:git-dir|work-tree|namespace|config-env|attr-source)=/.test(arg))
84
+ continue;
85
+ if (["--no-optional-locks", "--no-replace-objects", "--no-pager", "--no-lazy-fetch", "--literal-pathspecs"].includes(arg))
86
+ continue;
87
+ if (arg.startsWith("-"))
88
+ return null;
89
+ return arg;
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Whether a call only reads (and so may be asked again blindly): `git ls-remote` and `git fetch` (which only moves the
95
+ * coordinator's own remote-tracking refs); REST repository `gh api` reads with only the flags below (gh turns
96
+ * fields or input into a POST); `gh pr list|view|checks`, `gh release view`, `gh repo view`. Anything else is a write, or
97
+ * unknown, and is never retried by `retryingExec`.
98
+ */
99
+ export function readOnlyCall(command, args) {
100
+ const name = command.split("/").pop() ?? command;
101
+ if (name === "git") {
102
+ const sub = gitSubcommand(args);
103
+ return sub === "ls-remote" || sub === "fetch" ? { kind: "git", jsonBody: false } : null;
104
+ }
105
+ if (name !== "gh")
106
+ return null;
107
+ if (args[0] === "api") {
108
+ let endpoint = null, shaped = false;
109
+ for (let at = 1; at < args.length; at++) {
110
+ const arg = args[at];
111
+ // Parse only spellings we know. In particular, pflag accepts -iXPOST and -ifbody=...;
112
+ // a blacklist of tokens beginning with -X/-f cannot prove those calls read-only.
113
+ if (arg === "--paginate" || arg === "--slurp") {
114
+ shaped = true;
115
+ continue;
116
+ }
117
+ const flag = /^(--(?:method|hostname|jq|template))(?:=(.*))?$/.exec(arg) ?? /^(-[Xqt])(.*)$/.exec(arg);
118
+ if (flag !== null) {
119
+ const value = flag[2] || args[++at];
120
+ if (value === undefined || value.startsWith("-"))
121
+ return null;
122
+ if (flag[1] === "-X" || flag[1] === "--method") {
123
+ if (value !== "GET")
124
+ return null;
125
+ }
126
+ else if (flag[1] !== "--hostname")
127
+ shaped = true;
128
+ continue;
129
+ }
130
+ if (arg.startsWith("-") || endpoint !== null || !/^\/?repos\/[^\s]+$/.test(arg))
131
+ return null;
132
+ endpoint = arg;
133
+ }
134
+ return endpoint === null ? null : { kind: "gh-api", jsonBody: !shaped };
135
+ }
136
+ const pair = `${args[0] ?? ""} ${args[1] ?? ""}`;
137
+ if (!["pr list", "pr view", "pr checks", "release view", "repo view"].includes(pair))
138
+ return null;
139
+ const shaped = args.some(arg => arg === "--jq" || arg === "-q" || arg === "--template" || arg === "-t" || /^--(?:jq|template)=/.test(arg));
140
+ return { kind: "gh", jsonBody: !shaped && args.some(arg => arg === "--json" || arg.startsWith("--json=")) };
141
+ }
142
+ /** A short name for a call, said once when it is asked again: `gh api repos/…/check-runs`, `git ls-remote`. */
143
+ export function callLabel(command, args) {
144
+ const name = command.split("/").pop() ?? command;
145
+ if (name === "git")
146
+ return `git ${gitSubcommand(args) ?? ""}`.trim();
147
+ if (args[0] === "api") {
148
+ const path = args.find(one => one.startsWith("repos/")) ?? args.find((one, at) => at > 0 && !one.startsWith("-")) ?? "";
149
+ return `gh api ${path.split("?")[0].replace(/[0-9a-f]{40}/g, sha => sha.slice(0, 7))}`.trim();
150
+ }
151
+ return `gh ${args.slice(0, 2).join(" ")}`;
152
+ }
153
+ /**
154
+ * An Exec that asks a read again after a transient failure, with backoff, for up to `budgetMs` (about three minutes)
155
+ * and `maxAttempts` calls; it says why once per call. When the budget runs out it answers the FIRST failure, exactly as
156
+ * the program gave it, so the caller stops with the original error. A definitive answer or a success is returned at
157
+ * once. A call that isn't provably a read (readOnlyCall) is passed straight through, never retried.
158
+ */
159
+ export function retryingExec(run, options) {
160
+ return async (command, args, execOptions) => {
161
+ const read = readOnlyCall(command, args);
162
+ if (read === null)
163
+ return run(command, args, execOptions);
164
+ return retryCall(remainingMs => run(command, args, { ...execOptions,
165
+ timeoutMs: Math.min((execOptions?.timeoutMs ?? 0) > 0 ? execOptions.timeoutMs : 120_000, remainingMs),
166
+ }), callLabel(command, args), options, read.jsonBody);
167
+ };
168
+ }
169
+ /** Whether a 0-exit plain JSON body parses; a truncated one is a transient failure. */
170
+ const bodyParses = (stdout) => { try {
171
+ JSON.parse(stdout);
172
+ return true;
173
+ }
174
+ catch {
175
+ return false;
176
+ } };
177
+ /** One read, asked again as retryingExec describes. `call` may do more than the read itself (the release's Git
178
+ * re-checks the checkout's config first); anything it throws ends the read at once. */
179
+ export async function retryCall(call, label, options, jsonBody = false) {
180
+ const budget = retryBudget(options);
181
+ const maxAttempts = options.maxAttempts ?? NET_RETRY_MAX_ATTEMPTS;
182
+ let first = null;
183
+ for (let attempt = 0;; attempt++) {
184
+ const remaining = budget.remaining();
185
+ if (remaining <= 0)
186
+ return first ?? exhausted();
187
+ const result = await call(remaining);
188
+ let why = transientResult(result);
189
+ if (why === null && result.code === 0 && jsonBody && !bodyParses(result.stdout))
190
+ why = { words: "an answer cut short", retryAfterMs: null };
191
+ if (why === null)
192
+ return result;
193
+ // The original failure is what the caller sees; a truncated 0-exit body becomes an explicit failure.
194
+ first ??= result.code !== 0 ? result : { code: 1, stdout: result.stdout, stderr: "unexpected end of JSON input (GitHub's answer was cut short)" };
195
+ const wait = why.retryAfterMs ?? netBackoff(attempt);
196
+ if (attempt + 1 >= maxAttempts || wait >= budget.remaining())
197
+ return first;
198
+ if (attempt === 0)
199
+ options.say(` GitHub didn't answer ${label} (${why.words}); asking again for up to ${Math.round((options.budgetMs ?? NET_RETRY_BUDGET_MS) / 60_000)} min.`);
200
+ await budget.sleep(wait);
201
+ }
202
+ }
203
+ /**
204
+ * A write that may be asked again only once GitHub says it didn't happen (idempotency). `write` makes the call;
205
+ * `accept` reads a successful answer into the value (a SyntaxError there is an answer cut short: transient);
206
+ * `refuse` throws for a failure. After a transient failure, `recheck` reads the state (its own reads retried): `done`
207
+ * with the value when the write happened, `not-done` when it provably didn't (only then is it asked again), and
208
+ * anything else — or a recheck that can't read — stops with the write's original error.
209
+ */
210
+ export async function writeWithRecheck(label, options, steps) {
211
+ const budget = retryBudget(options);
212
+ const maxAttempts = options.maxAttempts ?? NET_RETRY_MAX_ATTEMPTS;
213
+ let first = null;
214
+ for (let attempt = 0;; attempt++) {
215
+ const remaining = budget.remaining();
216
+ if (remaining <= 0)
217
+ return steps.refuse(first ?? exhausted());
218
+ const result = await steps.write(remaining);
219
+ let why = transientResult(result);
220
+ if (result.code === 0) {
221
+ try {
222
+ return steps.accept(result);
223
+ }
224
+ catch (error) {
225
+ if (!(error instanceof SyntaxError))
226
+ throw error;
227
+ why = { words: "an answer cut short", retryAfterMs: null };
228
+ }
229
+ }
230
+ if (why === null)
231
+ return steps.refuse(result);
232
+ first ??= result.code !== 0 ? result : { code: 1, stdout: result.stdout, stderr: "unexpected end of JSON input (GitHub's answer was cut short)" };
233
+ if (budget.remaining() <= 0)
234
+ return steps.refuse(first);
235
+ if (attempt === 0)
236
+ options.say(` GitHub didn't answer ${label} (${why.words}); reading GitHub to learn whether it happened.`);
237
+ let state;
238
+ try {
239
+ state = await steps.recheck({ ...options, maxAttempts: NET_RETRY_MAX_ATTEMPTS, [sharedBudget]: budget });
240
+ }
241
+ catch (error) {
242
+ return steps.refuse({ ...first, stderr: `${first.stderr.trim()} (and whether it happened couldn't be read: ${String(error?.message ?? error).slice(0, 200)})` });
243
+ }
244
+ if (state.state === "done")
245
+ return state.value;
246
+ if (state.state === "other")
247
+ return steps.refuse({ ...first, stderr: `${first.stderr.trim()} (${state.why})` });
248
+ const wait = why.retryAfterMs ?? netBackoff(attempt);
249
+ if (attempt + 1 >= maxAttempts || wait >= budget.remaining())
250
+ return steps.refuse(first);
251
+ await budget.sleep(wait);
252
+ }
253
+ }
package/dist/release.d.ts CHANGED
@@ -670,7 +670,9 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
670
670
  export declare function tagChecksVerdict(runs: readonly CheckRun[]): ReturnType<typeof checksVerdict>;
671
671
  /** Run the release from wherever its journal stopped. */
672
672
  export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
673
- /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
673
+ /** A probe's failure that is only the network to GitHub failing for now (release-net.ts transientFailure: a timeout, a
674
+ * connection or name failure, a 5xx, an answer cut short, a secondary rate limit), never a decision (a Stop), a
675
+ * definitive answer or another error. */
674
676
  export declare function transientNetworkError(error: unknown): boolean;
675
677
  /**
676
678
  * The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
package/dist/release.js CHANGED
@@ -37,6 +37,7 @@ import { randomUUID } from "node:crypto";
37
37
  import { dirname, join } from "node:path";
38
38
  import { personProof } from "./person-proof.js";
39
39
  import { RELEASE_CI_RUNNING_WORDS as CI_RUNNING_WORDS } from "./release-ci-hold.js";
40
+ import { transientFailure } from "./release-net.js";
40
41
  /** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
41
42
  * concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
42
43
  export const REQUIRED_PR_CHECKS = [
@@ -1318,20 +1319,24 @@ async function containsMain(adapters, j, nothing) {
1318
1319
  * cannot make the final main read and merge atomic, so absent or unreadable protection stops that release too.
1319
1320
  */
1320
1321
  async function mainProtection(adapters, j, auto, nothing) {
1321
- let protection;
1322
+ let protection, unread = null;
1323
+ // Couldn't read (after the adapter's network retries) is its own stop, never "doesn't hold" and never "holds".
1322
1324
  try {
1323
1325
  protection = await adapters.github.upToDateRequired(j.github);
1324
1326
  }
1325
- catch {
1327
+ catch (error) {
1326
1328
  protection = null;
1329
+ unread = String(error?.message ?? error).slice(0, 400);
1327
1330
  }
1328
1331
  // The git lanes main's protection doesn't name yet are said, never refused (PROTECTION_REPORT_ONLY_CHECKS).
1329
1332
  if (protection !== null && protection.notYetRequired.length > 0)
1330
1333
  adapters.say(` Note: main's protection doesn't require ${protection.notYetRequired.join(", ")} yet; add ${protection.notYetRequired.length === 1 ? "it" : "them"} to its required checks.`);
1331
1334
  if (protection?.effective === true)
1332
1335
  return;
1333
- const lacking = protection === null ? "GitHub's branch protection for main couldn't be read" : `main's protection doesn't hold: ${protection.missing.join("; ")}`;
1334
- throw new Stop("base-unprotected", `${lacking}, so this release can't merge${auto ? " automatically" : ""}. ${nothing}. Restore effective main protection and try again.`);
1336
+ if (protection === null) {
1337
+ throw new Stop("github-unreadable", `Couldn't read GitHub's branch protection for main${unread === null ? "" : ` (${unread.replace(/^couldn't read GitHub's branch protection for main: /, "")})`}, so this release can't merge${auto ? " automatically" : ""}. ${nothing}. Run the release again once GitHub answers.`);
1338
+ }
1339
+ throw new Stop("base-unprotected", `main's protection doesn't hold: ${protection.missing.join("; ")}, so this release can't merge${auto ? " automatically" : ""}. ${nothing}. Restore effective main protection and try again.`);
1335
1340
  }
1336
1341
  /**
1337
1342
  * Wait for every required pull request check on the candidate to pass; stop on one that failed. `pendingIsEnough` (the
@@ -1377,17 +1382,17 @@ async function earlyChecks(adapters, j, wait, options = {}) {
1377
1382
  throw new Stop("ci-missing", `Pull request checks on ${sha} didn't all finish in time${named === "" ? (unread ? " (GitHub didn't answer)" : "") : `: ${named}`}. ${nothing}. Start or rerun them on GitHub, then run this command again.`);
1378
1383
  }
1379
1384
  }
1380
- /** The network failures a release's waits ride out: git or gh timing out (or cut off) on the way to GitHub. */
1381
- const TRANSIENT_NETWORK = /Recv failure: (?:Operation timed out|Connection reset by peer)|TLS handshake timeout|Operation timed out|Connection timed out|i\/o timeout|Connection reset by peer|Failed to connect to [^\s]*github\.com port \d+/i;
1382
- /** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
1385
+ /** A probe's failure that is only the network to GitHub failing for now (release-net.ts transientFailure: a timeout, a
1386
+ * connection or name failure, a 5xx, an answer cut short, a secondary rate limit), never a decision (a Stop), a
1387
+ * definitive answer or another error. */
1383
1388
  export function transientNetworkError(error) {
1384
1389
  if (error instanceof Stop || error instanceof Restart)
1385
1390
  return false;
1386
- return TRANSIENT_NETWORK.test(String(error?.message ?? error));
1391
+ return transientFailure(String(error?.message ?? error)) !== null;
1387
1392
  }
1388
1393
  /** The part of a network failure worth saying: its matching phrase. */
1389
1394
  function networkWords(error) {
1390
- return TRANSIENT_NETWORK.exec(String(error?.message ?? error))?.[0] ?? "a network failure";
1395
+ return transientFailure(String(error?.message ?? error))?.words ?? "a network failure";
1391
1396
  }
1392
1397
  /**
1393
1398
  * The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
@@ -23,7 +23,7 @@ export declare function refusal(result: ExecResult): string | null;
23
23
  * target is bound to the same repository as the folder it is removed from. A removal without --force runs under the
24
24
  * target's own view (its own config.worktree folded in), so git's last check of whether it is clean reads it as the
25
25
  * target itself would. `binding.repo`: the project's main checkout, from the plane's records, when the caller has it. */
26
- export declare function teardownGit(runner?: Runner, binding?: GitBinding): Git;
26
+ export declare function teardownGit(runner?: Runner, binding?: GitBinding, lockTimeoutMs?: number, gitFile?: string): Git;
27
27
  /**
28
28
  * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
29
29
  * work): the same fixed hardening as teardown (no hooks, fsmonitor, ext transport, credential helpers or automatic
@@ -190,7 +190,7 @@ function nestedRepository(path) {
190
190
  * repository (boundDirs; `binding.repo` when the caller has the project's), is refused; so is a command whose
191
191
  * repository changed under it while git ran.
192
192
  */
193
- async function inView(cwd, binding, work, after = true) {
193
+ async function inView(cwd, binding, work, after = true, lockTimeoutMs, gitFile = "git") {
194
194
  const root = checkoutRootOf(cwd);
195
195
  if (root === null)
196
196
  return NOT_A_REPOSITORY;
@@ -206,7 +206,7 @@ async function inView(cwd, binding, work, after = true) {
206
206
  return await withCheckoutLock(realpathSync(homedir()), key, async () => {
207
207
  let view;
208
208
  try {
209
- view = gitView(root, "write", binding);
209
+ view = gitView(root, "write", binding, gitFile);
210
210
  }
211
211
  catch (error) {
212
212
  return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
@@ -221,7 +221,7 @@ async function inView(cwd, binding, work, after = true) {
221
221
  finally {
222
222
  view.dispose();
223
223
  }
224
- });
224
+ }, lockTimeoutMs);
225
225
  }
226
226
  catch (error) {
227
227
  return refused(error instanceof Error ? error.message : "the checkout is busy");
@@ -234,11 +234,11 @@ async function inView(cwd, binding, work, after = true) {
234
234
  * target is bound to the same repository as the folder it is removed from. A removal without --force runs under the
235
235
  * target's own view (its own config.worktree folded in), so git's last check of whether it is clean reads it as the
236
236
  * target itself would. `binding.repo`: the project's main checkout, from the plane's records, when the caller has it. */
237
- export function teardownGit(runner = run, binding = {}) {
237
+ export function teardownGit(runner = run, binding = {}, lockTimeoutMs, gitFile = "git") {
238
238
  const options = (cwd, env) => ({ cwd, envAllowlist: ENV_ALLOWLIST, env: { ...ENV, ...env }, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
239
239
  const configProblem = async (cwd, env) => {
240
240
  // git-lfs's own filters may stand only beside a .lfsconfig that names no extension (commandSetting's lfsChecked).
241
- if (lfsExtensionIn(env["GIT_WORK_TREE"] ?? cwd))
241
+ if (lfsExtensionIn(env["GIT_WORK_TREE"] ?? cwd, gitFile))
242
242
  return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
243
243
  const config = await runner("git", ["--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], options(cwd, env));
244
244
  if (config.code !== 0)
@@ -287,8 +287,8 @@ export function teardownGit(runner = run, binding = {}) {
287
287
  // the target's view, inspected above, so it judges the target by the target's own settings.
288
288
  return ran(removal && !forced ? { ...view.env, GIT_COMMON_DIR: targetView.env["GIT_COMMON_DIR"] } : view.env);
289
289
  // A removed target has no git directory left to prove: the folder it was removed from is proven instead.
290
- }, !removal);
291
- });
290
+ }, !removal, lockTimeoutMs, gitFile);
291
+ }, true, lockTimeoutMs, gitFile);
292
292
  }
293
293
  /**
294
294
  * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.78",
3
+ "version": "0.9.79",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"