toolroll 0.9.78 → 0.9.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -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/guides.js +12 -9
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/release-adapters.d.ts +9 -2
- package/dist/release-adapters.js +178 -68
- package/dist/release-auto.d.ts +1 -1
- package/dist/release-net.d.ts +90 -0
- package/dist/release-net.js +253 -0
- package/dist/release.d.ts +23 -18
- package/dist/release.js +72 -33
- 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,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.81 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
- **The release proof works after the merge.** GitHub empties a CI run's
|
|
6
|
+
pull-request link once the pull request merges and its branch is deleted,
|
|
7
|
+
so 0.9.80's tag could never prove its checks and wasn't published. A run
|
|
8
|
+
with no link now stands only when its branch has had exactly one pull
|
|
9
|
+
request, ever, and that pull request merged into main. 0.9.80's changes
|
|
10
|
+
ship in this release.
|
|
11
|
+
|
|
12
|
+
## 0.9.80 — 2026-10-30
|
|
13
|
+
|
|
14
|
+
- **Each test runs once, where it adds something** (docs/plans/test-matrix.md).
|
|
15
|
+
Pull requests run the unit suite on Node 24 (Linux and macOS) and every
|
|
16
|
+
area lane (Windows, Linux fence and cgroup, git 2.41 and 2.47); a pull
|
|
17
|
+
request that only changes Markdown under `docs/`, the changelog or the
|
|
18
|
+
README skips the area lanes. Node 22 runs on Linux and macOS once per
|
|
19
|
+
release, on the `release-*` branch's pull request, in the new
|
|
20
|
+
Release candidate workflow. Release tags no longer rerun the macOS suites:
|
|
21
|
+
a release proof reads GitHub's record that the tagged tree passed every
|
|
22
|
+
required check on its pull request before npm publication. A push to main
|
|
23
|
+
typechecks and builds, and runs the full CI only when it can't prove the
|
|
24
|
+
merge commit was tested. The nightly runs everything. A release now needs
|
|
25
|
+
every required check executed, never skipped, and refuses to merge while
|
|
26
|
+
main's branch protection doesn't require all of them. Hosted unit shards
|
|
27
|
+
require their platform's native fence attacks to run.
|
|
28
|
+
|
|
29
|
+
## 0.9.79 — 2026-10-29
|
|
30
|
+
|
|
31
|
+
- **`toolroll release` rides out a short network failure instead of
|
|
32
|
+
stopping.** A read from GitHub or git (`gh api`, `gh pr list`,
|
|
33
|
+
`git ls-remote`, `git fetch`) that fails for a passing reason (no
|
|
34
|
+
connection, a name that didn't resolve, a timeout, a 5xx, an answer cut
|
|
35
|
+
short, GitHub's secondary rate limit) is asked again with backoff for up
|
|
36
|
+
to about three minutes, saying why once; then it stops with the original
|
|
37
|
+
error. A definitive answer (404, 401/403) is never asked again. A merge or
|
|
38
|
+
a tag is never sent twice blindly: after a lost answer the release reads
|
|
39
|
+
GitHub to learn whether it happened, and asks again only when it didn't.
|
|
40
|
+
When main's branch protection can't be read, the release now says
|
|
41
|
+
"Couldn't read GitHub's branch protection" instead of "main's protection
|
|
42
|
+
doesn't hold", and still stops.
|
|
43
|
+
|
|
3
44
|
## 0.9.78 — 2026-10-29
|
|
4
45
|
|
|
5
46
|
- **Toolroll says when it is restarting.** The worker and console now record
|
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
|
}
|
package/dist/guides.js
CHANGED
|
@@ -473,20 +473,23 @@ with a time limit.
|
|
|
473
473
|
saved first; if either name doesn't answer as the new build, both go
|
|
474
474
|
back. It goes on only when the console, worker and both names run the
|
|
475
475
|
gated commit. A rerun relinks without deploying again.
|
|
476
|
-
8. **ci**: waits for every pull-request check on the gated commit:
|
|
477
|
-
|
|
478
|
-
|
|
476
|
+
8. **ci**: waits for every pull-request check on the gated commit: Node 24
|
|
477
|
+
on Ubuntu and macOS, the area lanes (Linux cgroup and fence, Windows,
|
|
478
|
+
git 2.41 and 2.47), and Node 22 on Ubuntu and macOS, which runs on a
|
|
479
|
+
release-* branch's pull request.
|
|
480
|
+
Pending, missing, skipped, cancelled or failed never merges, and main's
|
|
481
|
+
protection must require every one of them.
|
|
479
482
|
9. **merge**: squash-merges with the head pinned to the gated commit,
|
|
480
483
|
once main has nothing the branch lacks, then deletes the remote branch
|
|
481
484
|
only if its head still matches. A resumed merge also finishes this cleanup.
|
|
482
485
|
10. **tree**: main's merge commit must have exactly the gated tree, or
|
|
483
486
|
nothing is tagged.
|
|
484
|
-
11. **tag**: pushes \`v<version>\` on that merge commit.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
12. **publish**: waits for npm, the GitHub release and the tag's
|
|
489
|
-
|
|
487
|
+
11. **tag**: pushes \`v<version>\` on that merge commit. The tag runs no
|
|
488
|
+
tests again: publish.yml's release proof reads GitHub's record that the
|
|
489
|
+
tagged tree is the merged pull request's head tree and passed every
|
|
490
|
+
required check there, and npm and GitHub publication need it.
|
|
491
|
+
12. **publish**: waits for npm, the GitHub release and the tag's release
|
|
492
|
+
proof and publish jobs; not main's own push run on the merge commit.
|
|
490
493
|
13. **homebrew**: the release is published once npm and the GitHub release
|
|
491
494
|
are verified. It proposes the tap's formula change (checksum from npm's
|
|
492
495
|
tarball) and looks at its checks once: \`Published; Homebrew pending\`
|
|
@@ -22,17 +22,20 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
|
|
|
22
22
|
export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
|
+
title?: never;
|
|
26
|
+
history?: never;
|
|
25
27
|
revision: number;
|
|
26
28
|
stale: boolean;
|
|
27
29
|
notice: string;
|
|
28
30
|
decision?: never;
|
|
29
|
-
title?: never;
|
|
30
31
|
content?: never;
|
|
31
|
-
history?: never;
|
|
32
32
|
instructions?: never;
|
|
33
33
|
references?: never;
|
|
34
34
|
decisions?: never;
|
|
35
35
|
} | {
|
|
36
|
+
title?: never;
|
|
37
|
+
revision?: never;
|
|
38
|
+
history?: never;
|
|
36
39
|
stale?: never;
|
|
37
40
|
decision: {
|
|
38
41
|
repo: string;
|
|
@@ -49,28 +52,25 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
49
52
|
id: number;
|
|
50
53
|
};
|
|
51
54
|
notice: string;
|
|
52
|
-
title?: never;
|
|
53
55
|
content?: never;
|
|
54
|
-
revision?: never;
|
|
55
|
-
history?: never;
|
|
56
56
|
instructions?: never;
|
|
57
57
|
references?: never;
|
|
58
58
|
decisions?: never;
|
|
59
59
|
} | {
|
|
60
|
+
history?: never;
|
|
60
61
|
stale?: never;
|
|
61
62
|
decision?: never;
|
|
62
63
|
revision: number;
|
|
63
64
|
title: string;
|
|
64
65
|
content: string;
|
|
65
66
|
notice: string;
|
|
66
|
-
history?: never;
|
|
67
67
|
instructions?: never;
|
|
68
68
|
references?: never;
|
|
69
69
|
decisions?: never;
|
|
70
70
|
} | {
|
|
71
|
+
title?: never;
|
|
71
72
|
stale?: never;
|
|
72
73
|
decision?: never;
|
|
73
|
-
title?: never;
|
|
74
74
|
content?: never;
|
|
75
75
|
revision: number;
|
|
76
76
|
history: {
|
|
@@ -176,9 +176,16 @@ export declare function readContains(run: Exec, gh: string, repo: string, sha: s
|
|
|
176
176
|
* can't be bypassed by that account:
|
|
177
177
|
* classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
|
|
178
178
|
* bypass list reads whole, names no role, team or organization admin, and says this account never bypasses it. Read
|
|
179
|
-
* only — Toolroll never changes a repository's settings. A layer that can't be read never holds
|
|
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)
|
|
@@ -1191,7 +1290,7 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
|
1191
1290
|
else {
|
|
1192
1291
|
const lacking = PROTECTION_MINIMUM_CHECKS.filter(name => !checks.includes(name));
|
|
1193
1292
|
if (lacking.length > 0)
|
|
1194
|
-
own.push(`${where}: required checks lack ${lacking.join(", ")}`);
|
|
1293
|
+
own.push(`${where}: required checks lack ${lacking.join(", ")} (add ${lacking.length === 1 ? "it" : "them"} to main's required status checks: a release's proof trusts what main required)`);
|
|
1195
1294
|
const unbound = PROTECTION_MINIMUM_CHECKS.filter(name => checks.includes(name) && !bound.includes(name));
|
|
1196
1295
|
if (unbound.length > 0)
|
|
1197
1296
|
own.push(`${where}: required checks must require GitHub Actions (app 15368) as their source: ${unbound.join(", ")}`);
|
|
@@ -1227,46 +1326,41 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
|
1227
1326
|
catch { /* unreadable below */ }
|
|
1228
1327
|
const p = object(answer), checks = p["required_status_checks"];
|
|
1229
1328
|
if (answer === null || typeof answer !== "object" || Array.isArray(answer))
|
|
1230
|
-
|
|
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). */
|
package/dist/release-auto.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type ObservedCheckRun = {
|
|
|
19
19
|
suite?: number | null;
|
|
20
20
|
head?: string | null;
|
|
21
21
|
workflow?: number | null;
|
|
22
|
-
/** The workflow's file (GitHub's workflow-run `path`): a required check counts only from
|
|
22
|
+
/** The workflow's file (GitHub's workflow-run `path`): a required check counts only from its own (REQUIRED_PR_CHECKS_WORKFLOW). */
|
|
23
23
|
workflowPath?: string | null;
|
|
24
24
|
/** GitHub's job record shows this unsuccessful attempt ended before any workflow step ran (release-adapters.ts). */
|
|
25
25
|
beforeTests?: boolean | null;
|