toolroll 0.9.76 → 0.9.78
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 +273 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +46 -10
- package/dist/assignment.js +3 -0
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +18 -3
- package/dist/builder.js +152 -47
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +295 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +67 -6
- package/dist/check-report.js +254 -9
- package/dist/checkout-cleanup.js +2 -2
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-service.js +4 -1
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.js +5 -0
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +9 -5
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +19 -4
- package/dist/exec.d.ts +9 -0
- package/dist/exec.js +22 -2
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/grant.js +5 -1
- package/dist/guides.js +22 -11
- 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.d.ts +13 -0
- package/dist/operate.js +969 -328
- package/dist/planner.js +2 -2
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +2 -2
- package/dist/proof.js +6 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +80 -2
- package/dist/release-adapters.js +441 -16
- package/dist/release-authority.js +6 -2
- package/dist/release-auto.d.ts +38 -7
- package/dist/release-auto.js +131 -15
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +95 -17
- package/dist/release-coverage.js +5 -2
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +8 -2
- package/dist/release.d.ts +144 -14
- package/dist/release.js +421 -81
- package/dist/review-context.js +8 -6
- 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 +73 -9
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +26 -3
- package/dist/scout.js +2 -2
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +112 -51
- package/dist/server/chat.js +5 -5
- 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/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/request-context.d.ts +8 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/task-views.js +7 -2
- package/dist/server/tasks.js +4 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-sweep.js +1 -1
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +135 -2
- package/dist/store.js +275 -13
- package/dist/surface.js +3 -1
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +52 -3
- package/dist/teardown-git.js +145 -33
- package/dist/test-infra.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +81 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/workspace-revision.js +3 -1
- package/package.json +2 -1
package/dist/release-adapters.js
CHANGED
|
@@ -14,13 +14,16 @@ import { parse as parseYaml } from "yaml";
|
|
|
14
14
|
import { sleep } from "./sleep.js";
|
|
15
15
|
// Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
|
|
16
16
|
const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
17
|
+
import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./release.js";
|
|
17
18
|
import { activeUpdateWork } from "./desktop-update-gate.js";
|
|
18
19
|
import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
|
|
19
20
|
import { findCliLinks, switchCliLinks } from "./release-links.js";
|
|
20
21
|
import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, releaseIssuerGone, takeReleaseLease, } from "./store.js";
|
|
21
22
|
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
22
23
|
import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
|
|
23
|
-
import {
|
|
24
|
+
import { recheckReleaseTask, releaseCheckStanding } from "./check-failure.js";
|
|
25
|
+
import { noteCiRunning, releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
|
|
26
|
+
import { autoPublishHeld, holdAutoPublish, liftCiHold, authorizeAutoCheck, giveUpAutoApproval, noteAutoCheckFailed, noteAutoRelease, releaseAutoApproves, sealAutoGreen } from "./release-auto.js";
|
|
24
27
|
import { proveCarry } from "./release-carry.js";
|
|
25
28
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
26
29
|
import { deploymentGate } from "./deploy-gate.js";
|
|
@@ -242,7 +245,8 @@ export function releaseAdapters(options) {
|
|
|
242
245
|
const ciGh = options.tools?.gh ?? releaseTools("").gh;
|
|
243
246
|
const observeCi = async (sha) => {
|
|
244
247
|
const observedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
245
|
-
const runs = (await readCheckRuns(run, 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
|
|
248
|
+
const runs = (await readCheckRuns(run, 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
|
+
...(one.beforeTests === undefined ? {} : { beforeTests: one.beforeTests }) }));
|
|
246
250
|
return { head: sha, observedAt, runs, complete: await readSuitesComplete(run, ciGh, releaseRepo().slug, sha) };
|
|
247
251
|
};
|
|
248
252
|
const database = options.database ?? databasePath(process.env, homedir());
|
|
@@ -369,7 +373,11 @@ export function releaseAdapters(options) {
|
|
|
369
373
|
rmSync(extracted.dir, { recursive: true, force: true });
|
|
370
374
|
}
|
|
371
375
|
};
|
|
372
|
-
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(run, ciGh, releaseRepo().slug, base, sha),
|
|
376
|
+
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(run, ciGh, releaseRepo().slug, base, sha),
|
|
377
|
+
upToDateRequired: () => readUpToDateRequired(run, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
|
|
378
|
+
// A successful Actions job authenticates the job, not the candidate-writable JSON it uploads. Until the
|
|
379
|
+
// executor protects that reporting channel, production releases must take the person's approval road.
|
|
380
|
+
reportsProtected: false });
|
|
373
381
|
return {
|
|
374
382
|
...(existsSync(database) ? { release } : {}),
|
|
375
383
|
now: options.now ?? (() => new Date()),
|
|
@@ -424,8 +432,6 @@ export function releaseAdapters(options) {
|
|
|
424
432
|
const result = await git("show", `${sha}:${path}`);
|
|
425
433
|
return result.code === 0 ? result.stdout : null;
|
|
426
434
|
},
|
|
427
|
-
async mainHead() { return (await must(git("rev-parse", "refs/remotes/origin/main"), "git rev-parse origin/main")).trim(); },
|
|
428
|
-
async contains(sha, ancestor) { return (await git("merge-base", "--is-ancestor", ancestor, sha)).code === 0; },
|
|
429
435
|
},
|
|
430
436
|
toolroll: {
|
|
431
437
|
async installed() { return { services: serviceRuntimes(), clis: cliRuntimes() }; },
|
|
@@ -569,8 +575,11 @@ export function releaseAdapters(options) {
|
|
|
569
575
|
return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
|
|
570
576
|
},
|
|
571
577
|
// Every attempt (filter=all), with its app, check suite and commit: the release judges each job by its latest attempt.
|
|
572
|
-
async checks(repo, sha) { return readCheckRuns(run, ciGh, repo, sha); },
|
|
578
|
+
async checks(repo, sha, options) { return readCheckRuns(run, ciGh, repo, sha, options); },
|
|
573
579
|
async complete(repo, sha) { return readSuitesComplete(run, ciGh, repo, sha); },
|
|
580
|
+
async mainHead(repo) { return readMainHead(run, ciGh, repo); },
|
|
581
|
+
async contains(repo, sha, ancestor) { return readContains(run, ciGh, repo, sha, ancestor); },
|
|
582
|
+
async upToDateRequired(repo) { return readMainProtection(run, ciGh, repo); },
|
|
574
583
|
async merge(repo, pr, head, message) {
|
|
575
584
|
// The squash commit's message lists every commit that landed after the approval (release-once.md).
|
|
576
585
|
const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`, ...(message === undefined ? [] : ["-f", `commit_message=${message}`])));
|
|
@@ -644,8 +653,16 @@ export function releaseAdapters(options) {
|
|
|
644
653
|
const current = await formulaAt(branch);
|
|
645
654
|
if (current.text.includes(`-${version}.tgz`))
|
|
646
655
|
return { kind: "current" };
|
|
647
|
-
|
|
648
|
-
const
|
|
656
|
+
// Ordering (pipeline-speed.md, P1): a tap already on a later version is never moved back by an older release.
|
|
657
|
+
const named = formulaVersion(current.text);
|
|
658
|
+
if (named !== null && versionAfter(named, version))
|
|
659
|
+
return { kind: "newer", version: named };
|
|
660
|
+
// Only a change from the tap's own branch counts: a fork's pull request of the same branch name never does.
|
|
661
|
+
const open = (await ghJson("gh pr list", "pr", "list", "--repo", tap, "--head", brewBranch(version), "--state", "open", "--json", "number,headRefOid,headRepository,headRepositoryOwner,isCrossRepository"));
|
|
662
|
+
const own = open.filter(one => tapOwnHead(one, tap));
|
|
663
|
+
if (own.length > 1)
|
|
664
|
+
throw new Error(`${tap} has ${own.length} open changes for ${brewBranch(version)}`);
|
|
665
|
+
const pr = own[0];
|
|
649
666
|
return pr === undefined ? { kind: "missing" } : { kind: "proposed", pr: Number(pr["number"]), head: String(pr["headRefOid"]) };
|
|
650
667
|
},
|
|
651
668
|
async propose(version) {
|
|
@@ -655,14 +672,20 @@ export function releaseAdapters(options) {
|
|
|
655
672
|
throw new Error(`downloading ${tarball} answered ${response.status}`);
|
|
656
673
|
const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
|
|
657
674
|
const base = await defaultBranch();
|
|
658
|
-
const
|
|
675
|
+
const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
676
|
+
const current = await formulaAt(baseSha);
|
|
677
|
+
const baseVersion = formulaVersion(current.text);
|
|
678
|
+
if (baseVersion === null || versionAfter(baseVersion, version))
|
|
679
|
+
throw new Error("The tap current version is unreadable or newer; no downgrade was proposed.");
|
|
659
680
|
const next = bumpFormula(current.text, tarball, sha256);
|
|
660
681
|
const branch = brewBranch(version);
|
|
661
|
-
const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
662
682
|
// A branch left by an earlier attempt is reused.
|
|
663
683
|
const made = await gh("api", "-X", "POST", `repos/${tap}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`);
|
|
664
684
|
if (made.code !== 0 && !/Reference already exists/.test(made.stderr + made.stdout))
|
|
665
685
|
throw new Error(`creating ${tap} ${branch}: ${made.stderr.trim()}`);
|
|
686
|
+
// A branch left by an earlier attempt starts again from the base (security scan): none of its history is kept.
|
|
687
|
+
if (made.code !== 0)
|
|
688
|
+
await must(gh("api", "-X", "PATCH", `repos/${tap}/git/refs/heads/${branch}`, "-f", `sha=${baseSha}`, "-F", "force=true"), `resetting ${tap} ${branch}`);
|
|
666
689
|
const onBranch = await formulaAt(branch);
|
|
667
690
|
if (onBranch.text !== next) {
|
|
668
691
|
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}`);
|
|
@@ -671,6 +694,24 @@ export function releaseAdapters(options) {
|
|
|
671
694
|
const opened = (await ghJson("gh pr view", "pr", "view", branch, "--repo", tap, "--json", "number,headRefOid"));
|
|
672
695
|
return { pr: Number(opened["number"]), head: String(opened["headRefOid"]) };
|
|
673
696
|
},
|
|
697
|
+
async verified(pr, head, version) {
|
|
698
|
+
// The change at exactly `head` is the tap's own, and its formula is exactly today's formula with npm's published
|
|
699
|
+
// tarball and that tarball's own sha256: nothing else is ever merged.
|
|
700
|
+
const view = object(await ghJson("gh pr view", "pr", "view", String(pr), "--repo", tap, "--json", "number,headRefOid,headRefName,baseRefName,headRepository,headRepositoryOwner,isCrossRepository,state"));
|
|
701
|
+
const base = await defaultBranch();
|
|
702
|
+
const baseHead = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
703
|
+
// The whole change against the tap's default branch head: its merge base, its commits and every file it touches.
|
|
704
|
+
const compare = object(await ghJson("gh api compare", "api", `repos/${tap}/compare/${encodeURIComponent(baseHead)}...${encodeURIComponent(head)}`));
|
|
705
|
+
const tarball = `https://registry.npmjs.org/toolroll/-/toolroll-${version}.tgz`;
|
|
706
|
+
const response = await get(tarball, { signal: AbortSignal.timeout(60_000) });
|
|
707
|
+
if (!response.ok)
|
|
708
|
+
return { ok: false, why: `downloading ${tarball} answered ${response.status}` };
|
|
709
|
+
const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
|
|
710
|
+
const current = (await formulaAt(baseHead)).text;
|
|
711
|
+
const expected = bumpFormula(current, tarball, sha256);
|
|
712
|
+
const problem = tapChangeProblem({ tap, pr, head, version, formula, defaultBranch: base, baseHead, baseVersion: formulaVersion(current), view, compare, expected, atHead: (await formulaAt(head)).text, sha256 });
|
|
713
|
+
return problem === null ? { ok: true } : { ok: false, why: problem };
|
|
714
|
+
},
|
|
674
715
|
async merge(pr, head) {
|
|
675
716
|
const merged = object(await ghJson(`merging ${tap} #${pr}`, "api", "-X", "PUT", `repos/${tap}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
|
|
676
717
|
if (merged["merged"] !== true || typeof merged["sha"] !== "string")
|
|
@@ -687,6 +728,58 @@ export function releaseAdapters(options) {
|
|
|
687
728
|
}
|
|
688
729
|
}
|
|
689
730
|
const brewBranch = (version) => `toolroll-${version}`;
|
|
731
|
+
/**
|
|
732
|
+
* Why a tap change isn't exactly the formula bump this release proposed (security scan), or null when it is: the tap's
|
|
733
|
+
* own open pull request number `pr` at exactly `head`, from its version branch into the default branch; compared with
|
|
734
|
+
* the default branch's head it is one commit on top of exactly that head, changing exactly one file, the formula; and
|
|
735
|
+
* the formula there is exactly today's with npm's published tarball and that tarball's own sha256.
|
|
736
|
+
*/
|
|
737
|
+
export function tapChangeProblem(input) {
|
|
738
|
+
const { tap, pr, head, version, view, compare } = input;
|
|
739
|
+
// State may have changed since the coordinator checked it. Judge the immutable base used for this comparison.
|
|
740
|
+
if (input.baseVersion === null)
|
|
741
|
+
return `${tap} current version cannot be read`;
|
|
742
|
+
if (versionAfter(input.baseVersion, version))
|
|
743
|
+
return `${tap} already names newer ${input.baseVersion}; refusing ${version}`;
|
|
744
|
+
if (Number(view["number"]) !== pr || view["headRefOid"] !== head || view["headRefName"] !== brewBranch(version) || view["state"] !== "OPEN" || !tapOwnHead(view, tap)) {
|
|
745
|
+
return `${tap} #${pr} isn't the tap's own open change at ${head.slice(0, 7)}`;
|
|
746
|
+
}
|
|
747
|
+
if (view["baseRefName"] !== input.defaultBranch)
|
|
748
|
+
return `${tap} #${pr} merges into ${String(view["baseRefName"])}, not ${input.defaultBranch}`;
|
|
749
|
+
const mergeBase = compare["merge_base_commit"] !== null && typeof compare["merge_base_commit"] === "object" ? compare["merge_base_commit"]["sha"] : undefined;
|
|
750
|
+
if (mergeBase !== input.baseHead)
|
|
751
|
+
return `${tap} #${pr} isn't on ${input.defaultBranch}'s head ${input.baseHead.slice(0, 7)}`;
|
|
752
|
+
if (compare["status"] !== "ahead" || compare["ahead_by"] !== 1 || compare["behind_by"] !== 0)
|
|
753
|
+
return `${tap} #${pr} isn't exactly one commit on ${input.defaultBranch}'s head`;
|
|
754
|
+
const files = Array.isArray(compare["files"]) ? compare["files"] : null;
|
|
755
|
+
if (files === null || files.length !== 1 || files[0]["filename"] !== input.formula || files[0]["status"] !== "modified")
|
|
756
|
+
return `${tap} #${pr} changes more than ${input.formula}`;
|
|
757
|
+
if (input.atHead !== input.expected)
|
|
758
|
+
return `${tap} #${pr}'s formula isn't npm's ${version} tarball with its sha256 ${input.sha256.slice(0, 12)}`;
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
/** Whether a pull request's head is a branch of the tap itself (never a fork's). */
|
|
762
|
+
export function tapOwnHead(pr, tap) {
|
|
763
|
+
const [owner, name] = tap.split("/");
|
|
764
|
+
const repo = pr["headRepository"], who = pr["headRepositoryOwner"];
|
|
765
|
+
const repoName = repo !== null && typeof repo === "object" ? repo["name"] : undefined;
|
|
766
|
+
const ownerLogin = who !== null && typeof who === "object" ? who["login"] : undefined;
|
|
767
|
+
return pr["isCrossRepository"] === false && typeof repoName === "string" && typeof ownerLogin === "string"
|
|
768
|
+
&& repoName.toLowerCase() === (name ?? "").toLowerCase() && ownerLogin.toLowerCase() === (owner ?? "").toLowerCase();
|
|
769
|
+
}
|
|
770
|
+
/** The version a formula's tarball url names (`…/toolroll-<x.y.z>.tgz`), or null. */
|
|
771
|
+
export function formulaVersion(text) {
|
|
772
|
+
const found = /^\s*url "[^"]*-(\d+\.\d+\.\d+)\.tgz"/m.exec(text);
|
|
773
|
+
return found === null ? null : found[1];
|
|
774
|
+
}
|
|
775
|
+
/** Whether version `a` comes after `b` (both x.y.z). */
|
|
776
|
+
export function versionAfter(a, b) {
|
|
777
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
778
|
+
for (let i = 0; i < 3; i++)
|
|
779
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
780
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
690
783
|
/** The events that put checks on a change. */
|
|
691
784
|
const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
|
|
692
785
|
/**
|
|
@@ -746,6 +839,21 @@ auto) {
|
|
|
746
839
|
} };
|
|
747
840
|
return {
|
|
748
841
|
...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
|
|
842
|
+
// After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
|
|
843
|
+
...(auto === undefined ? {} : {
|
|
844
|
+
async recheck(taskId) {
|
|
845
|
+
return withStore(store => {
|
|
846
|
+
const answer = recheckReleaseTask(store, auto.evidenceRoot, taskId, now());
|
|
847
|
+
return answer.action === "rerun" ? { action: "rerun", why: answer.why } : answer.action === "pending" ? { action: "pending" } : { action: "stop", class: answer.class, why: answer.why };
|
|
848
|
+
});
|
|
849
|
+
},
|
|
850
|
+
async standing(taskId, run) {
|
|
851
|
+
return withStore(store => {
|
|
852
|
+
const standing = releaseCheckStanding(store, auto.evidenceRoot, { taskId, run });
|
|
853
|
+
return standing.ok ? { ok: true, retried: standing.retried } : { ok: false, why: standing.why, retried: standing.retried };
|
|
854
|
+
});
|
|
855
|
+
},
|
|
856
|
+
}),
|
|
749
857
|
async authority(taskId) {
|
|
750
858
|
return withStore(store => {
|
|
751
859
|
const found = releaseAuthorityOfTask(store, taskId);
|
|
@@ -790,6 +898,8 @@ auto) {
|
|
|
790
898
|
/** The plane's automatic release approval through a store (release-auto.ts): what `toolroll release` asks. It hands the
|
|
791
899
|
* plane ids only; the plane reads CI itself (`observe`) right before it seals. */
|
|
792
900
|
function autoReleaseAdapter(withStore, now, auto) {
|
|
901
|
+
const reportProblem = () => auto.reportsProtected === true ? null
|
|
902
|
+
: "CI test reports aren't protected from candidate writes, so a person must approve this release";
|
|
793
903
|
return {
|
|
794
904
|
async setting(taskId) {
|
|
795
905
|
return withStore(store => { try {
|
|
@@ -801,6 +911,9 @@ function autoReleaseAdapter(withStore, now, auto) {
|
|
|
801
911
|
} });
|
|
802
912
|
},
|
|
803
913
|
async authorizeCheck(input) {
|
|
914
|
+
const problem = reportProblem();
|
|
915
|
+
if (problem !== null)
|
|
916
|
+
return { ok: false, why: problem };
|
|
804
917
|
// The comparison base for what the release changes: GitHub's own merge base of the candidate with the release's
|
|
805
918
|
// base branch (its signed terms), read by the plane — never a task branch or a build's recorded base.
|
|
806
919
|
const branch = withStore(store => store.getScope(input.taskId)?.releaseTerms?.base ?? null);
|
|
@@ -816,9 +929,17 @@ function autoReleaseAdapter(withStore, now, auto) {
|
|
|
816
929
|
return withStore(store => { const answer = authorizeAutoCheck(store, { ...input, base, now: now() }); return answer.ok ? { ok: true } : answer; });
|
|
817
930
|
},
|
|
818
931
|
async checking(taskId) {
|
|
932
|
+
const problem = reportProblem();
|
|
933
|
+
if (problem !== null)
|
|
934
|
+
return { ok: false, why: problem };
|
|
819
935
|
return withStore(store => { const answer = autoReleaseApprovalOf(store, taskId, "auto-check"); return answer.ok ? { ok: true } : { ok: false, why: answer.why }; });
|
|
820
936
|
},
|
|
821
937
|
async approve(input) {
|
|
938
|
+
const unprotected = reportProblem();
|
|
939
|
+
if (unprotected !== null) {
|
|
940
|
+
withStore(store => giveUpAutoApproval(store, { taskId: input.taskId, why: unprotected, now: now() }));
|
|
941
|
+
return { ok: false, why: unprotected };
|
|
942
|
+
}
|
|
822
943
|
let ci = null, problem = "";
|
|
823
944
|
// A short network failure is asked again a few times; GitHub not answering is never a pass.
|
|
824
945
|
for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
|
|
@@ -835,9 +956,36 @@ function autoReleaseAdapter(withStore, now, auto) {
|
|
|
835
956
|
return { ok: false, why };
|
|
836
957
|
}
|
|
837
958
|
const observed = ci;
|
|
838
|
-
|
|
959
|
+
let upToDate;
|
|
960
|
+
try {
|
|
961
|
+
upToDate = await auto.upToDateRequired();
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
upToDate = null;
|
|
965
|
+
}
|
|
966
|
+
return withStore(store => sealAutoGreen(store, { ...input, ci: observed, mainUpToDate: upToDate, evidenceRoot: auto.evidenceRoot, now: now() }));
|
|
839
967
|
},
|
|
840
968
|
async giveUp(taskId, why) { withStore(store => giveUpAutoApproval(store, { taskId, why, now: now() })); },
|
|
969
|
+
async ciRunning(taskId, candidate) { withStore(store => noteCiRunning(store, { taskId, candidate, now: now() })); },
|
|
970
|
+
async ciPassed(taskId, candidate, ask) {
|
|
971
|
+
// Reconcile the plane's record, not journal flags; without a pending row there is nothing to lift.
|
|
972
|
+
if (!withStore(store => releaseCiHoldOf(store, taskId) !== null || releaseCiRunningOf(store, taskId) !== null))
|
|
973
|
+
return { ok: true };
|
|
974
|
+
// The plane reads CI itself; a short network failure is asked again a few times, and unreadable is never a pass.
|
|
975
|
+
let ci = null, problem = "";
|
|
976
|
+
for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
|
|
977
|
+
try {
|
|
978
|
+
ci = await auto.observe(candidate);
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
problem = String(error?.message ?? error).slice(0, 200);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (ci === null)
|
|
985
|
+
return { ok: false, why: `GitHub CI on ${candidate.slice(0, 7)} couldn't be read (${problem})` };
|
|
986
|
+
const observed = ci;
|
|
987
|
+
return withStore(store => liftCiHold(store, { taskId, candidate, ci: observed, ask, now: now() }));
|
|
988
|
+
},
|
|
841
989
|
async checkFailed(taskId, why) { withStore(store => noteAutoCheckFailed(store, { taskId, why, now: now() })); },
|
|
842
990
|
async released(taskId, tag) { withStore(store => noteAutoRelease(store, { taskId, tag, now: now() })); },
|
|
843
991
|
async holdPublish(taskId, why) { withStore(store => holdAutoPublish(store, { taskId, why, now: now() })); },
|
|
@@ -854,7 +1002,11 @@ function ciEnv() {
|
|
|
854
1002
|
* lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
|
|
855
1003
|
* first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
|
|
856
1004
|
*/
|
|
857
|
-
export async function readCheckRuns(run, gh, repo, sha
|
|
1005
|
+
export async function readCheckRuns(run, gh, repo, sha,
|
|
1006
|
+
/** A release tag (pipeline-speed.md, item 3): a push of exactly this tag binds its workflow file too. */
|
|
1007
|
+
options = {}) {
|
|
1008
|
+
if (options.tag !== undefined && !/^v\d+\.\d+\.\d+$/.test(options.tag))
|
|
1009
|
+
throw new Error("not a release tag");
|
|
858
1010
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
859
1011
|
throw new Error("not a repository and commit GitHub can be asked about");
|
|
860
1012
|
const out = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/commits/${sha}/check-runs?filter=all&per_page=100`,
|
|
@@ -862,16 +1014,46 @@ export async function readCheckRuns(run, gh, repo, sha) {
|
|
|
862
1014
|
// Which workflow each check suite is a run of (GitHub's workflow-run API): a newer run of a workflow supersedes its
|
|
863
1015
|
// older runs on this commit (release.ts prChecksVerdict).
|
|
864
1016
|
const flows = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`,
|
|
865
|
-
"--jq", ".workflow_runs[] | {suite: .check_suite_id, workflow: .workflow_id, head: .head_sha}"], { cwd: "/", env: ciEnv() }), "gh api actions/runs");
|
|
1017
|
+
"--jq", ".workflow_runs[] | {suite: .check_suite_id, workflow: .workflow_id, path: .path, head: .head_sha, event: .event, branch: .head_branch, bases: [(.pull_requests // [])[] | .base.ref], prs: [(.pull_requests // [])[] | {number, repo: .base.repo.url}]}"], { cwd: "/", env: ciEnv() }), "gh api actions/runs");
|
|
1018
|
+
// A pull request's base can be edited after its runs: a run counts only if no pull request it belongs to ever had its
|
|
1019
|
+
// base changed (GitHub's timeline), so a run made against another base can't later read as a run into main.
|
|
1020
|
+
const retargeted = new Map();
|
|
1021
|
+
const everRetargeted = async (pr) => {
|
|
1022
|
+
const known = retargeted.get(pr);
|
|
1023
|
+
if (known !== undefined)
|
|
1024
|
+
return known;
|
|
1025
|
+
let changed = true;
|
|
1026
|
+
try {
|
|
1027
|
+
const events = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/issues/${pr}/timeline?per_page=100`, "--jq", ".[] | .event"], { cwd: "/", env: ciEnv() });
|
|
1028
|
+
changed = events.code !== 0 || events.stdout.split("\n").some(line => line.trim() === "base_ref_changed");
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
changed = true;
|
|
1032
|
+
}
|
|
1033
|
+
retargeted.set(pr, changed);
|
|
1034
|
+
return changed;
|
|
1035
|
+
};
|
|
866
1036
|
const workflowOf = new Map();
|
|
1037
|
+
// Each suite's workflow file: a required check counts only from the workflow that defines it (release.ts).
|
|
1038
|
+
const pathOf = new Map();
|
|
867
1039
|
for (const line of flows.split("\n").filter(Boolean)) {
|
|
868
1040
|
const one = object(JSON.parse(line));
|
|
869
|
-
if (!Number.isSafeInteger(one["suite"]) || !Number.isSafeInteger(one["workflow"]) || one["head"] !== sha)
|
|
1041
|
+
if (!Number.isSafeInteger(one["suite"]) || !Number.isSafeInteger(one["workflow"]) || one["head"] !== sha || typeof one["path"] !== "string" || one["path"] === "")
|
|
870
1042
|
throw new Error("GitHub answered a workflow run this can't read");
|
|
871
1043
|
const known = workflowOf.get(Number(one["suite"]));
|
|
872
|
-
if (known !== undefined && known !== Number(one["workflow"]))
|
|
1044
|
+
if (known !== undefined && (known !== Number(one["workflow"]) || pathOf.get(Number(one["suite"])) !== one["path"]))
|
|
873
1045
|
throw new Error("GitHub named two workflows for one check suite");
|
|
874
1046
|
workflowOf.set(Number(one["suite"]), Number(one["workflow"]));
|
|
1047
|
+
// Only a run on a pull request into main, or a push to main, binds its workflow file: a pull request into another
|
|
1048
|
+
// branch reads that branch's workflow and join verifier, which the candidate may control (security scan of the
|
|
1049
|
+
// join). Any other run keeps no path, so no required check counts from it.
|
|
1050
|
+
// Each pull request the run belongs to must be this repository's own (a number from another repository names a
|
|
1051
|
+
// different pull request here): any other refuses the run.
|
|
1052
|
+
const prs = (Array.isArray(one["prs"]) ? one["prs"] : []).map(pr => (pr !== null && typeof pr === "object" ? pr : {}));
|
|
1053
|
+
const ours = prs.length > 0 && prs.every(pr => Number.isSafeInteger(pr["number"]) && pr["repo"] === `https://api.github.com/repos/${repo}`);
|
|
1054
|
+
const untouched = one["event"] !== "pull_request" || (ours && !(await Promise.all(prs.map(pr => everRetargeted(Number(pr["number"]))))).some(Boolean));
|
|
1055
|
+
if ((options.tag === undefined ? mainTriggered(one) : tagTriggered(one, options.tag)) && untouched)
|
|
1056
|
+
pathOf.set(Number(one["suite"]), String(one["path"]));
|
|
875
1057
|
}
|
|
876
1058
|
const runs = out.split("\n").filter(Boolean).map(line => {
|
|
877
1059
|
const one = object(JSON.parse(line));
|
|
@@ -879,12 +1061,64 @@ export async function readCheckRuns(run, gh, repo, sha) {
|
|
|
879
1061
|
throw new Error("GitHub answered a check run this can't read");
|
|
880
1062
|
const suite = Number.isSafeInteger(one["suite"]) ? Number(one["suite"]) : null;
|
|
881
1063
|
return { id: Number(one["id"]), name: one["name"], status: one["status"], conclusion: textOr(one["conclusion"]), app: textOr(one["app"]),
|
|
882
|
-
suite, head: textOr(one["head"]), workflow: suite === null ? null : workflowOf.get(suite) ?? null };
|
|
1064
|
+
suite, head: textOr(one["head"]), workflow: suite === null ? null : workflowOf.get(suite) ?? null, workflowPath: suite === null ? null : pathOf.get(suite) ?? null };
|
|
883
1065
|
});
|
|
884
1066
|
if (new Set(runs.map(one => one.id)).size !== runs.length)
|
|
885
1067
|
throw new Error("GitHub answered the same check run twice");
|
|
1068
|
+
// Each unsuccessful GitHub Actions attempt: whether GitHub's own job record (a check run's id is its job's) shows it
|
|
1069
|
+
// ending before any of the workflow's own steps ran. Only that ever counts as the runner's failure; unreadable is no.
|
|
1070
|
+
for (const one of runs) {
|
|
1071
|
+
if (one.app !== "github-actions" || one.status !== "completed" || one.conclusion === "success" || one.conclusion === "skipped" || one.conclusion === "neutral")
|
|
1072
|
+
continue;
|
|
1073
|
+
one.beforeTests = await readJobBeforeTests(run, gh, repo, one.id);
|
|
1074
|
+
}
|
|
886
1075
|
return runs.sort((a, b) => a.id - b.id);
|
|
887
1076
|
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Whether a finished, unsuccessful job ended before any of the workflow's own steps ran: every step after GitHub's own
|
|
1079
|
+
* first one ("Set up job", always number 1) never ran (no conclusion, or skipped) — leaving out only GitHub's own
|
|
1080
|
+
* closing "Complete job" when it is the last step. Step numbers and outcomes are the runner's records; nothing the
|
|
1081
|
+
* candidate prints is read. A job with no steps never reached one. Anything else (a step that ran, even the checkout)
|
|
1082
|
+
* is not confirmed infrastructure.
|
|
1083
|
+
*/
|
|
1084
|
+
export function failedBeforeTests(job) {
|
|
1085
|
+
if (job.status !== "completed" || job.conclusion === "success" || job.conclusion === null)
|
|
1086
|
+
return false;
|
|
1087
|
+
const steps = [...job.steps].sort((a, b) => a.number - b.number);
|
|
1088
|
+
if (new Set(steps.map(one => one.number)).size !== steps.length)
|
|
1089
|
+
return false;
|
|
1090
|
+
const last = steps.at(-1);
|
|
1091
|
+
const own = steps.filter(one => one.number > 1 && !(one === last && one.name === "Complete job" && steps.length > 1));
|
|
1092
|
+
return own.every(one => one.status !== "in_progress" && (one.conclusion === null || one.conclusion === "skipped"));
|
|
1093
|
+
}
|
|
1094
|
+
/** GitHub's job record for one check run (its job), read strictly: null when it can't be read whole. */
|
|
1095
|
+
export async function readJobBeforeTests(run, gh, repo, id) {
|
|
1096
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !Number.isSafeInteger(id) || id <= 0)
|
|
1097
|
+
return null;
|
|
1098
|
+
let out;
|
|
1099
|
+
try {
|
|
1100
|
+
out = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/actions/jobs/${id}`, "--jq", "{id, status, conclusion, steps: [(.steps // [])[] | {number, name, status, conclusion}]}"], { cwd: "/", env: ciEnv() });
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
if (out.code !== 0)
|
|
1106
|
+
return null;
|
|
1107
|
+
let job;
|
|
1108
|
+
try {
|
|
1109
|
+
job = object(JSON.parse(out.stdout));
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
const steps = Array.isArray(job["steps"]) ? job["steps"].map(object) : null;
|
|
1115
|
+
if (job["id"] !== id || typeof job["status"] !== "string" || steps === null)
|
|
1116
|
+
return null;
|
|
1117
|
+
if (steps.some(one => !Number.isSafeInteger(one["number"]) || typeof one["status"] !== "string" || typeof one["name"] !== "string"))
|
|
1118
|
+
return null;
|
|
1119
|
+
return failedBeforeTests({ id, status: String(job["status"]), conclusion: textOr(job["conclusion"]),
|
|
1120
|
+
steps: steps.map(one => ({ number: Number(one["number"]), name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) })) });
|
|
1121
|
+
}
|
|
888
1122
|
/** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
|
|
889
1123
|
export const CHECK_SUITE_CEILING = 1000;
|
|
890
1124
|
export async function readSuitesComplete(run, gh, repo, sha) {
|
|
@@ -903,3 +1137,194 @@ export async function readMergeBase(run, gh, repo, base, sha) {
|
|
|
903
1137
|
throw new Error("GitHub named no merge base");
|
|
904
1138
|
return out;
|
|
905
1139
|
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Main's head as GitHub has it now (eval-harness review 1, finding 4): read by the plane's own gh from GitHub's API,
|
|
1142
|
+
* never from a ref in a checkout a build can write.
|
|
1143
|
+
*/
|
|
1144
|
+
export async function readMainHead(run, gh, repo, branch = "main") {
|
|
1145
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
|
|
1146
|
+
throw new Error("not a repository and branch GitHub can be asked about");
|
|
1147
|
+
const out = (await must(run(gh, ["api", "--hostname", "github.com", `repos/${repo}/git/ref/heads/${branch}`, "--jq", ".object.type + \" \" + .object.sha"], { cwd: "/", env: ciEnv() }), "gh api git/ref")).trim();
|
|
1148
|
+
const match = /^commit ([0-9a-f]{40})$/.exec(out);
|
|
1149
|
+
if (match === null)
|
|
1150
|
+
throw new Error(`GitHub named no commit for ${branch}`);
|
|
1151
|
+
return match[1];
|
|
1152
|
+
}
|
|
1153
|
+
/** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
|
|
1154
|
+
export async function readContains(run, gh, repo, sha, ancestor) {
|
|
1155
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(ancestor))
|
|
1156
|
+
throw new Error("not a repository and commits GitHub can be asked about");
|
|
1157
|
+
const out = (await must(run(gh, ["api", "--hostname", "github.com", `repos/${repo}/compare/${ancestor}...${sha}`, "--jq", "[.status, .behind_by] | @json"], { cwd: "/", env: ciEnv() }), "gh api compare")).trim();
|
|
1158
|
+
let answer;
|
|
1159
|
+
try {
|
|
1160
|
+
answer = JSON.parse(out);
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
throw new Error("GitHub's comparison couldn't be read");
|
|
1164
|
+
}
|
|
1165
|
+
if (!Array.isArray(answer) || typeof answer[0] !== "string" || !Number.isSafeInteger(answer[1]))
|
|
1166
|
+
throw new Error("GitHub's comparison couldn't be read");
|
|
1167
|
+
return answer[1] === 0 && (answer[0] === "ahead" || answer[0] === "identical");
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Main's protection, judged for the principal that merges (eval-harness review 2, finding 1; review 1, finding 4): the
|
|
1171
|
+
* plane's own gh, which merges as the same account. A layer holds only when it requires branches to be up to date, names
|
|
1172
|
+
* a non-empty set of required checks covering PROTECTION_MINIMUM_CHECKS, bound to GitHub Actions (app 15368), and
|
|
1173
|
+
* can't be bypassed by that account:
|
|
1174
|
+
* classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
|
|
1175
|
+
* 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; null only when nothing
|
|
1177
|
+
* could be read at all.
|
|
1178
|
+
*/
|
|
1179
|
+
export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
1180
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
|
|
1181
|
+
return null;
|
|
1182
|
+
const missing = [];
|
|
1183
|
+
const held = [];
|
|
1184
|
+
let read = 0;
|
|
1185
|
+
const judge = (where, strict, checks, bound, bypass) => {
|
|
1186
|
+
const own = [];
|
|
1187
|
+
if (!strict)
|
|
1188
|
+
own.push(`${where}: "Require branches to be up to date before merging" is off`);
|
|
1189
|
+
if (checks.length === 0)
|
|
1190
|
+
own.push(`${where}: no required status checks`);
|
|
1191
|
+
else {
|
|
1192
|
+
const lacking = PROTECTION_MINIMUM_CHECKS.filter(name => !checks.includes(name));
|
|
1193
|
+
if (lacking.length > 0)
|
|
1194
|
+
own.push(`${where}: required checks lack ${lacking.join(", ")}`);
|
|
1195
|
+
const unbound = PROTECTION_MINIMUM_CHECKS.filter(name => checks.includes(name) && !bound.includes(name));
|
|
1196
|
+
if (unbound.length > 0)
|
|
1197
|
+
own.push(`${where}: required checks must require GitHub Actions (app 15368) as their source: ${unbound.join(", ")}`);
|
|
1198
|
+
}
|
|
1199
|
+
own.push(...bypass.map(why => `${where}: ${why}`));
|
|
1200
|
+
if (own.length === 0)
|
|
1201
|
+
held.push(bound);
|
|
1202
|
+
else
|
|
1203
|
+
missing.push(...own);
|
|
1204
|
+
};
|
|
1205
|
+
const notProtected = (out) => /HTTP 404|Not Found|not protected|not enabled/i.test(`${out.stderr}\n${out.stdout}`);
|
|
1206
|
+
const names = (list, key) => {
|
|
1207
|
+
if (!Array.isArray(list))
|
|
1208
|
+
return null;
|
|
1209
|
+
const out = [];
|
|
1210
|
+
for (const one of list) {
|
|
1211
|
+
const name = key === "" ? one : object(one)[key];
|
|
1212
|
+
if (typeof name !== "string" || name === "")
|
|
1213
|
+
return null;
|
|
1214
|
+
out.push(name);
|
|
1215
|
+
}
|
|
1216
|
+
return out;
|
|
1217
|
+
};
|
|
1218
|
+
const actionsChecks = (list, appKey) => Array.isArray(list)
|
|
1219
|
+
? list.flatMap(one => { const check = object(one); return check[appKey] === 15368 && typeof check["context"] === "string" ? [check["context"]] : []; }) : [];
|
|
1220
|
+
// Classic branch protection, read whole. Context names alone allow an arbitrary writer to supply their status.
|
|
1221
|
+
const classic = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/branches/${branch}/protection`], { cwd: "/", env: ciEnv() });
|
|
1222
|
+
if (classic.code === 0) {
|
|
1223
|
+
let answer = null;
|
|
1224
|
+
try {
|
|
1225
|
+
answer = JSON.parse(classic.stdout);
|
|
1226
|
+
}
|
|
1227
|
+
catch { /* unreadable below */ }
|
|
1228
|
+
const p = object(answer), checks = p["required_status_checks"];
|
|
1229
|
+
if (answer === null || typeof answer !== "object" || Array.isArray(answer))
|
|
1230
|
+
missing.push("classic protection couldn't be read");
|
|
1231
|
+
else if (checks === undefined || checks === null) {
|
|
1232
|
+
read++;
|
|
1233
|
+
missing.push("classic protection: \"Require status checks to pass before merging\" is off");
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
const rsc = object(checks), contexts = names(rsc["contexts"] ?? [], ""), apps = names(rsc["checks"] ?? [], "context");
|
|
1237
|
+
const admins = p["enforce_admins"];
|
|
1238
|
+
if (contexts === null || apps === null || typeof rsc["strict"] !== "boolean" || admins === undefined || admins === null || typeof object(admins)["enabled"] !== "boolean")
|
|
1239
|
+
missing.push("classic protection couldn't be read");
|
|
1240
|
+
else {
|
|
1241
|
+
read++;
|
|
1242
|
+
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
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
else if (notProtected(classic))
|
|
1247
|
+
read++;
|
|
1248
|
+
else
|
|
1249
|
+
missing.push("classic protection couldn't be read");
|
|
1250
|
+
// Rulesets: every active rule requiring status checks on the branch, then its ruleset's enforcement and bypass list.
|
|
1251
|
+
const rules = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/rules/branches/${branch}`,
|
|
1252
|
+
"--jq", ".[] | select(.type == \"required_status_checks\") | @json"], { cwd: "/", env: ciEnv() });
|
|
1253
|
+
if (rules.code !== 0)
|
|
1254
|
+
missing.push("main's rulesets couldn't be read");
|
|
1255
|
+
else {
|
|
1256
|
+
read++;
|
|
1257
|
+
for (const line of rules.stdout.split("\n").filter(one => one.trim() !== "")) {
|
|
1258
|
+
let rule;
|
|
1259
|
+
try {
|
|
1260
|
+
rule = object(JSON.parse(line));
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
missing.push("a ruleset rule couldn't be read");
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
const id = rule["ruleset_id"], params = object(rule["parameters"]);
|
|
1267
|
+
const checks = names(params["required_status_checks"], "context");
|
|
1268
|
+
if (!Number.isSafeInteger(id) || Number(id) <= 0 || checks === null) {
|
|
1269
|
+
missing.push("a ruleset rule couldn't be read");
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const where = `ruleset ${id}`;
|
|
1273
|
+
const got = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/rulesets/${id}`, "--jq", "{enforcement, target, current_user_can_bypass, bypass_actors} | @json"], { cwd: "/", env: ciEnv() });
|
|
1274
|
+
let set = null;
|
|
1275
|
+
try {
|
|
1276
|
+
if (got.code === 0)
|
|
1277
|
+
set = object(JSON.parse(got.stdout));
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
set = null;
|
|
1281
|
+
}
|
|
1282
|
+
if (set === null) {
|
|
1283
|
+
missing.push(`${where}: its settings couldn't be read`);
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
const bypass = [];
|
|
1287
|
+
if (set["enforcement"] !== "active")
|
|
1288
|
+
bypass.push("it isn't active");
|
|
1289
|
+
if (set["target"] !== "branch")
|
|
1290
|
+
bypass.push("it doesn't target branches");
|
|
1291
|
+
if (set["current_user_can_bypass"] !== "never")
|
|
1292
|
+
bypass.push(typeof set["current_user_can_bypass"] === "string"
|
|
1293
|
+
? `the account that merges can bypass it (${set["current_user_can_bypass"]})` : "whether the account that merges can bypass it couldn't be read");
|
|
1294
|
+
const actors = set["bypass_actors"];
|
|
1295
|
+
if (!Array.isArray(actors))
|
|
1296
|
+
bypass.push("its bypass list couldn't be read");
|
|
1297
|
+
// Only a deploy key never merges as this account; a role, team, app or organization admin might.
|
|
1298
|
+
else if (actors.some(one => object(one)["actor_type"] !== "DeployKey"))
|
|
1299
|
+
bypass.push("its bypass list names a role, team, app or organization admin");
|
|
1300
|
+
judge(where, params["strict_required_status_checks_policy"] === true, checks, actionsChecks(params["required_status_checks"], "integration_id"), bypass);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (held.length > 0) {
|
|
1304
|
+
const required = new Set(held.flat());
|
|
1305
|
+
return { effective: true, missing: [], notYetRequired: PROTECTION_REPORT_ONLY_CHECKS.filter(name => !required.has(name)) };
|
|
1306
|
+
}
|
|
1307
|
+
if (read === 0)
|
|
1308
|
+
return null;
|
|
1309
|
+
if (missing.length === 0)
|
|
1310
|
+
missing.push("main has no branch protection or ruleset requiring status checks");
|
|
1311
|
+
return { effective: false, missing, notYetRequired: [] };
|
|
1312
|
+
}
|
|
1313
|
+
/** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
|
|
1314
|
+
export async function readUpToDateRequired(run, gh, repo, branch = "main") {
|
|
1315
|
+
const protection = await readMainProtection(run, gh, repo, branch);
|
|
1316
|
+
return protection === null ? null : protection.effective;
|
|
1317
|
+
}
|
|
1318
|
+
/** Whether a workflow run is the push of exactly this release tag (its workflow file is the tagged commit's own, on main). */
|
|
1319
|
+
function tagTriggered(run, tag) {
|
|
1320
|
+
return tag !== undefined && run["event"] === "push" && run["branch"] === tag;
|
|
1321
|
+
}
|
|
1322
|
+
/** Whether a workflow run was triggered on main: a push to main, or a pull request whose every base is main. */
|
|
1323
|
+
function mainTriggered(run) {
|
|
1324
|
+
const bases = Array.isArray(run["bases"]) ? run["bases"] : [];
|
|
1325
|
+
if (run["event"] === "push")
|
|
1326
|
+
return run["branch"] === "main";
|
|
1327
|
+
if (run["event"] === "pull_request")
|
|
1328
|
+
return bases.length > 0 && bases.every(base => base === "main");
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
@@ -143,11 +143,15 @@ export const AUTO_EVIDENCE_ACTION = "release auto-approved";
|
|
|
143
143
|
/** An automatic seal taken back for good (the setting lowered, a refusal): its outcome names the seal's `auto:<id>`, and
|
|
144
144
|
* that evidence never stands again. */
|
|
145
145
|
export const AUTO_WITHDRAWN_ACTION = "release auto-approval withdrawn";
|
|
146
|
+
/** The setting rows (no task; this action) through the ledger's (task_id, action, …) index: a seek to the few rows every
|
|
147
|
+
* project has written, never a walk down the project's whole ledger. The approval predicate reads this once per task row
|
|
148
|
+
* (SQLite evaluates every operand of an OR in a result column), so a walk made each Needs you count ~10x slower (0.9.76). */
|
|
149
|
+
const SETTING_INDEX = "INDEXED BY work_completion";
|
|
146
150
|
/** Read live at every use. Fail closed: a missing, unreadable or unknown row is `ask`. */
|
|
147
151
|
export function releaseApprovalSetting(store, repo) {
|
|
148
152
|
let row;
|
|
149
153
|
try {
|
|
150
|
-
row = store.handle.prepare(
|
|
154
|
+
row = store.handle.prepare(`SELECT actor, at, outcome FROM action_ledger ${SETTING_INDEX} WHERE task_id IS NULL AND action = ? AND repo = ? ORDER BY id DESC LIMIT 1`).get(RELEASE_APPROVAL_ACTION, repo);
|
|
151
155
|
}
|
|
152
156
|
catch {
|
|
153
157
|
return { auto: false, setBy: null, at: null };
|
|
@@ -158,7 +162,7 @@ export function releaseApprovalSetting(store, repo) {
|
|
|
158
162
|
}
|
|
159
163
|
export const releaseAutoApproves = (store, repo) => releaseApprovalSetting(store, repo).auto;
|
|
160
164
|
/** SQL: the newest setting row for `repo` (an SQL expression) says `auto`. */
|
|
161
|
-
export const releaseAutoSql = (repo) => `COALESCE((SELECT ra_set.outcome = 'auto' FROM action_ledger ra_set WHERE ra_set.repo = ${repo} AND ra_set.task_id IS NULL
|
|
165
|
+
export const releaseAutoSql = (repo) => `COALESCE((SELECT ra_set.outcome = 'auto' FROM action_ledger ra_set ${SETTING_INDEX} WHERE ra_set.repo = ${repo} AND ra_set.task_id IS NULL
|
|
162
166
|
AND ra_set.action = '${RELEASE_APPROVAL_ACTION}' ORDER BY ra_set.id DESC LIMIT 1), 0)`;
|
|
163
167
|
/**
|
|
164
168
|
* SQL over a `task_scope` alias: its automatic seal (`auto-check` or `auto-green`) stands on its own evidence row — this
|