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-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) { 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
|
},
|
|
@@ -653,8 +703,16 @@ export function releaseAdapters(options) {
|
|
|
653
703
|
const current = await formulaAt(branch);
|
|
654
704
|
if (current.text.includes(`-${version}.tgz`))
|
|
655
705
|
return { kind: "current" };
|
|
656
|
-
|
|
657
|
-
const
|
|
706
|
+
// Ordering (pipeline-speed.md, P1): a tap already on a later version is never moved back by an older release.
|
|
707
|
+
const named = formulaVersion(current.text);
|
|
708
|
+
if (named !== null && versionAfter(named, version))
|
|
709
|
+
return { kind: "newer", version: named };
|
|
710
|
+
// Only a change from the tap's own branch counts: a fork's pull request of the same branch name never does.
|
|
711
|
+
const open = (await ghJson("gh pr list", "pr", "list", "--repo", tap, "--head", brewBranch(version), "--state", "open", "--json", "number,headRefOid,headRepository,headRepositoryOwner,isCrossRepository"));
|
|
712
|
+
const own = open.filter(one => tapOwnHead(one, tap));
|
|
713
|
+
if (own.length > 1)
|
|
714
|
+
throw new Error(`${tap} has ${own.length} open changes for ${brewBranch(version)}`);
|
|
715
|
+
const pr = own[0];
|
|
658
716
|
return pr === undefined ? { kind: "missing" } : { kind: "proposed", pr: Number(pr["number"]), head: String(pr["headRefOid"]) };
|
|
659
717
|
},
|
|
660
718
|
async propose(version) {
|
|
@@ -664,14 +722,20 @@ export function releaseAdapters(options) {
|
|
|
664
722
|
throw new Error(`downloading ${tarball} answered ${response.status}`);
|
|
665
723
|
const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
|
|
666
724
|
const base = await defaultBranch();
|
|
667
|
-
const
|
|
725
|
+
const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
726
|
+
const current = await formulaAt(baseSha);
|
|
727
|
+
const baseVersion = formulaVersion(current.text);
|
|
728
|
+
if (baseVersion === null || versionAfter(baseVersion, version))
|
|
729
|
+
throw new Error("The tap current version is unreadable or newer; no downgrade was proposed.");
|
|
668
730
|
const next = bumpFormula(current.text, tarball, sha256);
|
|
669
731
|
const branch = brewBranch(version);
|
|
670
|
-
const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
671
732
|
// A branch left by an earlier attempt is reused.
|
|
672
733
|
const made = await gh("api", "-X", "POST", `repos/${tap}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`);
|
|
673
734
|
if (made.code !== 0 && !/Reference already exists/.test(made.stderr + made.stdout))
|
|
674
735
|
throw new Error(`creating ${tap} ${branch}: ${made.stderr.trim()}`);
|
|
736
|
+
// A branch left by an earlier attempt starts again from the base (security scan): none of its history is kept.
|
|
737
|
+
if (made.code !== 0)
|
|
738
|
+
await must(gh("api", "-X", "PATCH", `repos/${tap}/git/refs/heads/${branch}`, "-f", `sha=${baseSha}`, "-F", "force=true"), `resetting ${tap} ${branch}`);
|
|
675
739
|
const onBranch = await formulaAt(branch);
|
|
676
740
|
if (onBranch.text !== next) {
|
|
677
741
|
await must(gh("api", "-X", "PUT", `repos/${tap}/contents/${formula}`, "-f", `message=toolroll ${version}`, "-f", `branch=${branch}`, "-f", `sha=${onBranch.sha}`, "-f", `content=${Buffer.from(next).toString("base64")}`), `updating ${formula}`);
|
|
@@ -680,13 +744,76 @@ export function releaseAdapters(options) {
|
|
|
680
744
|
const opened = (await ghJson("gh pr view", "pr", "view", branch, "--repo", tap, "--json", "number,headRefOid"));
|
|
681
745
|
return { pr: Number(opened["number"]), head: String(opened["headRefOid"]) };
|
|
682
746
|
},
|
|
747
|
+
async verified(pr, head, version) {
|
|
748
|
+
// The change at exactly `head` is the tap's own, and its formula is exactly today's formula with npm's published
|
|
749
|
+
// tarball and that tarball's own sha256: nothing else is ever merged.
|
|
750
|
+
const view = object(await ghJson("gh pr view", "pr", "view", String(pr), "--repo", tap, "--json", "number,headRefOid,headRefName,baseRefName,headRepository,headRepositoryOwner,isCrossRepository,state"));
|
|
751
|
+
const base = await defaultBranch();
|
|
752
|
+
const baseHead = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
753
|
+
// The whole change against the tap's default branch head: its merge base, its commits and every file it touches.
|
|
754
|
+
const compare = object(await ghJson("gh api compare", "api", `repos/${tap}/compare/${encodeURIComponent(baseHead)}...${encodeURIComponent(head)}`));
|
|
755
|
+
const tarball = `https://registry.npmjs.org/toolroll/-/toolroll-${version}.tgz`;
|
|
756
|
+
const response = await get(tarball, { signal: AbortSignal.timeout(60_000) });
|
|
757
|
+
if (!response.ok)
|
|
758
|
+
return { ok: false, why: `downloading ${tarball} answered ${response.status}` };
|
|
759
|
+
const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
|
|
760
|
+
const current = (await formulaAt(baseHead)).text;
|
|
761
|
+
const expected = bumpFormula(current, tarball, sha256);
|
|
762
|
+
const problem = tapChangeProblem({ tap, pr, head, version, formula, defaultBranch: base, baseHead, baseVersion: formulaVersion(current), view, compare, expected, atHead: (await formulaAt(head)).text, sha256 });
|
|
763
|
+
return problem === null ? { ok: true } : { ok: false, why: problem };
|
|
764
|
+
},
|
|
683
765
|
async merge(pr, head) {
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
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
|
+
});
|
|
687
777
|
},
|
|
688
778
|
},
|
|
689
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
|
+
}
|
|
690
817
|
async function defaultBranch() {
|
|
691
818
|
return (await must(gh("api", `repos/${tap}`, "--jq", ".default_branch"), `gh api repos/${tap}`)).trim();
|
|
692
819
|
}
|
|
@@ -696,6 +823,58 @@ export function releaseAdapters(options) {
|
|
|
696
823
|
}
|
|
697
824
|
}
|
|
698
825
|
const brewBranch = (version) => `toolroll-${version}`;
|
|
826
|
+
/**
|
|
827
|
+
* Why a tap change isn't exactly the formula bump this release proposed (security scan), or null when it is: the tap's
|
|
828
|
+
* own open pull request number `pr` at exactly `head`, from its version branch into the default branch; compared with
|
|
829
|
+
* the default branch's head it is one commit on top of exactly that head, changing exactly one file, the formula; and
|
|
830
|
+
* the formula there is exactly today's with npm's published tarball and that tarball's own sha256.
|
|
831
|
+
*/
|
|
832
|
+
export function tapChangeProblem(input) {
|
|
833
|
+
const { tap, pr, head, version, view, compare } = input;
|
|
834
|
+
// State may have changed since the coordinator checked it. Judge the immutable base used for this comparison.
|
|
835
|
+
if (input.baseVersion === null)
|
|
836
|
+
return `${tap} current version cannot be read`;
|
|
837
|
+
if (versionAfter(input.baseVersion, version))
|
|
838
|
+
return `${tap} already names newer ${input.baseVersion}; refusing ${version}`;
|
|
839
|
+
if (Number(view["number"]) !== pr || view["headRefOid"] !== head || view["headRefName"] !== brewBranch(version) || view["state"] !== "OPEN" || !tapOwnHead(view, tap)) {
|
|
840
|
+
return `${tap} #${pr} isn't the tap's own open change at ${head.slice(0, 7)}`;
|
|
841
|
+
}
|
|
842
|
+
if (view["baseRefName"] !== input.defaultBranch)
|
|
843
|
+
return `${tap} #${pr} merges into ${String(view["baseRefName"])}, not ${input.defaultBranch}`;
|
|
844
|
+
const mergeBase = compare["merge_base_commit"] !== null && typeof compare["merge_base_commit"] === "object" ? compare["merge_base_commit"]["sha"] : undefined;
|
|
845
|
+
if (mergeBase !== input.baseHead)
|
|
846
|
+
return `${tap} #${pr} isn't on ${input.defaultBranch}'s head ${input.baseHead.slice(0, 7)}`;
|
|
847
|
+
if (compare["status"] !== "ahead" || compare["ahead_by"] !== 1 || compare["behind_by"] !== 0)
|
|
848
|
+
return `${tap} #${pr} isn't exactly one commit on ${input.defaultBranch}'s head`;
|
|
849
|
+
const files = Array.isArray(compare["files"]) ? compare["files"] : null;
|
|
850
|
+
if (files === null || files.length !== 1 || files[0]["filename"] !== input.formula || files[0]["status"] !== "modified")
|
|
851
|
+
return `${tap} #${pr} changes more than ${input.formula}`;
|
|
852
|
+
if (input.atHead !== input.expected)
|
|
853
|
+
return `${tap} #${pr}'s formula isn't npm's ${version} tarball with its sha256 ${input.sha256.slice(0, 12)}`;
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
/** Whether a pull request's head is a branch of the tap itself (never a fork's). */
|
|
857
|
+
export function tapOwnHead(pr, tap) {
|
|
858
|
+
const [owner, name] = tap.split("/");
|
|
859
|
+
const repo = pr["headRepository"], who = pr["headRepositoryOwner"];
|
|
860
|
+
const repoName = repo !== null && typeof repo === "object" ? repo["name"] : undefined;
|
|
861
|
+
const ownerLogin = who !== null && typeof who === "object" ? who["login"] : undefined;
|
|
862
|
+
return pr["isCrossRepository"] === false && typeof repoName === "string" && typeof ownerLogin === "string"
|
|
863
|
+
&& repoName.toLowerCase() === (name ?? "").toLowerCase() && ownerLogin.toLowerCase() === (owner ?? "").toLowerCase();
|
|
864
|
+
}
|
|
865
|
+
/** The version a formula's tarball url names (`…/toolroll-<x.y.z>.tgz`), or null. */
|
|
866
|
+
export function formulaVersion(text) {
|
|
867
|
+
const found = /^\s*url "[^"]*-(\d+\.\d+\.\d+)\.tgz"/m.exec(text);
|
|
868
|
+
return found === null ? null : found[1];
|
|
869
|
+
}
|
|
870
|
+
/** Whether version `a` comes after `b` (both x.y.z). */
|
|
871
|
+
export function versionAfter(a, b) {
|
|
872
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
873
|
+
for (let i = 0; i < 3; i++)
|
|
874
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
875
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
699
878
|
/** The events that put checks on a change. */
|
|
700
879
|
const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
|
|
701
880
|
/**
|
|
@@ -918,7 +1097,11 @@ function ciEnv() {
|
|
|
918
1097
|
* lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
|
|
919
1098
|
* first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
|
|
920
1099
|
*/
|
|
921
|
-
export async function readCheckRuns(run, gh, repo, sha
|
|
1100
|
+
export async function readCheckRuns(run, gh, repo, sha,
|
|
1101
|
+
/** A release tag (pipeline-speed.md, item 3): a push of exactly this tag binds its workflow file too. */
|
|
1102
|
+
options = {}) {
|
|
1103
|
+
if (options.tag !== undefined && !/^v\d+\.\d+\.\d+$/.test(options.tag))
|
|
1104
|
+
throw new Error("not a release tag");
|
|
922
1105
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
923
1106
|
throw new Error("not a repository and commit GitHub can be asked about");
|
|
924
1107
|
const out = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/commits/${sha}/check-runs?filter=all&per_page=100`,
|
|
@@ -964,7 +1147,7 @@ export async function readCheckRuns(run, gh, repo, sha) {
|
|
|
964
1147
|
const prs = (Array.isArray(one["prs"]) ? one["prs"] : []).map(pr => (pr !== null && typeof pr === "object" ? pr : {}));
|
|
965
1148
|
const ours = prs.length > 0 && prs.every(pr => Number.isSafeInteger(pr["number"]) && pr["repo"] === `https://api.github.com/repos/${repo}`);
|
|
966
1149
|
const untouched = one["event"] !== "pull_request" || (ours && !(await Promise.all(prs.map(pr => everRetargeted(Number(pr["number"]))))).some(Boolean));
|
|
967
|
-
if (mainTriggered(one) && untouched)
|
|
1150
|
+
if ((options.tag === undefined ? mainTriggered(one) : tagTriggered(one, options.tag)) && untouched)
|
|
968
1151
|
pathOf.set(Number(one["suite"]), String(one["path"]));
|
|
969
1152
|
}
|
|
970
1153
|
const runs = out.split("\n").filter(Boolean).map(line => {
|
|
@@ -1085,15 +1268,19 @@ export async function readContains(run, gh, repo, sha, ancestor) {
|
|
|
1085
1268
|
* can't be bypassed by that account:
|
|
1086
1269
|
* classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
|
|
1087
1270
|
* bypass list reads whole, names no role, team or organization admin, and says this account never bypasses it. Read
|
|
1088
|
-
* only — Toolroll never changes a repository's settings. A layer that can't be read never holds
|
|
1089
|
-
*
|
|
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.
|
|
1090
1275
|
*/
|
|
1091
1276
|
export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
1092
1277
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
|
|
1093
1278
|
return null;
|
|
1094
1279
|
const missing = [];
|
|
1095
1280
|
const held = [];
|
|
1096
|
-
|
|
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}`;
|
|
1097
1284
|
const judge = (where, strict, checks, bound, bypass) => {
|
|
1098
1285
|
const own = [];
|
|
1099
1286
|
if (!strict)
|
|
@@ -1139,46 +1326,41 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
|
1139
1326
|
catch { /* unreadable below */ }
|
|
1140
1327
|
const p = object(answer), checks = p["required_status_checks"];
|
|
1141
1328
|
if (answer === null || typeof answer !== "object" || Array.isArray(answer))
|
|
1142
|
-
|
|
1143
|
-
else if (checks === undefined || checks === null)
|
|
1144
|
-
read++;
|
|
1329
|
+
unread.push("classic protection (its answer didn't parse)");
|
|
1330
|
+
else if (checks === undefined || checks === null)
|
|
1145
1331
|
missing.push("classic protection: \"Require status checks to pass before merging\" is off");
|
|
1146
|
-
}
|
|
1147
1332
|
else {
|
|
1148
1333
|
const rsc = object(checks), contexts = names(rsc["contexts"] ?? [], ""), apps = names(rsc["checks"] ?? [], "context");
|
|
1149
1334
|
const admins = p["enforce_admins"];
|
|
1335
|
+
// GitHub answered, but without the settings this judges: read, and not holding.
|
|
1150
1336
|
if (contexts === null || apps === null || typeof rsc["strict"] !== "boolean" || admins === undefined || admins === null || typeof object(admins)["enabled"] !== "boolean")
|
|
1151
|
-
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");
|
|
1152
1338
|
else {
|
|
1153
|
-
read++;
|
|
1154
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"]);
|
|
1155
1340
|
}
|
|
1156
1341
|
}
|
|
1157
1342
|
}
|
|
1158
|
-
else if (notProtected(classic))
|
|
1159
|
-
|
|
1160
|
-
else
|
|
1161
|
-
missing.push("classic protection couldn't be read");
|
|
1343
|
+
else if (!notProtected(classic))
|
|
1344
|
+
unread.push(`classic protection (${words(classic)})`);
|
|
1162
1345
|
// Rulesets: every active rule requiring status checks on the branch, then its ruleset's enforcement and bypass list.
|
|
1163
1346
|
const rules = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/rules/branches/${branch}`,
|
|
1164
1347
|
"--jq", ".[] | select(.type == \"required_status_checks\") | @json"], { cwd: "/", env: ciEnv() });
|
|
1165
1348
|
if (rules.code !== 0)
|
|
1166
|
-
|
|
1349
|
+
unread.push(`main's rulesets (${words(rules)})`);
|
|
1167
1350
|
else {
|
|
1168
|
-
read++;
|
|
1169
1351
|
for (const line of rules.stdout.split("\n").filter(one => one.trim() !== "")) {
|
|
1170
1352
|
let rule;
|
|
1171
1353
|
try {
|
|
1172
1354
|
rule = object(JSON.parse(line));
|
|
1173
1355
|
}
|
|
1174
1356
|
catch {
|
|
1175
|
-
|
|
1357
|
+
unread.push("a ruleset rule (its answer didn't parse)");
|
|
1176
1358
|
continue;
|
|
1177
1359
|
}
|
|
1178
1360
|
const id = rule["ruleset_id"], params = object(rule["parameters"]);
|
|
1179
1361
|
const checks = names(params["required_status_checks"], "context");
|
|
1180
1362
|
if (!Number.isSafeInteger(id) || Number(id) <= 0 || checks === null) {
|
|
1181
|
-
missing.push("a ruleset rule
|
|
1363
|
+
missing.push("a ruleset rule doesn't name its ruleset or required checks");
|
|
1182
1364
|
continue;
|
|
1183
1365
|
}
|
|
1184
1366
|
const where = `ruleset ${id}`;
|
|
@@ -1192,7 +1374,7 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
|
1192
1374
|
set = null;
|
|
1193
1375
|
}
|
|
1194
1376
|
if (set === null) {
|
|
1195
|
-
|
|
1377
|
+
unread.push(`${where}: its settings (${got.code === 0 ? "its answer didn't parse" : words(got)})`);
|
|
1196
1378
|
continue;
|
|
1197
1379
|
}
|
|
1198
1380
|
const bypass = [];
|
|
@@ -1216,17 +1398,37 @@ export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
|
1216
1398
|
const required = new Set(held.flat());
|
|
1217
1399
|
return { effective: true, missing: [], notYetRequired: PROTECTION_REPORT_ONLY_CHECKS.filter(name => !required.has(name)) };
|
|
1218
1400
|
}
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1401
|
+
if (unread.length > 0)
|
|
1402
|
+
throw new ProtectionUnreadable(unread);
|
|
1221
1403
|
if (missing.length === 0)
|
|
1222
1404
|
missing.push("main has no branch protection or ruleset requiring status checks");
|
|
1223
1405
|
return { effective: false, missing, notYetRequired: [] };
|
|
1224
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
|
+
}
|
|
1225
1415
|
/** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
|
|
1226
1416
|
export async function readUpToDateRequired(run, gh, repo, branch = "main") {
|
|
1227
|
-
|
|
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
|
+
}
|
|
1228
1426
|
return protection === null ? null : protection.effective;
|
|
1229
1427
|
}
|
|
1428
|
+
/** Whether a workflow run is the push of exactly this release tag (its workflow file is the tagged commit's own, on main). */
|
|
1429
|
+
function tagTriggered(run, tag) {
|
|
1430
|
+
return tag !== undefined && run["event"] === "push" && run["branch"] === tag;
|
|
1431
|
+
}
|
|
1230
1432
|
/** Whether a workflow run was triggered on main: a push to main, or a pull request whose every base is main. */
|
|
1231
1433
|
function mainTriggered(run) {
|
|
1232
1434
|
const bases = Array.isArray(run["bases"]) ? run["bases"] : [];
|
package/dist/release-carry.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TreeChange, type VerifiedCommit } from "./commit-extract.js";
|
|
2
|
-
import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
2
|
+
import { type ReleaseCheckPurpose, type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
3
3
|
import { type ExecResult } from "./exec.js";
|
|
4
4
|
import { type ReleaseAuthority, type ReleaseCarry, type Store } from "./store.js";
|
|
5
5
|
/** The journey scripts: tests, by Alex's decision (they carry). */
|
|
@@ -66,6 +66,26 @@ export declare function releaseTermsFor(store: Store, input: {
|
|
|
66
66
|
ok: false;
|
|
67
67
|
why: string;
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
|
|
71
|
+
* outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
|
|
72
|
+
* and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
|
|
73
|
+
* this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
|
|
74
|
+
* infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
|
|
75
|
+
*/
|
|
76
|
+
export declare function releaseCheckPurposeFor(store: Store, input: {
|
|
77
|
+
repo: string;
|
|
78
|
+
version: string;
|
|
79
|
+
branch: string;
|
|
80
|
+
pr: number;
|
|
81
|
+
candidate: string;
|
|
82
|
+
}): {
|
|
83
|
+
ok: true;
|
|
84
|
+
purpose: ReleaseCheckPurpose;
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
why: string;
|
|
88
|
+
};
|
|
69
89
|
/** One carried commit, as the summary and the merge message list it. */
|
|
70
90
|
export type LandedCommit = {
|
|
71
91
|
sha: string;
|
package/dist/release-carry.js
CHANGED
|
@@ -165,6 +165,28 @@ export function releaseTermsFor(store, input) {
|
|
|
165
165
|
expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
|
|
166
166
|
} };
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
|
|
170
|
+
* outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
|
|
171
|
+
* and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
|
|
172
|
+
* this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
|
|
173
|
+
* infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
|
|
174
|
+
*/
|
|
175
|
+
export function releaseCheckPurposeFor(store, input) {
|
|
176
|
+
const gitDir = projectGitDir(input.repo);
|
|
177
|
+
if (gitDir === null)
|
|
178
|
+
return { ok: false, why: "the project's Git directory can't be read" };
|
|
179
|
+
const commit = readVerifiedCommit(gitDir, input.candidate);
|
|
180
|
+
if (!commit.ok)
|
|
181
|
+
return { ok: false, why: commit.message };
|
|
182
|
+
const grant = store.liveVerifyCommand(input.repo);
|
|
183
|
+
if (grant === null)
|
|
184
|
+
return { ok: false, why: "the project has no approved release check" };
|
|
185
|
+
return { ok: true, purpose: {
|
|
186
|
+
version: 1, purpose: "release-check", repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
|
|
187
|
+
approvedSha: input.candidate, approvedTree: commit.value.tree, grant: { id: grant.id, digest: grant.digest }, access: store.projectCheckAccess(input.repo),
|
|
188
|
+
} };
|
|
189
|
+
}
|
|
168
190
|
/**
|
|
169
191
|
* Step 2, read-only: nothing from the candidate is installed, built or run here. Objects come from the project's own
|
|
170
192
|
* Git directory as Toolroll records it, hashed here; the authority is read live.
|