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.
@@ -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
@@ -32,16 +32,18 @@
32
32
  * Everything outside this module is an adapter (release-adapters.ts), so the state machine is tested without git,
33
33
  * GitHub, npm or a running Toolroll.
34
34
  */
35
- /** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
36
- * concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
37
- export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie", "node 22 · macos-latest", "node 24 · macos-latest"];
38
- /** The workflow file every REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
35
+ /** The pull-request checks a release waits for, by exact job name (docs/plans/test-matrix.md). Every one must have
36
+ * concluded `success` — executed, never skipped — on the gated commit; src/ci-workflow.test.ts holds the workflows to
37
+ * this list, and publish.yml's release proof to the same list. */
38
+ export declare const REQUIRED_PR_CHECKS: readonly ["node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie", "node 24 · macos-latest", "node 22 · ubuntu-latest", "node 22 · macos-latest"];
39
+ /** The workflow file each REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
39
40
  * added beside it) never stands for it (Codex review round 1, inherited CI limitation). */
40
- export declare const REQUIRED_PR_CHECKS_WORKFLOW = ".github/workflows/ci.yml";
41
+ export declare const REQUIRED_PR_CHECKS_WORKFLOW: Readonly<Record<typeof REQUIRED_PR_CHECKS[number], string>>;
41
42
  /**
42
- * The pull-request checks main's GitHub protection is not yet required to name (eval-harness review 2, finding 1): the
43
- * git lanes join main's protection once they have run there, so a protection without them is reported, never refused.
44
- * A later release empties this list; from then on main's protection must name every REQUIRED_PR_CHECKS check.
43
+ * The pull-request checks main's GitHub protection is not yet required to name (eval-harness review 2, finding 1).
44
+ * Empty since the test matrix (docs/plans/test-matrix.md): publication's release proof trusts what main's protection
45
+ * required at the merge, so main's protection must name every REQUIRED_PR_CHECKS check, and a release refuses to merge
46
+ * (and so to publish) until it does.
45
47
  */
46
48
  export declare const PROTECTION_REPORT_ONLY_CHECKS: readonly string[];
47
49
  /** The checks main's protection must require at minimum: every required check that isn't report-only. */
@@ -57,10 +59,11 @@ export type MainProtection = {
57
59
  missing: string[];
58
60
  notYetRequired: string[];
59
61
  };
60
- /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, pipeline-speed.md item 3):
61
- * the publish job and the macOS legs it needs, each named. Nothing else reported on the merge commit (main's own CI push
62
- * run) holds publication. */
63
- export declare const RELEASE_TAG_CHECKS: readonly ["publish", "tag macOS · node 22", "tag macOS · node 24"];
62
+ /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, docs/plans/test-matrix.md):
63
+ * the release proof (the tagged tree passed every REQUIRED_PR_CHECKS check on its pull request, read from GitHub) and
64
+ * the publish job that needs it. The tag runs no tests again. Nothing else reported on the merge commit (main-push.yml)
65
+ * holds publication. */
66
+ export declare const RELEASE_TAG_CHECKS: readonly ["release proof", "publish"];
64
67
  export declare const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
65
68
  export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
66
69
  export type ReleaseStep = typeof RELEASE_STEPS[number];
@@ -662,15 +665,17 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
662
665
  failed: string[];
663
666
  };
664
667
  /**
665
- * Publication's verdict on the tag commit (pipeline-speed.md, item 3): only RELEASE_TAG_CHECKS, from GitHub Actions
666
- * runs of publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's
667
- * own CI run — is not waited for: the candidate's checks already passed on its exact head before the merge, and the
668
- * merged tree was proved equal to it.
668
+ * Publication's verdict on the tag commit (test-matrix.md): only RELEASE_TAG_CHECKS, from GitHub Actions runs of
669
+ * publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's own
670
+ * push run — is not waited for: the candidate's checks already passed on its exact head before the merge, the merged
671
+ * tree was proved equal to it, and publish.yml's release proof checks both again from GitHub's record.
669
672
  */
670
673
  export declare function tagChecksVerdict(runs: readonly CheckRun[]): ReturnType<typeof checksVerdict>;
671
674
  /** Run the release from wherever its journal stopped. */
672
675
  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. */
