toolroll 0.9.78 → 0.9.81

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,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.81 — 2026-10-30
4
+
5
+ - **The release proof works after the merge.** GitHub empties a CI run's
6
+ pull-request link once the pull request merges and its branch is deleted,
7
+ so 0.9.80's tag could never prove its checks and wasn't published. A run
8
+ with no link now stands only when its branch has had exactly one pull
9
+ request, ever, and that pull request merged into main. 0.9.80's changes
10
+ ship in this release.
11
+
12
+ ## 0.9.80 — 2026-10-30
13
+
14
+ - **Each test runs once, where it adds something** (docs/plans/test-matrix.md).
15
+ Pull requests run the unit suite on Node 24 (Linux and macOS) and every
16
+ area lane (Windows, Linux fence and cgroup, git 2.41 and 2.47); a pull
17
+ request that only changes Markdown under `docs/`, the changelog or the
18
+ README skips the area lanes. Node 22 runs on Linux and macOS once per
19
+ release, on the `release-*` branch's pull request, in the new
20
+ Release candidate workflow. Release tags no longer rerun the macOS suites:
21
+ a release proof reads GitHub's record that the tagged tree passed every
22
+ required check on its pull request before npm publication. A push to main
23
+ typechecks and builds, and runs the full CI only when it can't prove the
24
+ merge commit was tested. The nightly runs everything. A release now needs
25
+ every required check executed, never skipped, and refuses to merge while
26
+ main's branch protection doesn't require all of them. Hosted unit shards
27
+ require their platform's native fence attacks to run.
28
+
29
+ ## 0.9.79 — 2026-10-29
30
+
31
+ - **`toolroll release` rides out a short network failure instead of
32
+ stopping.** A read from GitHub or git (`gh api`, `gh pr list`,
33
+ `git ls-remote`, `git fetch`) that fails for a passing reason (no
34
+ connection, a name that didn't resolve, a timeout, a 5xx, an answer cut
35
+ short, GitHub's secondary rate limit) is asked again with backoff for up
36
+ to about three minutes, saying why once; then it stops with the original
37
+ error. A definitive answer (404, 401/403) is never asked again. A merge or
38
+ a tag is never sent twice blindly: after a lost answer the release reads
39
+ GitHub to learn whether it happened, and asks again only when it didn't.
40
+ When main's branch protection can't be read, the release now says
41
+ "Couldn't read GitHub's branch protection" instead of "main's protection
42
+ doesn't hold", and still stops.
43
+
3
44
  ## 0.9.78 — 2026-10-29
4
45
 
5
46
  - **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
  }
package/dist/guides.js CHANGED
@@ -473,20 +473,23 @@ with a time limit.
473
473
  saved first; if either name doesn't answer as the new build, both go
474
474
  back. It goes on only when the console, worker and both names run the
475
475
  gated commit. A rerun relinks without deploying again.
476
- 8. **ci**: waits for every pull-request check on the gated commit:
477
- Ubuntu on Node 22 and 24, Linux and Windows containment. Pending,
478
- missing, skipped, cancelled or failed never merges.
476
+ 8. **ci**: waits for every pull-request check on the gated commit: Node 24
477
+ on Ubuntu and macOS, the area lanes (Linux cgroup and fence, Windows,
478
+ git 2.41 and 2.47), and Node 22 on Ubuntu and macOS, which runs on a
479
+ release-* branch's pull request.
480
+ Pending, missing, skipped, cancelled or failed never merges, and main's
481
+ protection must require every one of them.
479
482
  9. **merge**: squash-merges with the head pinned to the gated commit,
480
483
  once main has nothing the branch lacks, then deletes the remote branch
481
484
  only if its head still matches. A resumed merge also finishes this cleanup.
482
485
  10. **tree**: main's merge commit must have exactly the gated tree, or
483
486
  nothing is tagged.
484
- 11. **tag**: pushes \`v<version>\` on that merge commit. In publish.yml,
485
- macOS Node 22 and 24 must both pass before npm and GitHub publication
486
- (the candidate's macOS checks already passed in ci.yml before the merge).
487
- macos.yml keeps the nightly matrix.
488
- 12. **publish**: waits for npm, the GitHub release and the tag's publish
489
- job (which needs the macOS legs); not main's own CI on the merge commit.
487
+ 11. **tag**: pushes \`v<version>\` on that merge commit. The tag runs no
488
+ tests again: publish.yml's release proof reads GitHub's record that the
489
+ tagged tree is the merged pull request's head tree and passed every
490
+ required check there, and npm and GitHub publication need it.
491
+ 12. **publish**: waits for npm, the GitHub release and the tag's release
492
+ proof and publish jobs; not main's own push run on the merge commit.
490
493
  13. **homebrew**: the release is published once npm and the GitHub release
491
494
  are verified. It proposes the tap's formula change (checksum from npm's
492
495
  tarball) and looks at its checks once: \`Published; Homebrew pending\`
@@ -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)
@@ -1191,7 +1290,7 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
1191
1290
  else {
1192
1291
  const lacking = PROTECTION_MINIMUM_CHECKS.filter(name => !checks.includes(name));
1193
1292
  if (lacking.length > 0)
1194
- own.push(`${where}: required checks lack ${lacking.join(", ")}`);
1293
+ own.push(`${where}: required checks lack ${lacking.join(", ")} (add ${lacking.length === 1 ? "it" : "them"} to main's required status checks: a release's proof trusts what main required)`);
1195
1294
  const unbound = PROTECTION_MINIMUM_CHECKS.filter(name => checks.includes(name) && !bound.includes(name));
1196
1295
  if (unbound.length > 0)
1197
1296
  own.push(`${where}: required checks must require GitHub Actions (app 15368) as their source: ${unbound.join(", ")}`);
@@ -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). */
@@ -19,7 +19,7 @@ export type ObservedCheckRun = {
19
19
  suite?: number | null;
20
20
  head?: string | null;
21
21
  workflow?: number | null;
22
- /** The workflow's file (GitHub's workflow-run `path`): a required check counts only from ci.yml. */
22
+ /** The workflow's file (GitHub's workflow-run `path`): a required check counts only from its own (REQUIRED_PR_CHECKS_WORKFLOW). */
23
23
  workflowPath?: string | null;
24
24
  /** GitHub's job record shows this unsuccessful attempt ended before any workflow step ran (release-adapters.ts). */
25
25
  beforeTests?: boolean | null;