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 +15 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/git-views.d.ts +1 -1
- package/dist/git-views.js +2 -2
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/release-adapters.d.ts +9 -2
- package/dist/release-adapters.js +177 -67
- package/dist/release-net.d.ts +90 -0
- package/dist/release-net.js +253 -0
- package/dist/release.d.ts +3 -1
- package/dist/release.js +14 -9
- package/dist/teardown-git.d.ts +1 -1
- package/dist/teardown-git.js +7 -7
- package/package.json +1 -1
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
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -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
|
} | {
|
package/dist/git-views.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
180
|
-
*
|
|
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>;
|
package/dist/release-adapters.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
259
|
-
*
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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(
|
|
377
|
-
upToDateRequired: () => readUpToDateRequired(
|
|
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
|
|
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(
|
|
579
|
-
async complete(repo, sha) { return readSuitesComplete(
|
|
580
|
-
async mainHead(repo) { return readMainHead(
|
|
581
|
-
async contains(repo, sha, ancestor) { return readContains(
|
|
582
|
-
async upToDateRequired(repo) { return readMainProtection(
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
throw
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
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
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
1177
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1308
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1334
|
-
|
|
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
|
-
/**
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
|
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
|
|
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):
|
package/dist/teardown-git.d.ts
CHANGED
|
@@ -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
|
package/dist/teardown-git.js
CHANGED
|
@@ -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.
|
|
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"
|