676
+ /** A probe's failure that is only the network to GitHub failing for now (release-net.ts transientFailure: a timeout, a
677
+ * connection or name failure, a 5xx, an answer cut short, a secondary rate limit), never a decision (a Stop), a
678
+ * definitive answer or another error. */
674
679
  export declare function transientNetworkError(error: unknown): boolean;
675
680
  /**
676
681
  * The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
@@ -684,7 +689,7 @@ export declare function transientNetworkError(error: unknown): boolean;
684
689
  */
685
690
  export declare function prChecksVerdict(runs: readonly CheckRun[], head: string, required: readonly string[], options?: {
686
691
  auto?: true;
687
- workflow?: string;
692
+ workflow?: string | Readonly<Record<string, string>>;
688
693
  }): {
689
694
  state: "passed";
690
695
  failures: CheckFailure[];
package/dist/release.js CHANGED
@@ -37,37 +37,55 @@ 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
- /** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
41
- * concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
40
+ import { transientFailure } from "./release-net.js";
41
+ /** The pull-request checks a release waits for, by exact job name (docs/plans/test-matrix.md). Every one must have
42
+ * concluded `success` — executed, never skipped — on the gated commit; src/ci-workflow.test.ts holds the workflows to
43
+ * this list, and publish.yml's release proof to the same list. */
42
44
  export const REQUIRED_PR_CHECKS = [
43
- "node 22 · ubuntu-latest",
45
+ // The unit suite on Node 24 (ci.yml "test-shard" and "test"), on every pull request.
44
46
  "node 24 · ubuntu-latest",
47
+ // The area lanes (ci.yml), on every pull request except a documentation-only one (scripts/ci-scope.mjs), where they
48
+ // are skipped; a release's pull request runs them.
45
49
  "linux native containment · delegated cgroup v2",
46
50
  "linux fence · sandbox-runtime",
47
51
  "Windows Job Object containment",
48
52
  // The git lanes (ci.yml job "git"): the declared floor (git-floor.ts) and Debian trixie's 2.47.
49
53
  "git 2.41 · debian trixie",
50
54
  "git 2.47 · debian trixie",
51
- // The unit suite on macOS (ci.yml jobs "macos-shard" and "macos"; pipeline-speed.md, item 3a): tested on the candidate
52
- // before it merges, no longer only on the tag after.
53
- "node 22 · macos-latest",
55
+ // The unit suite on macOS (ci.yml jobs "macos-shard" and "macos"; pipeline-speed.md, item 3a), Node 24.
54
56
  "node 24 · macos-latest",
57
+ // Node 22, the engines floor, on Linux and macOS (release-candidate.yml): on a release-* branch's pull request,
58
+ // skipped on every other pull request.
59
+ "node 22 · ubuntu-latest",
60
+ "node 22 · macos-latest",
55
61
  ];
56
- /** The workflow file every REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
62
+ /** The workflow file each REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
57
63
  * added beside it) never stands for it (Codex review round 1, inherited CI limitation). */
58
- export const REQUIRED_PR_CHECKS_WORKFLOW = ".github/workflows/ci.yml";
64
+ export const REQUIRED_PR_CHECKS_WORKFLOW = {
65
+ "node 24 · ubuntu-latest": ".github/workflows/ci.yml",
66
+ "linux native containment · delegated cgroup v2": ".github/workflows/ci.yml",
67
+ "linux fence · sandbox-runtime": ".github/workflows/ci.yml",
68
+ "Windows Job Object containment": ".github/workflows/ci.yml",
69
+ "git 2.41 · debian trixie": ".github/workflows/ci.yml",
70
+ "git 2.47 · debian trixie": ".github/workflows/ci.yml",
71
+ "node 24 · macos-latest": ".github/workflows/ci.yml",
72
+ "node 22 · ubuntu-latest": ".github/workflows/release-candidate.yml",
73
+ "node 22 · macos-latest": ".github/workflows/release-candidate.yml",
74
+ };
59
75
  /**
60
- * The pull-request checks main's GitHub protection is not yet required to name (eval-harness review 2, finding 1): the
61
- * git lanes join main's protection once they have run there, so a protection without them is reported, never refused.
62
- * A later release empties this list; from then on main's protection must name every REQUIRED_PR_CHECKS check.
76
+ * The pull-request checks main's GitHub protection is not yet required to name (eval-harness review 2, finding 1).
77
+ * Empty since the test matrix (docs/plans/test-matrix.md): publication's release proof trusts what main's protection
78
+ * required at the merge, so main's protection must name every REQUIRED_PR_CHECKS check, and a release refuses to merge
79
+ * (and so to publish) until it does.
63
80
  */
64
- export const PROTECTION_REPORT_ONLY_CHECKS = ["git 2.41 · debian trixie", "git 2.47 · debian trixie", "node 22 · macos-latest", "node 24 · macos-latest"];
81
+ export const PROTECTION_REPORT_ONLY_CHECKS = [];
65
82
  /** The checks main's protection must require at minimum: every required check that isn't report-only. */
66
83
  export const PROTECTION_MINIMUM_CHECKS = REQUIRED_PR_CHECKS.filter(name => !PROTECTION_REPORT_ONLY_CHECKS.includes(name));
67
- /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, pipeline-speed.md item 3):
68
- * the publish job and the macOS legs it needs, each named. Nothing else reported on the merge commit (main's own CI push
69
- * run) holds publication. */
70
- export const RELEASE_TAG_CHECKS = ["publish", "tag macOS · node 22", "tag macOS · node 24"];
84
+ /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, docs/plans/test-matrix.md):
85
+ * the release proof (the tagged tree passed every REQUIRED_PR_CHECKS check on its pull request, read from GitHub) and
86
+ * the publish job that needs it. The tag runs no tests again. Nothing else reported on the merge commit (main-push.yml)
87
+ * holds publication. */
88
+ export const RELEASE_TAG_CHECKS = ["release proof", "publish"];
71
89
  export const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
