toolroll 0.9.75 → 0.9.77
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 +241 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +36 -8
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/assignment.js +3 -0
- package/dist/batch-checks.js +3 -1
- 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/builder.d.ts +14 -0
- package/dist/builder.js +141 -42
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +267 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +52 -6
- package/dist/check-report.js +193 -8
- package/dist/checkout-cleanup.js +2 -2
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/coding-workspace.js +7 -5
- 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 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -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-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +8 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +6 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.js +4 -1
- package/dist/exec.d.ts +7 -0
- package/dist/exec.js +7 -1
- package/dist/first-run.js +3 -1
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +7 -1
- package/dist/guides.js +4 -3
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +779 -319
- package/dist/planner.js +5 -1
- 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 +23 -3
- package/dist/proof.js +6 -0
- package/dist/pull-request-flow.js +4 -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 +71 -2
- package/dist/release-adapters.js +471 -12
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +68 -0
- package/dist/release-auto.d.ts +184 -0
- package/dist/release-auto.js +612 -0
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.js +77 -14
- package/dist/release-coverage.js +5 -2
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +6 -1
- package/dist/release.d.ts +207 -6
- package/dist/release.js +495 -20
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/review-context.js +8 -6
- package/dist/runner.js +2 -1
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +51 -4
- package/dist/scout.js +5 -1
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +101 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +25 -14
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +15 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +11 -1
- package/dist/store.js +44 -5
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +90 -13
- package/dist/teardown-git.js +1027 -129
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- 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 +77 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +2 -1
package/dist/release-adapters.js
CHANGED
|
@@ -14,12 +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
|
-
import { liveReleaseOf } from "./scope.js";
|
|
23
|
+
import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
|
|
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";
|
|
23
27
|
import { proveCarry } from "./release-carry.js";
|
|
24
28
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
25
29
|
import { deploymentGate } from "./deploy-gate.js";
|
|
@@ -236,6 +240,15 @@ export function releaseAdapters(options) {
|
|
|
236
240
|
}
|
|
237
241
|
};
|
|
238
242
|
const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
|
|
243
|
+
/** gh for every CI and merge-base read (auto-approve-green.md): found only in the system's own folders, never the
|
|
244
|
+
* caller's PATH (a test hands its own). */
|
|
245
|
+
const ciGh = options.tools?.gh ?? releaseTools("").gh;
|
|
246
|
+
const observeCi = async (sha) => {
|
|
247
|
+
const observedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
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 }) }));
|
|
250
|
+
return { head: sha, observedAt, runs, complete: await readSuitesComplete(run, ciGh, releaseRepo().slug, sha) };
|
|
251
|
+
};
|
|
239
252
|
const database = options.database ?? databasePath(process.env, homedir());
|
|
240
253
|
let recorded = options.releaseRepo ?? null;
|
|
241
254
|
/** The recorded repository, read once (chat-management.md §0a.2). */
|
|
@@ -284,7 +297,7 @@ export function releaseAdapters(options) {
|
|
|
284
297
|
return { ok: false, message: "The plane's database isn't here, so the candidate can't be proven." };
|
|
285
298
|
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
286
299
|
try {
|
|
287
|
-
return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))() });
|
|
300
|
+
return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))(), releaseFlow: true });
|
|
288
301
|
}
|
|
289
302
|
catch (error) {
|
|
290
303
|
return { ok: false, message: `The candidate can't be proven: ${error.message}` };
|
|
@@ -360,7 +373,11 @@ export function releaseAdapters(options) {
|
|
|
360
373
|
rmSync(extracted.dir, { recursive: true, force: true });
|
|
361
374
|
}
|
|
362
375
|
};
|
|
363
|
-
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()))
|
|
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 });
|
|
364
381
|
return {
|
|
365
382
|
...(existsSync(database) ? { release } : {}),
|
|
366
383
|
now: options.now ?? (() => new Date()),
|
|
@@ -415,8 +432,6 @@ export function releaseAdapters(options) {
|
|
|
415
432
|
const result = await git("show", `${sha}:${path}`);
|
|
416
433
|
return result.code === 0 ? result.stdout : null;
|
|
417
434
|
},
|
|
418
|
-
async mainHead() { return (await must(git("rev-parse", "refs/remotes/origin/main"), "git rev-parse origin/main")).trim(); },
|
|
419
|
-
async contains(sha, ancestor) { return (await git("merge-base", "--is-ancestor", ancestor, sha)).code === 0; },
|
|
420
435
|
},
|
|
421
436
|
toolroll: {
|
|
422
437
|
async installed() { return { services: serviceRuntimes(), clis: cliRuntimes() }; },
|
|
@@ -559,12 +574,12 @@ export function releaseAdapters(options) {
|
|
|
559
574
|
}));
|
|
560
575
|
return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
|
|
561
576
|
},
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
},
|
|
577
|
+
// 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(run, ciGh, repo, sha); },
|
|
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); },
|
|
568
583
|
async merge(repo, pr, head, message) {
|
|
569
584
|
// The squash commit's message lists every commit that landed after the approval (release-once.md).
|
|
570
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}`])));
|
|
@@ -728,7 +743,10 @@ function personAnswerOf(value) {
|
|
|
728
743
|
* The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
|
|
729
744
|
* lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
|
|
730
745
|
*/
|
|
731
|
-
export function storeReleaseAdapter(open, done, now, runner
|
|
746
|
+
export function storeReleaseAdapter(open, done, now, runner,
|
|
747
|
+
/** Releases that approve themselves (auto-approve-green.md): the plane's own look at CI on a commit, and where the
|
|
748
|
+
* check receipts are kept. Absent: no automatic approval, every release asks a person. */
|
|
749
|
+
auto) {
|
|
732
750
|
const withStore = (body) => { const store = open(); try {
|
|
733
751
|
return body(store);
|
|
734
752
|
}
|
|
@@ -736,6 +754,22 @@ export function storeReleaseAdapter(open, done, now, runner) {
|
|
|
736
754
|
done(store);
|
|
737
755
|
} };
|
|
738
756
|
return {
|
|
757
|
+
...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
|
|
758
|
+
// After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
|
|
759
|
+
...(auto === undefined ? {} : {
|
|
760
|
+
async recheck(taskId) {
|
|
761
|
+
return withStore(store => {
|
|
762
|
+
const answer = recheckReleaseTask(store, auto.evidenceRoot, taskId, now());
|
|
763
|
+
return answer.action === "rerun" ? { action: "rerun", why: answer.why } : answer.action === "pending" ? { action: "pending" } : { action: "stop", class: answer.class, why: answer.why };
|
|
764
|
+
});
|
|
765
|
+
},
|
|
766
|
+
async standing(taskId, run) {
|
|
767
|
+
return withStore(store => {
|
|
768
|
+
const standing = releaseCheckStanding(store, auto.evidenceRoot, { taskId, run });
|
|
769
|
+
return standing.ok ? { ok: true, retried: standing.retried } : { ok: false, why: standing.why, retried: standing.retried };
|
|
770
|
+
});
|
|
771
|
+
},
|
|
772
|
+
}),
|
|
739
773
|
async authority(taskId) {
|
|
740
774
|
return withStore(store => {
|
|
741
775
|
const found = releaseAuthorityOfTask(store, taskId);
|
|
@@ -777,3 +811,428 @@ export function storeReleaseAdapter(open, done, now, runner) {
|
|
|
777
811
|
},
|
|
778
812
|
};
|
|
779
813
|
}
|
|
814
|
+
/** The plane's automatic release approval through a store (release-auto.ts): what `toolroll release` asks. It hands the
|
|
815
|
+
* plane ids only; the plane reads CI itself (`observe`) right before it seals. */
|
|
816
|
+
function autoReleaseAdapter(withStore, now, auto) {
|
|
817
|
+
const reportProblem = () => auto.reportsProtected === true ? null
|
|
818
|
+
: "CI test reports aren't protected from candidate writes, so a person must approve this release";
|
|
819
|
+
return {
|
|
820
|
+
async setting(taskId) {
|
|
821
|
+
return withStore(store => { try {
|
|
822
|
+
const ref = store.lookupRef(taskId);
|
|
823
|
+
return ref?.repo != null && releaseAutoApproves(store, ref.repo);
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return false;
|
|
827
|
+
} });
|
|
828
|
+
},
|
|
829
|
+
async authorizeCheck(input) {
|
|
830
|
+
const problem = reportProblem();
|
|
831
|
+
if (problem !== null)
|
|
832
|
+
return { ok: false, why: problem };
|
|
833
|
+
// The comparison base for what the release changes: GitHub's own merge base of the candidate with the release's
|
|
834
|
+
// base branch (its signed terms), read by the plane — never a task branch or a build's recorded base.
|
|
835
|
+
const branch = withStore(store => store.getScope(input.taskId)?.releaseTerms?.base ?? null);
|
|
836
|
+
if (branch === null)
|
|
837
|
+
return { ok: false, why: "only a release task is approved automatically" };
|
|
838
|
+
let base;
|
|
839
|
+
try {
|
|
840
|
+
base = await auto.mergeBase(branch, input.candidate);
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
return { ok: false, why: `GitHub couldn't say where ${input.candidate.slice(0, 7)} left ${branch} (${String(error?.message ?? error).slice(0, 160)})` };
|
|
844
|
+
}
|
|
845
|
+
return withStore(store => { const answer = authorizeAutoCheck(store, { ...input, base, now: now() }); return answer.ok ? { ok: true } : answer; });
|
|
846
|
+
},
|
|
847
|
+
async checking(taskId) {
|
|
848
|
+
const problem = reportProblem();
|
|
849
|
+
if (problem !== null)
|
|
850
|
+
return { ok: false, why: problem };
|
|
851
|
+
return withStore(store => { const answer = autoReleaseApprovalOf(store, taskId, "auto-check"); return answer.ok ? { ok: true } : { ok: false, why: answer.why }; });
|
|
852
|
+
},
|
|
853
|
+
async approve(input) {
|
|
854
|
+
const unprotected = reportProblem();
|
|
855
|
+
if (unprotected !== null) {
|
|
856
|
+
withStore(store => giveUpAutoApproval(store, { taskId: input.taskId, why: unprotected, now: now() }));
|
|
857
|
+
return { ok: false, why: unprotected };
|
|
858
|
+
}
|
|
859
|
+
let ci = null, problem = "";
|
|
860
|
+
// A short network failure is asked again a few times; GitHub not answering is never a pass.
|
|
861
|
+
for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
|
|
862
|
+
try {
|
|
863
|
+
ci = await auto.observe(input.candidate);
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
problem = String(error?.message ?? error).slice(0, 200);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (ci === null) {
|
|
870
|
+
const why = `GitHub CI on ${input.candidate.slice(0, 7)} couldn't be read (${problem})`;
|
|
871
|
+
withStore(store => giveUpAutoApproval(store, { taskId: input.taskId, why, now: now() }));
|
|
872
|
+
return { ok: false, why };
|
|
873
|
+
}
|
|
874
|
+
const observed = ci;
|
|
875
|
+
let upToDate;
|
|
876
|
+
try {
|
|
877
|
+
upToDate = await auto.upToDateRequired();
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
upToDate = null;
|
|
881
|
+
}
|
|
882
|
+
return withStore(store => sealAutoGreen(store, { ...input, ci: observed, mainUpToDate: upToDate, evidenceRoot: auto.evidenceRoot, now: now() }));
|
|
883
|
+
},
|
|
884
|
+
async giveUp(taskId, why) { withStore(store => giveUpAutoApproval(store, { taskId, why, now: now() })); },
|
|
885
|
+
async ciRunning(taskId, candidate) { withStore(store => noteCiRunning(store, { taskId, candidate, now: now() })); },
|
|
886
|
+
async ciPassed(taskId, candidate, ask) {
|
|
887
|
+
// Reconcile the plane's record, not journal flags; without a pending row there is nothing to lift.
|
|
888
|
+
if (!withStore(store => releaseCiHoldOf(store, taskId) !== null || releaseCiRunningOf(store, taskId) !== null))
|
|
889
|
+
return { ok: true };
|
|
890
|
+
// The plane reads CI itself; a short network failure is asked again a few times, and unreadable is never a pass.
|
|
891
|
+
let ci = null, problem = "";
|
|
892
|
+
for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
|
|
893
|
+
try {
|
|
894
|
+
ci = await auto.observe(candidate);
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
problem = String(error?.message ?? error).slice(0, 200);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (ci === null)
|
|
901
|
+
return { ok: false, why: `GitHub CI on ${candidate.slice(0, 7)} couldn't be read (${problem})` };
|
|
902
|
+
const observed = ci;
|
|
903
|
+
return withStore(store => liftCiHold(store, { taskId, candidate, ci: observed, ask, now: now() }));
|
|
904
|
+
},
|
|
905
|
+
async checkFailed(taskId, why) { withStore(store => noteAutoCheckFailed(store, { taskId, why, now: now() })); },
|
|
906
|
+
async released(taskId, tag) { withStore(store => noteAutoRelease(store, { taskId, tag, now: now() })); },
|
|
907
|
+
async holdPublish(taskId, why) { withStore(store => holdAutoPublish(store, { taskId, why, now: now() })); },
|
|
908
|
+
async publishHeld(taskId) { return withStore(store => autoPublishHeld(store, taskId)); },
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/** The environment every CI read runs gh with: no caller PATH, host, config folder or proxy override. */
|
|
912
|
+
function ciEnv() {
|
|
913
|
+
const kept = Object.fromEntries(["HOME", "LANG", "LC_ALL", "TMPDIR", "USER", "LOGNAME", "GH_TOKEN", "GITHUB_TOKEN"].flatMap(name => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
|
|
914
|
+
return { ...kept, PATH: "/usr/bin:/bin", GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" };
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Every check run GitHub holds for one commit — every attempt, not only the latest (`filter=all`; GitHub's default
|
|
918
|
+
* lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
|
|
919
|
+
* first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
|
|
920
|
+
*/
|
|
921
|
+
export async function readCheckRuns(run, gh, repo, sha) {
|
|
922
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
923
|
+
throw new Error("not a repository and commit GitHub can be asked about");
|
|
924
|
+
const out = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/commits/${sha}/check-runs?filter=all&per_page=100`,
|
|
925
|
+
"--jq", ".check_runs[] | {id, name, status, conclusion, app: .app.slug, suite: .check_suite.id, head: .head_sha}"], { cwd: "/", env: ciEnv() }), "gh api check-runs");
|
|
926
|
+
// Which workflow each check suite is a run of (GitHub's workflow-run API): a newer run of a workflow supersedes its
|
|
927
|
+
// older runs on this commit (release.ts prChecksVerdict).
|
|
928
|
+
const flows = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`,
|
|
929
|
+
"--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");
|
|
930
|
+
// 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
|
|
931
|
+
// base changed (GitHub's timeline), so a run made against another base can't later read as a run into main.
|
|
932
|
+
const retargeted = new Map();
|
|
933
|
+
const everRetargeted = async (pr) => {
|
|
934
|
+
const known = retargeted.get(pr);
|
|
935
|
+
if (known !== undefined)
|
|
936
|
+
return known;
|
|
937
|
+
let changed = true;
|
|
938
|
+
try {
|
|
939
|
+
const events = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/issues/${pr}/timeline?per_page=100`, "--jq", ".[] | .event"], { cwd: "/", env: ciEnv() });
|
|
940
|
+
changed = events.code !== 0 || events.stdout.split("\n").some(line => line.trim() === "base_ref_changed");
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
changed = true;
|
|
944
|
+
}
|
|
945
|
+
retargeted.set(pr, changed);
|
|
946
|
+
return changed;
|
|
947
|
+
};
|
|
948
|
+
const workflowOf = new Map();
|
|
949
|
+
// Each suite's workflow file: a required check counts only from the workflow that defines it (release.ts).
|
|
950
|
+
const pathOf = new Map();
|
|
951
|
+
for (const line of flows.split("\n").filter(Boolean)) {
|
|
952
|
+
const one = object(JSON.parse(line));
|
|
953
|
+
if (!Number.isSafeInteger(one["suite"]) || !Number.isSafeInteger(one["workflow"]) || one["head"] !== sha || typeof one["path"] !== "string" || one["path"] === "")
|
|
954
|
+
throw new Error("GitHub answered a workflow run this can't read");
|
|
955
|
+
const known = workflowOf.get(Number(one["suite"]));
|
|
956
|
+
if (known !== undefined && (known !== Number(one["workflow"]) || pathOf.get(Number(one["suite"])) !== one["path"]))
|
|
957
|
+
throw new Error("GitHub named two workflows for one check suite");
|
|
958
|
+
workflowOf.set(Number(one["suite"]), Number(one["workflow"]));
|
|
959
|
+
// Only a run on a pull request into main, or a push to main, binds its workflow file: a pull request into another
|
|
960
|
+
// branch reads that branch's workflow and join verifier, which the candidate may control (security scan of the
|
|
961
|
+
// join). Any other run keeps no path, so no required check counts from it.
|
|
962
|
+
// Each pull request the run belongs to must be this repository's own (a number from another repository names a
|
|
963
|
+
// different pull request here): any other refuses the run.
|
|
964
|
+
const prs = (Array.isArray(one["prs"]) ? one["prs"] : []).map(pr => (pr !== null && typeof pr === "object" ? pr : {}));
|
|
965
|
+
const ours = prs.length > 0 && prs.every(pr => Number.isSafeInteger(pr["number"]) && pr["repo"] === `https://api.github.com/repos/${repo}`);
|
|
966
|
+
const untouched = one["event"] !== "pull_request" || (ours && !(await Promise.all(prs.map(pr => everRetargeted(Number(pr["number"]))))).some(Boolean));
|
|
967
|
+
if (mainTriggered(one) && untouched)
|
|
968
|
+
pathOf.set(Number(one["suite"]), String(one["path"]));
|
|
969
|
+
}
|
|
970
|
+
const runs = out.split("\n").filter(Boolean).map(line => {
|
|
971
|
+
const one = object(JSON.parse(line));
|
|
972
|
+
if (!Number.isSafeInteger(one["id"]) || typeof one["name"] !== "string" || typeof one["status"] !== "string")
|
|
973
|
+
throw new Error("GitHub answered a check run this can't read");
|
|
974
|
+
const suite = Number.isSafeInteger(one["suite"]) ? Number(one["suite"]) : null;
|
|
975
|
+
return { id: Number(one["id"]), name: one["name"], status: one["status"], conclusion: textOr(one["conclusion"]), app: textOr(one["app"]),
|
|
976
|
+
suite, head: textOr(one["head"]), workflow: suite === null ? null : workflowOf.get(suite) ?? null, workflowPath: suite === null ? null : pathOf.get(suite) ?? null };
|
|
977
|
+
});
|
|
978
|
+
if (new Set(runs.map(one => one.id)).size !== runs.length)
|
|
979
|
+
throw new Error("GitHub answered the same check run twice");
|
|
980
|
+
// Each unsuccessful GitHub Actions attempt: whether GitHub's own job record (a check run's id is its job's) shows it
|
|
981
|
+
// ending before any of the workflow's own steps ran. Only that ever counts as the runner's failure; unreadable is no.
|
|
982
|
+
for (const one of runs) {
|
|
983
|
+
if (one.app !== "github-actions" || one.status !== "completed" || one.conclusion === "success" || one.conclusion === "skipped" || one.conclusion === "neutral")
|
|
984
|
+
continue;
|
|
985
|
+
one.beforeTests = await readJobBeforeTests(run, gh, repo, one.id);
|
|
986
|
+
}
|
|
987
|
+
return runs.sort((a, b) => a.id - b.id);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Whether a finished, unsuccessful job ended before any of the workflow's own steps ran: every step after GitHub's own
|
|
991
|
+
* first one ("Set up job", always number 1) never ran (no conclusion, or skipped) — leaving out only GitHub's own
|
|
992
|
+
* closing "Complete job" when it is the last step. Step numbers and outcomes are the runner's records; nothing the
|
|
993
|
+
* candidate prints is read. A job with no steps never reached one. Anything else (a step that ran, even the checkout)
|
|
994
|
+
* is not confirmed infrastructure.
|
|
995
|
+
*/
|
|
996
|
+
export function failedBeforeTests(job) {
|
|
997
|
+
if (job.status !== "completed" || job.conclusion === "success" || job.conclusion === null)
|
|
998
|
+
return false;
|
|
999
|
+
const steps = [...job.steps].sort((a, b) => a.number - b.number);
|
|
1000
|
+
if (new Set(steps.map(one => one.number)).size !== steps.length)
|
|
1001
|
+
return false;
|
|
1002
|
+
const last = steps.at(-1);
|
|
1003
|
+
const own = steps.filter(one => one.number > 1 && !(one === last && one.name === "Complete job" && steps.length > 1));
|
|
1004
|
+
return own.every(one => one.status !== "in_progress" && (one.conclusion === null || one.conclusion === "skipped"));
|
|
1005
|
+
}
|
|
1006
|
+
/** GitHub's job record for one check run (its job), read strictly: null when it can't be read whole. */
|
|
1007
|
+
export async function readJobBeforeTests(run, gh, repo, id) {
|
|
1008
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !Number.isSafeInteger(id) || id <= 0)
|
|
1009
|
+
return null;
|
|
1010
|
+
let out;
|
|
1011
|
+
try {
|
|
1012
|
+
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() });
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
if (out.code !== 0)
|
|
1018
|
+
return null;
|
|
1019
|
+
let job;
|
|
1020
|
+
try {
|
|
1021
|
+
job = object(JSON.parse(out.stdout));
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
const steps = Array.isArray(job["steps"]) ? job["steps"].map(object) : null;
|
|
1027
|
+
if (job["id"] !== id || typeof job["status"] !== "string" || steps === null)
|
|
1028
|
+
return null;
|
|
1029
|
+
if (steps.some(one => !Number.isSafeInteger(one["number"]) || typeof one["status"] !== "string" || typeof one["name"] !== "string"))
|
|
1030
|
+
return null;
|
|
1031
|
+
return failedBeforeTests({ id, status: String(job["status"]), conclusion: textOr(job["conclusion"]),
|
|
1032
|
+
steps: steps.map(one => ({ number: Number(one["number"]), name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) })) });
|
|
1033
|
+
}
|
|
1034
|
+
/** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
|
|
1035
|
+
export const CHECK_SUITE_CEILING = 1000;
|
|
1036
|
+
export async function readSuitesComplete(run, gh, repo, sha) {
|
|
1037
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
1038
|
+
return false;
|
|
1039
|
+
const out = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/commits/${sha}/check-suites?per_page=1`, "--jq", ".total_count"], { cwd: "/", env: ciEnv() });
|
|
1040
|
+
const total = Number(out.stdout.trim());
|
|
1041
|
+
return out.code === 0 && out.stdout.trim() !== "" && Number.isSafeInteger(total) && total >= 0 && total < CHECK_SUITE_CEILING;
|
|
1042
|
+
}
|
|
1043
|
+
/** GitHub's merge base of `sha` with branch `base` (the compare API), read by the plane. */
|
|
1044
|
+
export async function readMergeBase(run, gh, repo, base, sha) {
|
|
1045
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[A-Za-z0-9._/-]+$/.test(base) || base.includes(".."))
|
|
1046
|
+
throw new Error("not a repository, branch and commit GitHub can be asked about");
|
|
1047
|
+
const out = (await must(run(gh, ["api", "--hostname", "github.com", `repos/${repo}/compare/${base}...${sha}`, "--jq", ".merge_base_commit.sha"], { cwd: "/", env: ciEnv() }), "gh api compare")).trim();
|
|
1048
|
+
if (!/^[0-9a-f]{40}$/.test(out))
|
|
1049
|
+
throw new Error("GitHub named no merge base");
|
|
1050
|
+
return out;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* 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,
|
|
1054
|
+
* never from a ref in a checkout a build can write.
|
|
1055
|
+
*/
|
|
1056
|
+
export async function readMainHead(run, gh, repo, branch = "main") {
|
|
1057
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
|
|
1058
|
+
throw new Error("not a repository and branch GitHub can be asked about");
|
|
1059
|
+
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();
|
|
1060
|
+
const match = /^commit ([0-9a-f]{40})$/.exec(out);
|
|
1061
|
+
if (match === null)
|
|
1062
|
+
throw new Error(`GitHub named no commit for ${branch}`);
|
|
1063
|
+
return match[1];
|
|
1064
|
+
}
|
|
1065
|
+
/** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
|
|
1066
|
+
export async function readContains(run, gh, repo, sha, ancestor) {
|
|
1067
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(ancestor))
|
|
1068
|
+
throw new Error("not a repository and commits GitHub can be asked about");
|
|
1069
|
+
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();
|
|
1070
|
+
let answer;
|
|
1071
|
+
try {
|
|
1072
|
+
answer = JSON.parse(out);
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
throw new Error("GitHub's comparison couldn't be read");
|
|
1076
|
+
}
|
|
1077
|
+
if (!Array.isArray(answer) || typeof answer[0] !== "string" || !Number.isSafeInteger(answer[1]))
|
|
1078
|
+
throw new Error("GitHub's comparison couldn't be read");
|
|
1079
|
+
return answer[1] === 0 && (answer[0] === "ahead" || answer[0] === "identical");
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Main's protection, judged for the principal that merges (eval-harness review 2, finding 1; review 1, finding 4): the
|
|
1083
|
+
* plane's own gh, which merges as the same account. A layer holds only when it requires branches to be up to date, names
|
|
1084
|
+
* a non-empty set of required checks covering PROTECTION_MINIMUM_CHECKS, bound to GitHub Actions (app 15368), and
|
|
1085
|
+
* can't be bypassed by that account:
|
|
1086
|
+
* classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
|
|
1087
|
+
* 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; null only when nothing
|
|
1089
|
+
* could be read at all.
|
|
1090
|
+
*/
|
|
1091
|
+
export async function readMainProtection(run, gh, repo, branch = "main") {
|
|
1092
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
|
|
1093
|
+
return null;
|
|
1094
|
+
const missing = [];
|
|
1095
|
+
const held = [];
|
|
1096
|
+
let read = 0;
|
|
1097
|
+
const judge = (where, strict, checks, bound, bypass) => {
|
|
1098
|
+
const own = [];
|
|
1099
|
+
if (!strict)
|
|
1100
|
+
own.push(`${where}: "Require branches to be up to date before merging" is off`);
|
|
1101
|
+
if (checks.length === 0)
|
|
1102
|
+
own.push(`${where}: no required status checks`);
|
|
1103
|
+
else {
|
|
1104
|
+
const lacking = PROTECTION_MINIMUM_CHECKS.filter(name => !checks.includes(name));
|
|
1105
|
+
if (lacking.length > 0)
|
|
1106
|
+
own.push(`${where}: required checks lack ${lacking.join(", ")}`);
|
|
1107
|
+
const unbound = PROTECTION_MINIMUM_CHECKS.filter(name => checks.includes(name) && !bound.includes(name));
|
|
1108
|
+
if (unbound.length > 0)
|
|
1109
|
+
own.push(`${where}: required checks must require GitHub Actions (app 15368) as their source: ${unbound.join(", ")}`);
|
|
1110
|
+
}
|
|
1111
|
+
own.push(...bypass.map(why => `${where}: ${why}`));
|
|
1112
|
+
if (own.length === 0)
|
|
1113
|
+
held.push(bound);
|
|
1114
|
+
else
|
|
1115
|
+
missing.push(...own);
|
|
1116
|
+
};
|
|
1117
|
+
const notProtected = (out) => /HTTP 404|Not Found|not protected|not enabled/i.test(`${out.stderr}\n${out.stdout}`);
|
|
1118
|
+
const names = (list, key) => {
|
|
1119
|
+
if (!Array.isArray(list))
|
|
1120
|
+
return null;
|
|
1121
|
+
const out = [];
|
|
1122
|
+
for (const one of list) {
|
|
1123
|
+
const name = key === "" ? one : object(one)[key];
|
|
1124
|
+
if (typeof name !== "string" || name === "")
|
|
1125
|
+
return null;
|
|
1126
|
+
out.push(name);
|
|
1127
|
+
}
|
|
1128
|
+
return out;
|
|
1129
|
+
};
|
|
1130
|
+
const actionsChecks = (list, appKey) => Array.isArray(list)
|
|
1131
|
+
? list.flatMap(one => { const check = object(one); return check[appKey] === 15368 && typeof check["context"] === "string" ? [check["context"]] : []; }) : [];
|
|
1132
|
+
// Classic branch protection, read whole. Context names alone allow an arbitrary writer to supply their status.
|
|
1133
|
+
const classic = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/branches/${branch}/protection`], { cwd: "/", env: ciEnv() });
|
|
1134
|
+
if (classic.code === 0) {
|
|
1135
|
+
let answer = null;
|
|
1136
|
+
try {
|
|
1137
|
+
answer = JSON.parse(classic.stdout);
|
|
1138
|
+
}
|
|
1139
|
+
catch { /* unreadable below */ }
|
|
1140
|
+
const p = object(answer), checks = p["required_status_checks"];
|
|
1141
|
+
if (answer === null || typeof answer !== "object" || Array.isArray(answer))
|
|
1142
|
+
missing.push("classic protection couldn't be read");
|
|
1143
|
+
else if (checks === undefined || checks === null) {
|
|
1144
|
+
read++;
|
|
1145
|
+
missing.push("classic protection: \"Require status checks to pass before merging\" is off");
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
const rsc = object(checks), contexts = names(rsc["contexts"] ?? [], ""), apps = names(rsc["checks"] ?? [], "context");
|
|
1149
|
+
const admins = p["enforce_admins"];
|
|
1150
|
+
if (contexts === null || apps === null || typeof rsc["strict"] !== "boolean" || admins === undefined || admins === null || typeof object(admins)["enabled"] !== "boolean")
|
|
1151
|
+
missing.push("classic protection couldn't be read");
|
|
1152
|
+
else {
|
|
1153
|
+
read++;
|
|
1154
|
+
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
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
else if (notProtected(classic))
|
|
1159
|
+
read++;
|
|
1160
|
+
else
|
|
1161
|
+
missing.push("classic protection couldn't be read");
|
|
1162
|
+
// Rulesets: every active rule requiring status checks on the branch, then its ruleset's enforcement and bypass list.
|
|
1163
|
+
const rules = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/rules/branches/${branch}`,
|
|
1164
|
+
"--jq", ".[] | select(.type == \"required_status_checks\") | @json"], { cwd: "/", env: ciEnv() });
|
|
1165
|
+
if (rules.code !== 0)
|
|
1166
|
+
missing.push("main's rulesets couldn't be read");
|
|
1167
|
+
else {
|
|
1168
|
+
read++;
|
|
1169
|
+
for (const line of rules.stdout.split("\n").filter(one => one.trim() !== "")) {
|
|
1170
|
+
let rule;
|
|
1171
|
+
try {
|
|
1172
|
+
rule = object(JSON.parse(line));
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
missing.push("a ruleset rule couldn't be read");
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
const id = rule["ruleset_id"], params = object(rule["parameters"]);
|
|
1179
|
+
const checks = names(params["required_status_checks"], "context");
|
|
1180
|
+
if (!Number.isSafeInteger(id) || Number(id) <= 0 || checks === null) {
|
|
1181
|
+
missing.push("a ruleset rule couldn't be read");
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
const where = `ruleset ${id}`;
|
|
1185
|
+
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() });
|
|
1186
|
+
let set = null;
|
|
1187
|
+
try {
|
|
1188
|
+
if (got.code === 0)
|
|
1189
|
+
set = object(JSON.parse(got.stdout));
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
set = null;
|
|
1193
|
+
}
|
|
1194
|
+
if (set === null) {
|
|
1195
|
+
missing.push(`${where}: its settings couldn't be read`);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
const bypass = [];
|
|
1199
|
+
if (set["enforcement"] !== "active")
|
|
1200
|
+
bypass.push("it isn't active");
|
|
1201
|
+
if (set["target"] !== "branch")
|
|
1202
|
+
bypass.push("it doesn't target branches");
|
|
1203
|
+
if (set["current_user_can_bypass"] !== "never")
|
|
1204
|
+
bypass.push(typeof set["current_user_can_bypass"] === "string"
|
|
1205
|
+
? `the account that merges can bypass it (${set["current_user_can_bypass"]})` : "whether the account that merges can bypass it couldn't be read");
|
|
1206
|
+
const actors = set["bypass_actors"];
|
|
1207
|
+
if (!Array.isArray(actors))
|
|
1208
|
+
bypass.push("its bypass list couldn't be read");
|
|
1209
|
+
// Only a deploy key never merges as this account; a role, team, app or organization admin might.
|
|
1210
|
+
else if (actors.some(one => object(one)["actor_type"] !== "DeployKey"))
|
|
1211
|
+
bypass.push("its bypass list names a role, team, app or organization admin");
|
|
1212
|
+
judge(where, params["strict_required_status_checks_policy"] === true, checks, actionsChecks(params["required_status_checks"], "integration_id"), bypass);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (held.length > 0) {
|
|
1216
|
+
const required = new Set(held.flat());
|
|
1217
|
+
return { effective: true, missing: [], notYetRequired: PROTECTION_REPORT_ONLY_CHECKS.filter(name => !required.has(name)) };
|
|
1218
|
+
}
|
|
1219
|
+
if (read === 0)
|
|
1220
|
+
return null;
|
|
1221
|
+
if (missing.length === 0)
|
|
1222
|
+
missing.push("main has no branch protection or ruleset requiring status checks");
|
|
1223
|
+
return { effective: false, missing, notYetRequired: [] };
|
|
1224
|
+
}
|
|
1225
|
+
/** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
|
|
1226
|
+
export async function readUpToDateRequired(run, gh, repo, branch = "main") {
|
|
1227
|
+
const protection = await readMainProtection(run, gh, repo, branch);
|
|
1228
|
+
return protection === null ? null : protection.effective;
|
|
1229
|
+
}
|
|
1230
|
+
/** Whether a workflow run was triggered on main: a push to main, or a pull request whose every base is main. */
|
|
1231
|
+
function mainTriggered(run) {
|
|
1232
|
+
const bases = Array.isArray(run["bases"]) ? run["bases"] : [];
|
|
1233
|
+
if (run["event"] === "push")
|
|
1234
|
+
return run["branch"] === "main";
|
|
1235
|
+
if (run["event"] === "pull_request")
|
|
1236
|
+
return bases.length > 0 && bases.every(base => base === "main");
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
@@ -116,3 +116,49 @@ export declare function outstandingReleaseOperations(store: Store, authority: nu
|
|
|
116
116
|
* superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
|
|
117
117
|
*/
|
|
118
118
|
export declare function releaseSealProblem(store: Store, terms: ReleaseTerms): string | null;
|
|
119
|
+
/** "Release approval: Ask me / Approve automatically when checks pass": a project setting, newest choice wins (a ledger
|
|
120
|
+
* row each); `ask` by default. Turning it on is a person's credential act (a password typed now); off is one click. */
|
|
121
|
+
export declare const RELEASE_APPROVAL_ACTION = "release approval changed";
|
|
122
|
+
/** The plane's record of what proved an automatic seal (`auto-check` or `auto-green`): one ledger row per seal, its
|
|
123
|
+
* id named by the seal's `mode_digest` as `auto:<id>`. Only release-auto.ts writes it. */
|
|
124
|
+
export declare const AUTO_EVIDENCE_ACTION = "release auto-approved";
|
|
125
|
+
/** An automatic seal taken back for good (the setting lowered, a refusal): its outcome names the seal's `auto:<id>`, and
|
|
126
|
+
* that evidence never stands again. */
|
|
127
|
+
export declare const AUTO_WITHDRAWN_ACTION = "release auto-approval withdrawn";
|
|
128
|
+
export type ReleaseApprovalSetting = {
|
|
129
|
+
auto: boolean;
|
|
130
|
+
setBy: string | null;
|
|
131
|
+
at: string | null;
|
|
132
|
+
};
|
|
133
|
+
/** Read live at every use. Fail closed: a missing, unreadable or unknown row is `ask`. */
|
|
134
|
+
export declare function releaseApprovalSetting(store: Store, repo: string): ReleaseApprovalSetting;
|
|
135
|
+
export declare const releaseAutoApproves: (store: Store, repo: string) => boolean;
|
|
136
|
+
/** SQL: the newest setting row for `repo` (an SQL expression) says `auto`. */
|
|
137
|
+
export declare const releaseAutoSql: (repo: string) => string;
|
|
138
|
+
/**
|
|
139
|
+
* SQL over a `task_scope` alias: its automatic seal (`auto-check` or `auto-green`) stands on its own evidence row — this
|
|
140
|
+
* task, this kind, `mode_digest = 'auto:<row id>'`, the row's digest and candidate exactly the sealed ones — on a release
|
|
141
|
+
* task (a candidate and release terms signed in), while the project's setting is on. The approver's standing is read
|
|
142
|
+
* beside it (approval-authority.ts).
|
|
143
|
+
*/
|
|
144
|
+
export declare const autoSealSql: (scope: string, repo: string) => string;
|
|
145
|
+
/** The evidence an automatic seal names, read strictly; null when the row is missing or doesn't match the seal. */
|
|
146
|
+
export type AutoEvidence = {
|
|
147
|
+
id: number;
|
|
148
|
+
kind: "auto-check" | "auto-green";
|
|
149
|
+
taskId: string;
|
|
150
|
+
digest: string;
|
|
151
|
+
candidate: string;
|
|
152
|
+
at: string;
|
|
153
|
+
by: string;
|
|
154
|
+
/** auto-green only: the build whose check passed, its receipt, the CI check runs observed green and when, the base. */
|
|
155
|
+
run: number | null;
|
|
156
|
+
receipt: string | null;
|
|
157
|
+
ci: {
|
|
158
|
+
id: number;
|
|
159
|
+
name: string;
|
|
160
|
+
}[];
|
|
161
|
+
observedAt: string | null;
|
|
162
|
+
base: string | null;
|
|
163
|
+
};
|
|
164
|
+
export declare function autoEvidenceOf(store: Store, taskId: string, modeDigest: string | null | undefined, kind: "auto-check" | "auto-green"): AutoEvidence | null;
|