toolroll 0.9.77 → 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 +96 -0
- package/dist/agent-fence.js +18 -2
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +4 -3
- package/dist/builder.js +14 -8
- package/dist/check-failure.js +31 -3
- package/dist/check-report.d.ts +18 -3
- package/dist/check-report.js +67 -7
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/controller-service.js +4 -1
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/dispose.js +4 -3
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +15 -3
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +15 -1
- package/dist/git-views.d.ts +1 -1
- package/dist/git-views.js +2 -2
- package/dist/guides.js +18 -8
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.js +203 -16
- package/dist/planner.js +2 -2
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/release-adapters.d.ts +41 -3
- package/dist/release-adapters.js +275 -73
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +74 -5
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-net.d.ts +90 -0
- package/dist/release-net.js +253 -0
- package/dist/release-recheck.js +3 -2
- package/dist/release.d.ts +40 -5
- package/dist/release.js +142 -52
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +71 -8
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +19 -2
- package/dist/scout.js +2 -2
- package/dist/serve.js +11 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/tasks.js +4 -1
- package/dist/storage-sweep.js +1 -1
- package/dist/store.d.ts +125 -2
- package/dist/store.js +250 -12
- package/dist/surface.js +3 -1
- package/dist/teardown-git.d.ts +1 -1
- package/dist/teardown-git.js +12 -12
- package/dist/toolroll-update.js +2 -2
- package/dist/up-role-child.js +6 -2
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
package/dist/release-cli.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type ReleaseAdapters } from "./release.js";
|
|
9
9
|
import { type GateReleaseWorld } from "./release-gate.js";
|
|
10
|
+
import { type RescueWorld } from "./deploy-rescue.js";
|
|
10
11
|
export declare const RELEASE_HELP: string;
|
|
11
12
|
/** A runner lease id (claim.ts mints randomUUID()). */
|
|
12
13
|
export declare const LEASE_SHAPED: RegExp;
|
|
@@ -20,6 +21,8 @@ export type ReleaseCliDeps = {
|
|
|
20
21
|
warn?: (line: string) => void;
|
|
21
22
|
/** What --release-gate reads and changes (release-gate.ts); the installation's own by default. */
|
|
22
23
|
gateWorld?: GateReleaseWorld;
|
|
24
|
+
/** What --recover-deploy reads and runs (deploy-rescue.ts); the installation's own by default. */
|
|
25
|
+
rescueWorld?: RescueWorld;
|
|
23
26
|
/** The plane's database for the authority commands (default: this installation's). */
|
|
24
27
|
database?: string;
|
|
25
28
|
now?: () => Date;
|
package/dist/release-cli.js
CHANGED
|
@@ -14,7 +14,8 @@ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
|
14
14
|
import { checkedEnvelopeJson } from "./contracts/cli.js";
|
|
15
15
|
import { RELEASE_LIMITS, runRelease } from "./release.js";
|
|
16
16
|
import { processes, releaseAdapters } from "./release-adapters.js";
|
|
17
|
-
import { releaseGate } from "./release-gate.js";
|
|
17
|
+
import { journals, releaseGate } from "./release-gate.js";
|
|
18
|
+
import { rescueDeployment } from "./deploy-rescue.js";
|
|
18
19
|
import { databasePath, openStore, revokeRelease } from "./store.js";
|
|
19
20
|
import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
|
|
20
21
|
import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
|
|
@@ -39,6 +40,13 @@ toolroll release --release-gate <id> [--yes] — lift the update pause a deploym
|
|
|
39
40
|
database, service definition and running service are all still the previous build's. --yes lifts that one pause.
|
|
40
41
|
Any missing or contradicting evidence refuses and leaves the pause in place.
|
|
41
42
|
|
|
43
|
+
toolroll release --recover-deploy [--stage <dir>] [--yes] — put back a deployment that stopped half way
|
|
44
|
+
|
|
45
|
+
Finds the one unfinished deployment (or the stage named) and shows what its recovery will do; --yes does it. When
|
|
46
|
+
the old service isn't proved stopped, it boots the service out and stops what it left running, but only processes
|
|
47
|
+
proved, by exact runtime path and arguments, to be that deployment's own; then it starts the previous service
|
|
48
|
+
again, waits for it, and lifts the update pause. Anything it can't prove refuses. Safe to run again.
|
|
49
|
+
|
|
42
50
|
toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
|
|
43
51
|
|
|
44
52
|
Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
|
|
@@ -74,15 +82,17 @@ Options
|
|
|
74
82
|
--limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
|
|
75
83
|
--tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
|
|
76
84
|
--release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
|
|
77
|
-
--yes with --release-gate: lift the pause once it is proved safe
|
|
85
|
+
--yes with --release-gate: lift the pause once it is proved safe; with --recover-deploy: recover
|
|
86
|
+
--recover-deploy recover the unfinished deployment (see above)
|
|
87
|
+
--stage <dir> with --recover-deploy: the deployment's staging folder, when more than one is unfinished
|
|
78
88
|
--json answer with one machine envelope
|
|
79
89
|
|
|
80
90
|
A runner's lease is returned with \`toolroll worker release <lease>\`.`;
|
|
81
91
|
/** A runner lease id (claim.ts mints randomUUID()). */
|
|
82
92
|
export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
83
93
|
export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
|
|
84
|
-
const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule"]);
|
|
85
|
-
const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start"]);
|
|
94
|
+
const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage"]);
|
|
95
|
+
const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start", "--recover-deploy"]);
|
|
86
96
|
export async function runReleaseCommand(args, write, deps = {}) {
|
|
87
97
|
// The flag, not a secret's value that happens to read "--json".
|
|
88
98
|
// A secret is never exactly one of these names (the loop below refuses that as a missing value).
|
|
@@ -94,6 +104,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
94
104
|
};
|
|
95
105
|
const positional = [];
|
|
96
106
|
const limits = {};
|
|
107
|
+
let stage;
|
|
97
108
|
let repo, tap, gate, to, request, as, token, scheduleFlag;
|
|
98
109
|
for (let i = 0; i < args.length; i += 1) {
|
|
99
110
|
const raw = args[i];
|
|
@@ -126,6 +137,8 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
126
137
|
tap = value;
|
|
127
138
|
else if (arg === "--release-gate")
|
|
128
139
|
gate = value;
|
|
140
|
+
else if (arg === "--stage")
|
|
141
|
+
stage = value;
|
|
129
142
|
else {
|
|
130
143
|
const match = /^([a-z]+)=([0-9]+)$/.exec(value);
|
|
131
144
|
if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
|
|
@@ -147,8 +160,15 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
147
160
|
return refuse("usage", "--release-gate <id> takes no branch or release options.", 2);
|
|
148
161
|
return runGateRelease(gate, bare.has("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
|
|
149
162
|
}
|
|
163
|
+
if (bare.has("--recover-deploy")) {
|
|
164
|
+
if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new", "--release-gate"].includes(arg.split("=")[0])))
|
|
165
|
+
return refuse("usage", "--recover-deploy takes only --stage <dir>, --yes and --json.", 2);
|
|
166
|
+
return runRescue(stage, bare.has("--yes"), write, json, deps.rescueWorld ?? installedRescueWorld(deps.cwd ?? process.cwd()));
|
|
167
|
+
}
|
|
168
|
+
if (stage !== undefined)
|
|
169
|
+
return refuse("usage", "--stage goes with --recover-deploy.", 2);
|
|
150
170
|
if (bare.has("--yes"))
|
|
151
|
-
return refuse("usage", "--yes goes with --release-gate <id
|
|
171
|
+
return refuse("usage", "--yes goes with --release-gate <id> or --recover-deploy; a release needs no confirmation beyond the owner's approval.", 2);
|
|
152
172
|
if (positional[0] === "nightly" && positional.length === 1)
|
|
153
173
|
return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
|
|
154
174
|
if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
@@ -217,6 +237,55 @@ function installedGateWorld() {
|
|
|
217
237
|
.filter(path => existsSync(path)).map(path => ({ path, text: readFileSync(path, "utf8") })),
|
|
218
238
|
};
|
|
219
239
|
}
|
|
240
|
+
/** The installation's deployments, its plane's records and the release's own recovery road, for --recover-deploy. */
|
|
241
|
+
function installedRescueWorld(cwd) {
|
|
242
|
+
const database = databasePath(process.env, homedir());
|
|
243
|
+
const stateDir = dirname(database);
|
|
244
|
+
const adapters = releaseAdapters({ checkout: cwd, say: line => process.stderr.write(`${line}\n`) });
|
|
245
|
+
return {
|
|
246
|
+
database, stateDir,
|
|
247
|
+
journals: () => journals(stateDir),
|
|
248
|
+
runWorktree: run => {
|
|
249
|
+
if (!existsSync(database))
|
|
250
|
+
return null;
|
|
251
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
252
|
+
try {
|
|
253
|
+
const row = db.prepare("SELECT worktree FROM run WHERE id = ?").get(run);
|
|
254
|
+
return typeof row?.["worktree"] === "string" && row["worktree"] !== "" ? row["worktree"] : null;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
db.close();
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
recover: async (input) => {
|
|
264
|
+
const answer = await adapters.toolroll.recoverDeploy(input);
|
|
265
|
+
return answer.ok ? { ok: true } : { ok: false, message: answer.message };
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function runRescue(stage, yes, write, json, world) {
|
|
270
|
+
let outcome;
|
|
271
|
+
try {
|
|
272
|
+
outcome = await rescueDeployment(stage, yes, world);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
outcome = { ok: false, reason: "failed", message: `${error.message} Run \`toolroll release --recover-deploy --yes\` again once the cause is fixed.` };
|
|
276
|
+
}
|
|
277
|
+
if (!outcome.ok) {
|
|
278
|
+
write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message }) : `✗ ${outcome.message}`);
|
|
279
|
+
return outcome.reason === "usage" ? 2 : 3;
|
|
280
|
+
}
|
|
281
|
+
if (json) {
|
|
282
|
+
write(checkedEnvelopeJson({ ok: true, command: "release", recovery: { acted: outcome.acted, stage: outcome.stage, phase: outcome.phase, said: outcome.words } }));
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
for (const line of outcome.words)
|
|
286
|
+
write(outcome.acted ? `✓ ${line}` : line);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
220
289
|
function runGateRelease(id, yes, write, json, world) {
|
|
221
290
|
let outcome;
|
|
222
291
|
try {
|
package/dist/release-gate.d.ts
CHANGED
|
@@ -61,6 +61,11 @@ export type GateProof = {
|
|
|
61
61
|
};
|
|
62
62
|
/** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
|
|
63
63
|
export declare function gateOwner(db: Database): string | null;
|
|
64
|
+
/** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
|
|
65
|
+
export declare function journals(stateDir: string): {
|
|
66
|
+
stage: string;
|
|
67
|
+
journal: Record<string, unknown> | null;
|
|
68
|
+
}[];
|
|
64
69
|
/** Whether the pause owned by `id` was left by a deployment that never began its swap, and the evidence that says so. */
|
|
65
70
|
export declare function proveBeforeSwap(id: string, world: GateReleaseWorld): GateProof;
|
|
66
71
|
export type GateReleaseOutcome = {
|
package/dist/release-gate.js
CHANGED
|
@@ -36,7 +36,7 @@ export function gateOwner(db) {
|
|
|
36
36
|
return ids.size === 1 ? [...ids][0] : null;
|
|
37
37
|
}
|
|
38
38
|
/** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
|
|
39
|
-
function journals(stateDir) {
|
|
39
|
+
export function journals(stateDir) {
|
|
40
40
|
const root = join(stateDir, "staged-upgrades");
|
|
41
41
|
let stages = [];
|
|
42
42
|
try {
|
|
@@ -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-recheck.js
CHANGED
|
@@ -25,7 +25,7 @@ import { readVerifiedCommit, verifiedTreeDiff } from "./commit-extract.js";
|
|
|
25
25
|
import { checkContextSchema, fileIdentity, groupIdentity, journeyIdentity, obligationSnapshotSchema, readCheckReport, sealedInventorySchema, targetedPlanSchema, unitIdentity, versionIdentity, } from "./contracts/release-check.js";
|
|
26
26
|
import { parseContract } from "./contracts/contract.js";
|
|
27
27
|
import { releaseAuthority, releaseAuthorityOfTask, releaseCarries } from "./release-authority.js";
|
|
28
|
-
import { liveReleaseOf } from "./scope.js";
|
|
28
|
+
import { liveReleaseOf, releaseCheckScope } from "./scope.js";
|
|
29
29
|
import { checkAttemptOf } from "./store.js";
|
|
30
30
|
/** The sanity pass every targeted check runs beside its plan: a few fast tests of the core rules, when the tree has them. */
|
|
31
31
|
export const SANITY_UNITS = Object.freeze(["src/scope.test.ts", "src/contract.test.ts", "src/check-report.test.ts", "src/update-safe-migrations.test.ts"]);
|
|
@@ -485,7 +485,8 @@ export function settleCoverage(store, root, input, gateOf) {
|
|
|
485
485
|
}
|
|
486
486
|
// A release's Full check (its task carries signed release terms, before or after its authority opens — an automatic
|
|
487
487
|
// approval's check runs before) counts only with a complete, valid result (check-report.ts): anything less is no result.
|
|
488
|
-
|
|
488
|
+
// A release check purpose (pipeline-speed.md, P0) counts too: a termsless release is held to the same rules.
|
|
489
|
+
const release = b.attempt.authority !== null || releaseCheckScope(store.getScope(input.taskId));
|
|
489
490
|
if (release && fullCheckProblem({ report: b.report, inventory: b.inventory, exitCode: input.exitCode }) !== null)
|
|
490
491
|
return { coverage: "no-result", composed: null };
|
|
491
492
|
return { coverage: "full", composed: null };
|
package/dist/release.d.ts
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
/** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
|
|
36
36
|
* concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
|
|
37
|
-
export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie"];
|
|
37
|
+
export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie", "node 22 · macos-latest", "node 24 · macos-latest"];
|
|
38
38
|
/** The workflow file every REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
|
|
39
39
|
* added beside it) never stands for it (Codex review round 1, inherited CI limitation). */
|
|
40
40
|
export declare const REQUIRED_PR_CHECKS_WORKFLOW = ".github/workflows/ci.yml";
|
|
@@ -57,8 +57,11 @@ export type MainProtection = {
|
|
|
57
57
|
missing: string[];
|
|
58
58
|
notYetRequired: string[];
|
|
59
59
|
};
|
|
60
|
-
/** The checks a release tag
|
|
61
|
-
|
|
60
|
+
/** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, pipeline-speed.md item 3):
|
|
61
|
+
* the publish job and the macOS legs it needs, each named. Nothing else reported on the merge commit (main's own CI push
|
|
62
|
+
* run) holds publication. */
|
|
63
|
+
export declare const RELEASE_TAG_CHECKS: readonly ["publish", "tag macOS · node 22", "tag macOS · node 24"];
|
|
64
|
+
export declare const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
|
|
62
65
|
export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
|
|
63
66
|
export type ReleaseStep = typeof RELEASE_STEPS[number];
|
|
64
67
|
type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
|
|
@@ -170,6 +173,8 @@ export type DeployStop = {
|
|
|
170
173
|
phase: string;
|
|
171
174
|
};
|
|
172
175
|
};
|
|
176
|
+
/** The tap's formula for a version: current, a proposal open, missing — or `newer`: the tap's default branch already
|
|
177
|
+
* names a later version, which an older release must never overwrite. */
|
|
173
178
|
export type HomebrewState = {
|
|
174
179
|
kind: "current";
|
|
175
180
|
} | {
|
|
@@ -178,7 +183,14 @@ export type HomebrewState = {
|
|
|
178
183
|
head: string;
|
|
179
184
|
} | {
|
|
180
185
|
kind: "missing";
|
|
186
|
+
} | {
|
|
187
|
+
kind: "newer";
|
|
188
|
+
version: string;
|
|
181
189
|
};
|
|
190
|
+
/** Homebrew after publication (pipeline-speed.md, P1): its own durable state, finished by `toolroll release <branch>`.
|
|
191
|
+
* `pending`: a change proposed or its checks still running; `failed`: its checks failed (a later run reads them again);
|
|
192
|
+
* `current`: merged, or already on the tap; `superseded`: the tap already names a newer version, so nothing was changed. */
|
|
193
|
+
export type HomebrewProgress = "pending" | "failed" | "current" | "superseded";
|
|
182
194
|
/** One commit that landed after the approval, with its files and line counts (release_carry). */
|
|
183
195
|
export type LandedCommit = {
|
|
184
196
|
sha: string;
|
|
@@ -442,7 +454,10 @@ export type ReleaseAdapters = {
|
|
|
442
454
|
github: {
|
|
443
455
|
authenticated(): Promise<boolean>;
|
|
444
456
|
pullRequest(repo: string, branch: string): Promise<PullRequest | null>;
|
|
445
|
-
|
|
457
|
+
/** `tag`: a release tag's push binds its workflow file too (only that exact tag's push). */
|
|
458
|
+
checks(repo: string, sha: string, options?: {
|
|
459
|
+
tag?: string;
|
|
460
|
+
}): Promise<CheckRun[]>;
|
|
446
461
|
/** Whether GitHub's check history of this commit can be read whole (fewer check suites than the check-runs
|
|
447
462
|
* endpoint's ceiling). Absent or false: an automatic release fails closed; a person's is unaffected. */
|
|
448
463
|
complete?(repo: string, sha: string): Promise<boolean>;
|
|
@@ -472,6 +487,14 @@ export type ReleaseAdapters = {
|
|
|
472
487
|
pr: number;
|
|
473
488
|
head: string;
|
|
474
489
|
}>;
|
|
490
|
+
/** Right before the merge: the change is the tap's own open pull request at exactly `head`, and its formula is
|
|
491
|
+
* exactly the tap's formula with npm's published tarball for `version` and that tarball's own sha256. */
|
|
492
|
+
verified(pr: number, head: string, version: string): Promise<{
|
|
493
|
+
ok: true;
|
|
494
|
+
} | {
|
|
495
|
+
ok: false;
|
|
496
|
+
why: string;
|
|
497
|
+
}>;
|
|
475
498
|
merge(pr: number, head: string): Promise<void>;
|
|
476
499
|
};
|
|
477
500
|
};
|
|
@@ -538,6 +561,9 @@ export type ReleaseJournal = {
|
|
|
538
561
|
pr: number | null;
|
|
539
562
|
head: string | null;
|
|
540
563
|
merged: boolean;
|
|
564
|
+
/** Absent in a journal from before Homebrew was finished apart: read from `merged`. */
|
|
565
|
+
state?: HomebrewProgress;
|
|
566
|
+
why?: string | null;
|
|
541
567
|
} | null;
|
|
542
568
|
/** The release's authority record (release-once.md), once a person approved terms: the id and generation. */
|
|
543
569
|
authority?: {
|
|
@@ -635,9 +661,18 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
|
|
|
635
661
|
state: "failed";
|
|
636
662
|
failed: string[];
|
|
637
663
|
};
|
|
664
|
+
/**
|
|
665
|
+
* Publication's verdict on the tag commit (pipeline-speed.md, item 3): only RELEASE_TAG_CHECKS, from GitHub Actions
|
|
666
|
+
* runs of publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's
|
|
667
|
+
* own CI run — is not waited for: the candidate's checks already passed on its exact head before the merge, and the
|
|
668
|
+
* merged tree was proved equal to it.
|
|
669
|
+
*/
|
|
670
|
+
export declare function tagChecksVerdict(runs: readonly CheckRun[]): ReturnType<typeof checksVerdict>;
|
|
638
671
|
/** Run the release from wherever its journal stopped. */
|
|
639
672
|
export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
|
|
640
|
-
/** 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. */
|
|
641
676
|
export declare function transientNetworkError(error: unknown): boolean;
|
|
642
677
|
/**
|
|
643
678
|
* The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
|