72
90
  export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
73
91
  /** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
@@ -191,10 +209,10 @@ export function checksVerdict(runs, required) {
191
209
  return waiting.length > 0 ? { state: "pending", waiting } : { state: "passed" };
192
210
  }
193
211
  /**
194
- * Publication's verdict on the tag commit (pipeline-speed.md, item 3): only RELEASE_TAG_CHECKS, from GitHub Actions
195
- * runs of publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's
196
- * own CI run — is not waited for: the candidate's checks already passed on its exact head before the merge, and the
197
- * merged tree was proved equal to it.
212
+ * Publication's verdict on the tag commit (test-matrix.md): only RELEASE_TAG_CHECKS, from GitHub Actions runs of
213
+ * publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's own
214
+ * push run — is not waited for: the candidate's checks already passed on its exact head before the merge, the merged
215
+ * tree was proved equal to it, and publish.yml's release proof checks both again from GitHub's record.
198
216
  */
199
217
  export function tagChecksVerdict(runs) {
200
218
  const names = RELEASE_TAG_CHECKS;
@@ -1318,20 +1336,24 @@ async function containsMain(adapters, j, nothing) {
1318
1336
  * cannot make the final main read and merge atomic, so absent or unreadable protection stops that release too.
1319
1337
  */
1320
1338
  async function mainProtection(adapters, j, auto, nothing) {
1321
- let protection;
1339
+ let protection, unread = null;
1340
+ // Couldn't read (after the adapter's network retries) is its own stop, never "doesn't hold" and never "holds".
1322
1341
  try {
1323
1342
  protection = await adapters.github.upToDateRequired(j.github);
1324
1343
  }
1325
- catch {
1344
+ catch (error) {
1326
1345
  protection = null;
1346
+ unread = String(error?.message ?? error).slice(0, 400);
1327
1347
  }
1328
1348
  // The git lanes main's protection doesn't name yet are said, never refused (PROTECTION_REPORT_ONLY_CHECKS).
1329
1349
  if (protection !== null && protection.notYetRequired.length > 0)
1330
1350
  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
1351
  if (protection?.effective === true)
1332
1352
  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.`);
1353
+ if (protection === null) {
1354
+ 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.`);
1355
+ }
1356
+ 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
1357
  }
1336
1358
  /**
1337
1359
  * Wait for every required pull request check on the candidate to pass; stop on one that failed. `pendingIsEnough` (the
@@ -1377,17 +1399,17 @@ async function earlyChecks(adapters, j, wait, options = {}) {
1377
1399
  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
1400
  }
1379
1401
  }
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. */
1402
+ /** A probe's failure that is only the network to GitHub failing for now (release-net.ts transientFailure: a timeout, a
1403
+ * connection or name failure, a 5xx, an answer cut short, a secondary rate limit), never a decision (a Stop), a
1404
+ * definitive answer or another error. */
1383
1405
  export function transientNetworkError(error) {
1384
1406
  if (error instanceof Stop || error instanceof Restart)
1385
1407
  return false;
1386
- return TRANSIENT_NETWORK.test(String(error?.message ?? error));
1408
+ return transientFailure(String(error?.message ?? error)) !== null;
1387
1409
  }
1388
1410
  /** The part of a network failure worth saying: its matching phrase. */
1389
1411
  function networkWords(error) {
1390
- return TRANSIENT_NETWORK.exec(String(error?.message ?? error))?.[0] ?? "a network failure";
1412
+ return transientFailure(String(error?.message ?? error))?.words ?? "a network failure";
1391
1413
  }
1392
1414
  /**
1393
1415
  * The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
@@ -1417,9 +1439,12 @@ export function prChecksVerdict(runs, head, required, options = {}) {
1417
1439
  }
1418
1440
  // A required name counts only from the workflow that defines it: a job of the same name in another workflow (or
1419
1441
  // one whose workflow GitHub doesn't name) fails the verdict rather than stand in for it.
1420
- if (options.workflow !== undefined && required.includes(run.name) && !workflowIs(run.workflowPath, options.workflow)) {
1421
- failed.push(`${run.name} (from ${run.workflowPath ?? "a workflow GitHub doesn't name"}, not ${options.workflow})`);
1422
- continue;
1442
+ if (options.workflow !== undefined && required.includes(run.name)) {
1443
+ const want = typeof options.workflow === "string" ? options.workflow : options.workflow[run.name];
1444
+ if (want === undefined || !workflowIs(run.workflowPath, want)) {
1445
+ failed.push(`${run.name} (from ${run.workflowPath ?? "a workflow GitHub doesn't name"}, not ${want ?? "a workflow this release knows for it"})`);
1446
+ continue;
1447
+ }
1423
1448
  }
1424
1449
  if (run.app === "github-actions" && (typeof run.suite !== "number" || !Number.isSafeInteger(run.suite))) {
1425
1450
  failed.push(`${run.name} (no workflow run)`);
@@ -1463,6 +1488,13 @@ export function prChecksVerdict(runs, head, required, options = {}) {
1463
1488
  waiting.push(`${name} (${latest.status})`);
1464
1489
  continue;
1465
1490
  }
1491
+ // Skipped is never success (test-matrix.md): GitHub's protection reads a skipped job as passing a merge, a release
1492
+ // never does. A documentation-only pull request skips the area lanes, and any pull request but a release-* branch's
1493
+ // skips Node 22, so a release needs every lane executed on its exact head.
1494
+ if (latest.conclusion === "skipped" && required.includes(name)) {
1495
+ failed.push(`${name} (skipped: a release needs it run on this exact commit; release from a branch named release-*, whose pull request runs every lane)`);
1496
+ continue;
1497
+ }
1466
1498
  if (latest.conclusion !== "success") {
1467
1499
  failed.push(`${name} (${latest.conclusion ?? "no conclusion"})`);
1468
1500
  continue;
@@ -1520,5 +1552,12 @@ export function checkFailedWords(input) {
1520
1552
  }
1521
1553
  /** Whether GitHub's workflow-run `path` names exactly `file` (it may carry the ref it ran from, after an `@`). */
1522
1554
  export function workflowIs(path, file) {
1523
- return typeof path === "string" && (path === file || path.startsWith(`${file}@`));
1555
+ if (typeof path !== "string")
1556
+ return false;
1557
+ if (path === file)
1558
+ return true;
1559
+ // GitHub may append @ref, but ci.yml@evil.yml is also a valid *different* workflow filename. Multiple @ signs
1560
+ // likewise cannot distinguish that filename from a ref. Refuse ambiguous suffixes instead of granting its checks.
1561
+ const ref = path.startsWith(`${file}@`) ? path.slice(file.length + 1) : "";
1562
+ return /^[A-Za-z0-9._+/-]+$/.test(ref) && !/\.ya?ml$/i.test(ref);
1524
1563
  }
@@ -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