toolroll 0.9.76 → 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 +192 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +28 -8
- package/dist/assignment.js +3 -0
- 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 +140 -41
- 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/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/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.js +5 -0
- 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/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/grant.js +5 -1
- package/dist/guides.js +4 -3
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +773 -319
- 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 +48 -1
- package/dist/release-adapters.js +343 -10
- 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-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.js +21 -12
- 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 +108 -11
- package/dist/release.js +294 -39
- 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.js +7 -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 +2 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/task-views.js +7 -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 +10 -0
- package/dist/store.js +25 -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 +143 -31
- package/dist/test-infra.js +1 -1
- 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/package.json +2 -1
package/dist/release.js
CHANGED
|
@@ -36,6 +36,7 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
|
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { dirname, join } from "node:path";
|
|
38
38
|
import { personProof } from "./person-proof.js";
|
|
39
|
+
import { RELEASE_CI_RUNNING_WORDS as CI_RUNNING_WORDS } from "./release-ci-hold.js";
|
|
39
40
|
/** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
|
|
40
41
|
* concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
|
|
41
42
|
export const REQUIRED_PR_CHECKS = [
|
|
@@ -44,16 +45,35 @@ export const REQUIRED_PR_CHECKS = [
|
|
|
44
45
|
"linux native containment · delegated cgroup v2",
|
|
45
46
|
"linux fence · sandbox-runtime",
|
|
46
47
|
"Windows Job Object containment",
|
|
48
|
+
// The git lanes (ci.yml job "git"): the declared floor (git-floor.ts) and Debian trixie's 2.47.
|
|
49
|
+
"git 2.41 · debian trixie",
|
|
50
|
+
"git 2.47 · debian trixie",
|
|
47
51
|
];
|
|
52
|
+
/** The workflow file every REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
|
|
53
|
+
* added beside it) never stands for it (Codex review round 1, inherited CI limitation). */
|
|
54
|
+
export const REQUIRED_PR_CHECKS_WORKFLOW = ".github/workflows/ci.yml";
|
|
55
|
+
/**
|
|
56
|
+
* The pull-request checks main's GitHub protection is not yet required to name (eval-harness review 2, finding 1): the
|
|
57
|
+
* git lanes join main's protection once they have run there, so a protection without them is reported, never refused.
|
|
58
|
+
* A later release empties this list; from then on main's protection must name every REQUIRED_PR_CHECKS check.
|
|
59
|
+
*/
|
|
60
|
+
export const PROTECTION_REPORT_ONLY_CHECKS = ["git 2.41 · debian trixie", "git 2.47 · debian trixie"];
|
|
61
|
+
/** The checks main's protection must require at minimum: every required check that isn't report-only. */
|
|
62
|
+
export const PROTECTION_MINIMUM_CHECKS = REQUIRED_PR_CHECKS.filter(name => !PROTECTION_REPORT_ONLY_CHECKS.includes(name));
|
|
48
63
|
/** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
|
|
49
64
|
export const RELEASE_TAG_CHECKS = ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
|
|
50
65
|
export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
|
|
51
66
|
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
|
|
52
67
|
export const RELEASE_LIMITS = { gate: 60, approval: 24 * 60, check: 120, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
|
|
53
68
|
const POLL_MS = 15_000;
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Failure classes replace a flat flake allowance (docs/plans/eval-harness.md; check-failure.ts says the classes): only a
|
|
71
|
+
* failure the plane itself confirmed as infrastructure is ever run again under the same approval — at most once per
|
|
72
|
+
* incident and twice per candidate — for a release check and for a GitHub CI job alike. A failing test, a typecheck, an
|
|
73
|
+
* attack test, a migration: none tolerated. An unexplained failure blocks, and a person decides.
|
|
74
|
+
*/
|
|
75
|
+
export const INFRA_RERUNS_PER_INCIDENT = 1;
|
|
76
|
+
export const INFRA_RERUNS_PER_CANDIDATE = 2;
|
|
57
77
|
/** A stop the release reports: the step it stopped at and why, in words. */
|
|
58
78
|
class Stop extends Error {
|
|
59
79
|
reason;
|
|
@@ -261,6 +281,9 @@ export async function runRelease(options, adapters) {
|
|
|
261
281
|
else
|
|
262
282
|
say(`Resuming the release of ${journal.tag} (${journal.candidate.sha.slice(0, 7)}) at ${journal.step}.`);
|
|
263
283
|
const j = journal;
|
|
284
|
+
// A journal an older coordinator filed ahead of CI: its deploy reads CI fresh too.
|
|
285
|
+
if (j.ciAhead === true && j.ciBeforeDeploy !== true)
|
|
286
|
+
j.ciBeforeDeploy = true;
|
|
264
287
|
if (j.checkout !== options.checkout)
|
|
265
288
|
throw new Stop("checkout", `This release runs in ${j.checkout}; rerun with --repo ${j.checkout}.`);
|
|
266
289
|
save();
|
|
@@ -387,12 +410,14 @@ export async function runRelease(options, adapters) {
|
|
|
387
410
|
? `${what}. Nothing was merged. The deploy had already started, so this commit needs a person's approval again: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`
|
|
388
411
|
: `${what}. Nothing was deployed or merged. This commit needs a person's approval: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
389
412
|
};
|
|
390
|
-
/** An automatic approval's CI allowance:
|
|
391
|
-
|
|
413
|
+
/** An automatic approval's CI allowance: only attempts GitHub shows failing before the tests ran, at most
|
|
414
|
+
* INFRA_RERUNS_PER_CANDIDATE on the commit; a person's approval judges each job by its latest attempt. */
|
|
415
|
+
const autoCap = () => (j.auto?.state === "approved" ? { auto: true } : {});
|
|
392
416
|
/**
|
|
393
417
|
* An automatically approved release stands only while its CI does (auto-approve-green.md): the complete, current
|
|
394
418
|
* checks on the exact commit are read again right before each step that can't be taken back — the deploy, the CLI
|
|
395
|
-
* relink, the merge and the tag. Anything but green (failed, a job re-run and pending,
|
|
419
|
+
* relink, the merge and the tag. Anything but green (failed, a job re-run and pending, an earlier failure GitHub
|
|
420
|
+
* doesn't show as the runner's own) stops the
|
|
396
421
|
* release, revokes its approval and tells the owner; the commit then needs a person.
|
|
397
422
|
*/
|
|
398
423
|
const autoCiHolds = async (before) => {
|
|
@@ -400,9 +425,11 @@ export async function runRelease(options, adapters) {
|
|
|
400
425
|
return;
|
|
401
426
|
const read = await adapters.github.checks(j.github, j.candidate.sha);
|
|
402
427
|
const whole = adapters.github.complete === undefined ? false : await adapters.github.complete(j.github, j.candidate.sha);
|
|
403
|
-
const verdict = whole ? prChecksVerdict(read, j.candidate.sha, REQUIRED_PR_CHECKS, {
|
|
404
|
-
if (verdict.state === "passed")
|
|
428
|
+
const verdict = whole ? prChecksVerdict(read, j.candidate.sha, REQUIRED_PR_CHECKS, { auto: true, workflow: REQUIRED_PR_CHECKS_WORKFLOW }) : { state: "failed", failed: ["GitHub's check history of this commit can't be read whole"] };
|
|
429
|
+
if (verdict.state === "passed") {
|
|
430
|
+
await liftCiHold(false, `The release cannot continue before ${before}`);
|
|
405
431
|
return;
|
|
432
|
+
}
|
|
406
433
|
if (before === "the tag") {
|
|
407
434
|
// Already merged: nothing to revoke. It isn't tagged or published, and a person decides — durably, in the plane.
|
|
408
435
|
const why = `Pull request checks on ${j.candidate.sha.slice(0, 7)} stopped passing after the merge`;
|
|
@@ -472,6 +499,65 @@ export async function runRelease(options, adapters) {
|
|
|
472
499
|
await rel?.auto?.giveUp(j.task.id, why);
|
|
473
500
|
throw new Stop("approval", `Not approved automatically: ${why}. A person approves task ${j.task.id} (\`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or the console), then rerun \`${resume}\`.`);
|
|
474
501
|
};
|
|
502
|
+
/**
|
|
503
|
+
* CI passed on the candidate as the coordinator read it: the plane reads it itself and lifts the approval hold it
|
|
504
|
+
* placed when the task was filed ahead of CI (round 1, finding 4). A refusal stops here; nobody was asked.
|
|
505
|
+
*/
|
|
506
|
+
const liftCiHold = async (ask, nothing) => {
|
|
507
|
+
const lift = rel?.auto?.ciPassed;
|
|
508
|
+
if (lift === undefined)
|
|
509
|
+
return;
|
|
510
|
+
const lifted = await lift(j.task.id, j.candidate.sha, ask);
|
|
511
|
+
if (!lifted.ok)
|
|
512
|
+
throw new Stop("ci-failed", `The plane didn't confirm CI passed on ${j.candidate.sha.slice(0, 7)}: ${lifted.why}. ${nothing}. Rerun \`${resume}\` once every pull request check passes.`);
|
|
513
|
+
};
|
|
514
|
+
/**
|
|
515
|
+
* One look at the pull request's required checks on the exact commit while CI may still run: a failed check stops
|
|
516
|
+
* (`nothing` says what hasn't happened), passed ends the plane's "CI still running" note, pending (or GitHub not
|
|
517
|
+
* answering) answers false. Judged as the ci step judges a person's release (prChecksVerdict, ci.yml, exact head).
|
|
518
|
+
*/
|
|
519
|
+
const ciWhileRunning = async (nothing, ask) => {
|
|
520
|
+
let runs;
|
|
521
|
+
try {
|
|
522
|
+
runs = await adapters.github.checks(j.github, j.candidate.sha);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (transientNetworkError(error))
|
|
526
|
+
return false;
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
const verdict = prChecksVerdict(runs, j.candidate.sha, REQUIRED_PR_CHECKS, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
530
|
+
if (verdict.state === "failed") {
|
|
531
|
+
throw new Stop("ci-failed", `GitHub's checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}. ${nothing}. ${j.approval === null ? "" : `The approval covers ${j.candidate.sha.slice(0, 7)} only. `}Rerun the failed job on GitHub and run \`${resume}\` again, or fix the branch and release the new commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
532
|
+
}
|
|
533
|
+
if (verdict.state !== "passed")
|
|
534
|
+
return false;
|
|
535
|
+
// The plane's note can survive a crash before the journal saved ciAhead. Clear it on the plane's own reading,
|
|
536
|
+
// even on an older/resumed journal whose flags say nothing about CI.
|
|
537
|
+
await liftCiHold(ask, nothing);
|
|
538
|
+
if (j.ciAhead === true) {
|
|
539
|
+
j.ciAhead = false;
|
|
540
|
+
save();
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
};
|
|
544
|
+
/** Every person's release reads exact-head CI at deployment, even if it was green at the gate or an older
|
|
545
|
+
* journal has no ciBeforeDeploy flag. An approval and the release check can outlive that earlier green reading. */
|
|
546
|
+
const personCiBeforeDeploy = async () => {
|
|
547
|
+
if (j.auto?.state === "approved")
|
|
548
|
+
return;
|
|
549
|
+
let said = false;
|
|
550
|
+
await waitFor("ci", `the pull request checks on ${j.candidate.sha.slice(0, 7)} before the deploy`, async () => {
|
|
551
|
+
await watch("deploy");
|
|
552
|
+
if (await ciWhileRunning("Nothing was deployed, merged, tagged or published", false))
|
|
553
|
+
return true;
|
|
554
|
+
if (!said) {
|
|
555
|
+
say(` ${CI_RUNNING_WORDS}`);
|
|
556
|
+
said = true;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
});
|
|
560
|
+
};
|
|
475
561
|
/**
|
|
476
562
|
* The task is approved for `action`: a release task (a carried commit, or any task once the release has an
|
|
477
563
|
* authority) only when the live-release predicate applies to it AND accepts it — fail closed, whatever its own seal
|
|
@@ -501,7 +587,7 @@ export async function runRelease(options, adapters) {
|
|
|
501
587
|
if (gate.result?.level !== "full")
|
|
502
588
|
throw new Stop("check-level", `A Full check is required for this release (run ${gate.result?.run ?? "unknown"}: ${gate.result?.level ?? "unknown"}). Quick and Off checks cannot release a build.`);
|
|
503
589
|
if (gate.result.check !== "passed")
|
|
504
|
-
throw new Stop("check-failed",
|
|
590
|
+
throw new Stop("check-failed", checkFailedWords({ sha: j.candidate.sha, run: gate.result.run, class: null, why: `its check reads ${gate.result.check}`, branch: j.branch, checkout: j.checkout, taskId: j.task.id }));
|
|
505
591
|
};
|
|
506
592
|
/** The commit the passing build recorded: what the deployed runtime is stamped with. */
|
|
507
593
|
const builtHead = () => j.check?.head ?? j.candidate.sha;
|
|
@@ -538,6 +624,11 @@ export async function runRelease(options, adapters) {
|
|
|
538
624
|
}
|
|
539
625
|
}
|
|
540
626
|
}
|
|
627
|
+
// The tree CI tested (the pull request's head, not GitHub's merge commit) is the tree that merges only when the
|
|
628
|
+
// branch already contains main's head: refused here, before anything is filed, and again right before merging.
|
|
629
|
+
await containsMain(adapters, j, "Nothing was filed or sent for approval");
|
|
630
|
+
if (j.intents.gate === undefined)
|
|
631
|
+
await mainProtection(adapters, j, false, "Nothing was filed or sent for approval");
|
|
541
632
|
const existing = await adapters.toolroll.gate(j.task.id);
|
|
542
633
|
// A fresh task this journal never filed has appeared under its id: it isn't ours, and isn't a fresh approval.
|
|
543
634
|
if (existing.exists && j.freshTask === true && j.intents.gate === undefined)
|
|
@@ -549,7 +640,20 @@ export async function runRelease(options, adapters) {
|
|
|
549
640
|
j.task.scopeDigest = existing.scopeDigest;
|
|
550
641
|
}
|
|
551
642
|
else {
|
|
552
|
-
|
|
643
|
+
// The gate only stops on a check that already failed: the task is filed and a person asked while CI runs, and
|
|
644
|
+
// the release check runs beside it (pipeline-speed.md, D2 and "Decision: a person's release asks during
|
|
645
|
+
// CI"). The automatic approval waits for CI (the check step); nothing deploys before it passes (the deploy).
|
|
646
|
+
const settled = await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe), { pendingIsEnough: true });
|
|
647
|
+
// Recorded before the task exists, so its approval card says CI still runs from the first moment.
|
|
648
|
+
if (!settled)
|
|
649
|
+
await rel?.auto?.ciRunning?.(j.task.id, j.candidate.sha);
|
|
650
|
+
j.ciAhead = !settled;
|
|
651
|
+
if (!settled)
|
|
652
|
+
j.ciBeforeDeploy = true;
|
|
653
|
+
save();
|
|
654
|
+
if (!settled) {
|
|
655
|
+
say(` Pull request checks on ${j.candidate.sha.slice(0, 7)} are still running: the release check runs beside them, and nothing deploys before they pass`);
|
|
656
|
+
}
|
|
553
657
|
intend("gate");
|
|
554
658
|
await adapters.repo.sync(j.branch);
|
|
555
659
|
const filed = await adapters.toolroll.fileGate({
|
|
@@ -587,9 +691,19 @@ export async function runRelease(options, adapters) {
|
|
|
587
691
|
say(` Not approved automatically: ${authorized.why}. A person approves it.`);
|
|
588
692
|
}
|
|
589
693
|
}
|
|
694
|
+
// Filed while CI still runs: the person is asked now (Alex, 2026-10-29), and told nothing deploys until CI passes.
|
|
695
|
+
// A check that already failed asks nobody (as at the gate).
|
|
696
|
+
if (j.ciAhead === true)
|
|
697
|
+
await ciWhileRunning("Nothing was deployed or merged", true);
|
|
590
698
|
say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
|
|
699
|
+
if (j.ciAhead === true)
|
|
700
|
+
say(` ${CI_RUNNING_WORDS}`);
|
|
591
701
|
const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
|
|
592
702
|
await watch("dispatch");
|
|
703
|
+
// While asking: a check that failed stops here (nobody approves a red commit), and CI passing ends the card's
|
|
704
|
+
// "still running" note (and lifts a hold an older coordinator placed).
|
|
705
|
+
if (j.ciAhead === true)
|
|
706
|
+
await ciWhileRunning("Nothing was deployed or merged", true);
|
|
593
707
|
const gate = await adapters.toolroll.gate(j.task.id);
|
|
594
708
|
if (gate.scopeDigest !== j.task.scopeDigest)
|
|
595
709
|
throw new Stop("scope-changed", `The scope of ${j.task.id} changed after it was filed; the release only runs on the scope it filed.`);
|
|
@@ -623,6 +737,18 @@ export async function runRelease(options, adapters) {
|
|
|
623
737
|
// A rerun of the same approved commit is on its way: the failure before it is not this release's answer.
|
|
624
738
|
if (gate.pending === true && gate.result?.check !== "passed")
|
|
625
739
|
return null;
|
|
740
|
+
// A failed Full check: the plane says why (its own records only) and reruns it under the same approval only
|
|
741
|
+
// for confirmed infrastructure; every attempt stays in the evidence. Anything else stops, saying its class.
|
|
742
|
+
if (gate.failed === null && gate.result !== null && gate.result.level === "full" && gate.result.check === "failed" && rel?.recheck !== undefined) {
|
|
743
|
+
const answer = await rel.recheck(j.task.id);
|
|
744
|
+
if (answer.action === "pending")
|
|
745
|
+
return null;
|
|
746
|
+
if (answer.action === "rerun") {
|
|
747
|
+
say(` run ${gate.result.run} failed: ${answer.why}. Toolroll confirmed it as its own infrastructure, so the check runs again on ${j.candidate.sha.slice(0, 7)} under the same approval`);
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
throw new Stop("check-failed", checkFailedWords({ sha: j.candidate.sha, run: gate.result.run, class: answer.class, why: answer.why, branch: j.branch, checkout: j.checkout, taskId: j.task.id }));
|
|
751
|
+
}
|
|
626
752
|
if (gate.failed !== null)
|
|
627
753
|
throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`, true);
|
|
628
754
|
if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
|
|
@@ -636,6 +762,17 @@ export async function runRelease(options, adapters) {
|
|
|
636
762
|
fullCheck(gate);
|
|
637
763
|
if (gate.result.worktree === null)
|
|
638
764
|
throw new Stop("worktree", `Run ${gate.result.run} names no checkout to deploy from.`);
|
|
765
|
+
// A pass stands only when no earlier attempt on this commit failed for a reason other than confirmed
|
|
766
|
+
// infrastructure the release reran; what passed only on a retry is said (and an automatic approval refuses it).
|
|
767
|
+
if (rel?.standing !== undefined) {
|
|
768
|
+
const standing = await rel.standing(j.task.id, gate.result.run);
|
|
769
|
+
if (!standing.ok)
|
|
770
|
+
throw new Stop("check-failed", `Run ${gate.result.run} passed on ${j.candidate.sha.slice(0, 7)}, but ${standing.why}: approve this commit again with \`toolroll release ${j.branch} --repo ${j.checkout} --new\`, or fix the branch and release the new commit the same way.`);
|
|
771
|
+
if (standing.retried === null)
|
|
772
|
+
say(` run ${gate.result.run} passed, but its report can't be read, so whether anything passed only on a retry is unknown`);
|
|
773
|
+
else if (standing.retried.length > 0)
|
|
774
|
+
say(` run ${gate.result.run} passed, but only on a retry: ${standing.retried.join(", ")}`);
|
|
775
|
+
}
|
|
639
776
|
return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree, head };
|
|
640
777
|
};
|
|
641
778
|
const result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
@@ -655,6 +792,26 @@ export async function runRelease(options, adapters) {
|
|
|
655
792
|
j.check = result;
|
|
656
793
|
say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
657
794
|
if (j.auto?.state === "checking" && !j.authority) {
|
|
795
|
+
// The check ran beside CI (the gate didn't wait): it waits for every pull request check now. A failure ends the
|
|
796
|
+
// automatic road, and the person's road refuses it too (the approval step waits for CI first).
|
|
797
|
+
if (j.ciAhead === true) {
|
|
798
|
+
try {
|
|
799
|
+
await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe), { nothing: "Nothing was approved" });
|
|
800
|
+
await liftCiHold(false, "Nothing was approved");
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
if (error instanceof Stop && error.reason === "ci-failed") {
|
|
804
|
+
j.auto = { state: "refused", why: error.message };
|
|
805
|
+
j.check = null;
|
|
806
|
+
j.step = "approval";
|
|
807
|
+
save();
|
|
808
|
+
await rel?.auto?.giveUp(j.task.id, error.message.replace(/\.$/, ""));
|
|
809
|
+
}
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
j.ciAhead = false;
|
|
813
|
+
save();
|
|
814
|
+
}
|
|
658
815
|
// The release approval, sealed only now: the plane reads CI on the exact commit itself and proves the check.
|
|
659
816
|
const sealed = rel?.auto === undefined ? { ok: false, why: "this plane can't approve releases automatically" }
|
|
660
817
|
: await rel.auto.approve({ taskId: j.task.id, digest: j.task.scopeDigest, candidate: j.candidate.sha, run: result.run, receipt: result.receipt });
|
|
@@ -714,6 +871,7 @@ export async function runRelease(options, adapters) {
|
|
|
714
871
|
if (runtime !== null && rel !== undefined && j.authority && epoch !== null) {
|
|
715
872
|
const authority = await rel.authority(j.task.id);
|
|
716
873
|
if (authority !== null && authority.state === "open") {
|
|
874
|
+
await personCiBeforeDeploy();
|
|
717
875
|
await watch("deploy");
|
|
718
876
|
// Every deploy reservation, whatever the journal saved: CI must still be green on the exact commit.
|
|
719
877
|
await autoCiHolds("the deploy");
|
|
@@ -727,6 +885,9 @@ export async function runRelease(options, adapters) {
|
|
|
727
885
|
}
|
|
728
886
|
if (runtime === null) {
|
|
729
887
|
// The last moment a moved head restarts; then the deploy reservation freezes the candidate in the store.
|
|
888
|
+
// A person's release: every required check passed on the exact commit, read now (the approval may have come
|
|
889
|
+
// while CI ran). A check that failed stops here, before anything is deployed.
|
|
890
|
+
await personCiBeforeDeploy();
|
|
730
891
|
await watch("deploy");
|
|
731
892
|
await pinned();
|
|
732
893
|
// Every deploy issued, whatever the journal saved (a retry after a rollback included): CI still green.
|
|
@@ -793,7 +954,7 @@ export async function runRelease(options, adapters) {
|
|
|
793
954
|
case "ci": {
|
|
794
955
|
await waitFor("ci", `the pull request checks on ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
795
956
|
await pinned();
|
|
796
|
-
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, autoCap());
|
|
957
|
+
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, { ...autoCap(), workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
797
958
|
if (verdict.state === "failed")
|
|
798
959
|
await ciFailedAfterDeploy(`Pull request checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}`);
|
|
799
960
|
return verdict.state === "passed" ? true : null;
|
|
@@ -816,15 +977,19 @@ export async function runRelease(options, adapters) {
|
|
|
816
977
|
if (rel !== undefined && j.authority)
|
|
817
978
|
await identity("merge", false);
|
|
818
979
|
await pinned();
|
|
819
|
-
// A squash merge only reproduces the gated tree when main hasn't moved past the branch's base.
|
|
820
|
-
await adapters
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
throw new Stop("base-moved", `main moved to ${main.slice(0, 7)}, which ${j.branch} doesn't contain, so the merge wouldn't be the gated tree. Nothing was merged: update the branch and release the new commit with --new.`);
|
|
980
|
+
// A squash merge only reproduces the gated (and CI-tested) tree when main hasn't moved past the branch's base.
|
|
981
|
+
const main = await containsMain(adapters, j, "Nothing was merged");
|
|
982
|
+
// Every release merges only where GitHub itself refuses a branch behind main.
|
|
983
|
+
await mainProtection(adapters, j, j.auto?.state === "approved", "Nothing was merged");
|
|
824
984
|
// The checks again, at the moment of merging: a rerun that failed since must not merge.
|
|
825
|
-
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, autoCap());
|
|
985
|
+
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, { ...autoCap(), workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
826
986
|
if (verdict.state !== "passed")
|
|
827
987
|
await ciFailedAfterDeploy(`Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}`);
|
|
988
|
+
// Main once more, from GitHub, the last thing before the merge is reserved: moved since the comparison, it
|
|
989
|
+
// stops here (finding 4). GitHub's own up-to-date rule refuses the rest of the window.
|
|
990
|
+
const now = await adapters.github.mainHead(j.github);
|
|
991
|
+
if (now !== main)
|
|
992
|
+
throw new Stop("base-moved", `The branch is behind main; update it and push. Main moved to ${now.slice(0, 7)} while ${j.branch} was being merged, so what merges wouldn't be the tree CI tested. Nothing was merged: release the updated commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
828
993
|
let operation;
|
|
829
994
|
if (rel !== undefined && j.authority && epoch !== null) {
|
|
830
995
|
const reserved = await rel.reserve({ authority: j.authority.id, kind: "merge", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
@@ -1047,12 +1212,54 @@ async function deployed(adapters, candidate) {
|
|
|
1047
1212
|
* running are named, and the gate waits for them; ones that never finish within the gate's limit stop here too. GitHub
|
|
1048
1213
|
* not answering is waited out the same way, never taken for a pass. The ci step still checks again before merging.
|
|
1049
1214
|
*/
|
|
1050
|
-
|
|
1215
|
+
/**
|
|
1216
|
+
* The branch must contain main's head as GitHub's API answers now: CI tests the pull request's head commit,
|
|
1217
|
+
* so only a branch that already holds main merges into exactly the tested tree. Refused in plain words otherwise.
|
|
1218
|
+
*/
|
|
1219
|
+
async function containsMain(adapters, j, nothing) {
|
|
1220
|
+
// GitHub's own answers (eval-harness review 1, finding 4): main's head and the comparison are read from its API by
|
|
1221
|
+
// this coordinator, never from refs/remotes/origin/main in a checkout a build can write. The checkout is still synced
|
|
1222
|
+
// for what the gate reads from it later.
|
|
1223
|
+
await adapters.repo.sync(j.branch);
|
|
1224
|
+
const main = await adapters.github.mainHead(j.github);
|
|
1225
|
+
if (!/^[0-9a-f]{40}$/.test(main))
|
|
1226
|
+
throw new Stop("base-moved", `GitHub named no commit for main. ${nothing}.`);
|
|
1227
|
+
if (await adapters.github.contains(j.github, j.candidate.sha, main))
|
|
1228
|
+
return main;
|
|
1229
|
+
throw new Stop("base-moved", `The branch is behind main; update it and push. ${j.branch} at ${j.candidate.sha.slice(0, 7)} doesn't contain main's head ${main.slice(0, 7)}, so what merges wouldn't be the tree CI tested. ${nothing}: release the updated commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* GitHub must refuse a branch behind main for every release (eval-harness review 1, finding 4). A person's approval
|
|
1233
|
+
* cannot make the final main read and merge atomic, so absent or unreadable protection stops that release too.
|
|
1234
|
+
*/
|
|
1235
|
+
async function mainProtection(adapters, j, auto, nothing) {
|
|
1236
|
+
let protection;
|
|
1237
|
+
try {
|
|
1238
|
+
protection = await adapters.github.upToDateRequired(j.github);
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
protection = null;
|
|
1242
|
+
}
|
|
1243
|
+
// The git lanes main's protection doesn't name yet are said, never refused (PROTECTION_REPORT_ONLY_CHECKS).
|
|
1244
|
+
if (protection !== null && protection.notYetRequired.length > 0)
|
|
1245
|
+
adapters.say(` Note: main's protection doesn't require ${protection.notYetRequired.join(", ")} yet; add ${protection.notYetRequired.length === 1 ? "it" : "them"} to its required checks.`);
|
|
1246
|
+
if (protection?.effective === true)
|
|
1247
|
+
return;
|
|
1248
|
+
const lacking = protection === null ? "GitHub's branch protection for main couldn't be read" : `main's protection doesn't hold: ${protection.missing.join("; ")}`;
|
|
1249
|
+
throw new Stop("base-unprotected", `${lacking}, so this release can't merge${auto ? " automatically" : ""}. ${nothing}. Restore effective main protection and try again.`);
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Wait for every required pull request check on the candidate to pass; stop on one that failed. `pendingIsEnough` (the
|
|
1253
|
+
* automatic road's gate, pipeline-speed.md D2): checks still running are no reason to wait — returns false then, true
|
|
1254
|
+
* once they all passed. `nothing` says what hasn't happened yet when a check failed.
|
|
1255
|
+
*/
|
|
1256
|
+
async function earlyChecks(adapters, j, wait, options = {}) {
|
|
1257
|
+
const nothing = options.nothing ?? "Nothing was filed or sent for approval";
|
|
1051
1258
|
const sha = j.candidate.sha.slice(0, 7);
|
|
1052
1259
|
const required = REQUIRED_PR_CHECKS;
|
|
1053
1260
|
let named = "", unread = false;
|
|
1054
1261
|
try {
|
|
1055
|
-
await wait(`the pull request checks on ${sha}`, async () => {
|
|
1262
|
+
return await wait(`the pull request checks on ${sha}`, async () => {
|
|
1056
1263
|
let runs;
|
|
1057
1264
|
try {
|
|
1058
1265
|
runs = await adapters.github.checks(j.github, j.candidate.sha);
|
|
@@ -1063,12 +1270,14 @@ async function earlyChecks(adapters, j, wait) {
|
|
|
1063
1270
|
unread = true;
|
|
1064
1271
|
return null;
|
|
1065
1272
|
}
|
|
1066
|
-
const verdict = prChecksVerdict(runs.filter(run => required.includes(run.name)), j.candidate.sha, required);
|
|
1273
|
+
const verdict = prChecksVerdict(runs.filter(run => required.includes(run.name)), j.candidate.sha, required, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
1067
1274
|
if (verdict.state === "failed") {
|
|
1068
|
-
throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}.
|
|
1275
|
+
throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}. ${nothing}. Fix the branch, then release the new commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\` (or rerun the failed job on GitHub and run this command again).`);
|
|
1069
1276
|
}
|
|
1070
1277
|
if (verdict.state === "passed")
|
|
1071
1278
|
return true;
|
|
1279
|
+
if (options.pendingIsEnough === true)
|
|
1280
|
+
return false;
|
|
1072
1281
|
const now = verdict.waiting.join(", ");
|
|
1073
1282
|
if (now !== named) {
|
|
1074
1283
|
adapters.say(` Pull request checks on ${sha} not finished yet: ${now}.`);
|
|
@@ -1080,7 +1289,7 @@ async function earlyChecks(adapters, j, wait) {
|
|
|
1080
1289
|
catch (error) {
|
|
1081
1290
|
if (!(error instanceof Stop) || error.reason !== "timeout")
|
|
1082
1291
|
throw error;
|
|
1083
|
-
throw new Stop("ci-missing", `Pull request checks on ${sha} didn't all finish in time${named === "" ? (unread ? " (GitHub didn't answer)" : "") : `: ${named}`}.
|
|
1292
|
+
throw new Stop("ci-missing", `Pull request checks on ${sha} didn't all finish in time${named === "" ? (unread ? " (GitHub didn't answer)" : "") : `: ${named}`}. ${nothing}. Start or rerun them on GitHub, then run this command again.`);
|
|
1084
1293
|
}
|
|
1085
1294
|
}
|
|
1086
1295
|
/** The network failures a release's waits ride out: git or gh timing out (or cut off) on the way to GitHub. */
|
|
@@ -1100,8 +1309,10 @@ function networkWords(error) {
|
|
|
1100
1309
|
* each reported attempt must be filed under `head` with a check-run id; a required check counts only from GitHub
|
|
1101
1310
|
* Actions (an app that names a check alike can't stand in for it); attempts are grouped by the job they belong to — the
|
|
1102
1311
|
* app, its check suite (a workflow run, whose re-runs stay in it) and the name — and every group is judged by its
|
|
1103
|
-
* latest attempt; every required check needs a group. With `
|
|
1104
|
-
*
|
|
1312
|
+
* latest attempt; every required check needs a group. With `auto` (an automatic approval), every other failed attempt on
|
|
1313
|
+
* the commit — an earlier attempt, or a job a newer run renamed or dropped — must be one GitHub's own job record shows ending before any workflow step ran (`beforeTests`: confirmed
|
|
1314
|
+
* infrastructure), and at most INFRA_RERUNS_PER_CANDIDATE of them on the commit; any other earlier failure — a test,
|
|
1315
|
+
* a typecheck, or one nothing explains — fails the verdict, so a person decides. The failures it tolerated are returned.
|
|
1105
1316
|
*/
|
|
1106
1317
|
export function prChecksVerdict(runs, head, required, options = {}) {
|
|
1107
1318
|
const failed = [], waiting = [], failures = [];
|
|
@@ -1119,6 +1330,12 @@ export function prChecksVerdict(runs, head, required, options = {}) {
|
|
|
1119
1330
|
failed.push(`${run.name} (reported by ${run.app ?? "an unknown app"}, not GitHub Actions)`);
|
|
1120
1331
|
continue;
|
|
1121
1332
|
}
|
|
1333
|
+
// A required name counts only from the workflow that defines it: a job of the same name in another workflow (or
|
|
1334
|
+
// one whose workflow GitHub doesn't name) fails the verdict rather than stand in for it.
|
|
1335
|
+
if (options.workflow !== undefined && required.includes(run.name) && !workflowIs(run.workflowPath, options.workflow)) {
|
|
1336
|
+
failed.push(`${run.name} (from ${run.workflowPath ?? "a workflow GitHub doesn't name"}, not ${options.workflow})`);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1122
1339
|
if (run.app === "github-actions" && (typeof run.suite !== "number" || !Number.isSafeInteger(run.suite))) {
|
|
1123
1340
|
failed.push(`${run.name} (no workflow run)`);
|
|
1124
1341
|
continue;
|
|
@@ -1143,16 +1360,20 @@ export function prChecksVerdict(runs, head, required, options = {}) {
|
|
|
1143
1360
|
groups.set(key, [...(groups.get(key) ?? []), run]);
|
|
1144
1361
|
}
|
|
1145
1362
|
const judged = new Set();
|
|
1363
|
+
// The attempt each current job stands on (its latest in the current run); every other attempt on the commit is
|
|
1364
|
+
// history, whatever its job is called now.
|
|
1365
|
+
const standing = new Set();
|
|
1146
1366
|
for (const attempts of groups.values()) {
|
|
1147
1367
|
const ordered = [...attempts].sort((a, b) => a.id - b.id);
|
|
1148
1368
|
const now = current.get(workflowOf(ordered[0])) ?? null;
|
|
1149
1369
|
const live = ordered.filter(one => (one.suite ?? null) === now);
|
|
1150
|
-
// A job only an older run had (the newer run no longer has it
|
|
1151
|
-
// required check.
|
|
1370
|
+
// A job only an older run had (the newer run no longer has it, or calls it something else) doesn't stand for a
|
|
1371
|
+
// required check; its failures are still history below.
|
|
1152
1372
|
if (live.length === 0)
|
|
1153
1373
|
continue;
|
|
1154
1374
|
const latest = live.at(-1), name = latest.name;
|
|
1155
1375
|
judged.add(name);
|
|
1376
|
+
standing.add(latest);
|
|
1156
1377
|
if (latest.status !== "completed") {
|
|
1157
1378
|
waiting.push(`${name} (${latest.status})`);
|
|
1158
1379
|
continue;
|
|
@@ -1161,20 +1382,31 @@ export function prChecksVerdict(runs, head, required, options = {}) {
|
|
|
1161
1382
|
failed.push(`${name} (${latest.conclusion ?? "no conclusion"})`);
|
|
1162
1383
|
continue;
|
|
1163
1384
|
}
|
|
1164
|
-
const earlier = ordered.filter(one => one !== latest);
|
|
1165
|
-
// An automatic approval also waits out an earlier attempt of the current run still running (it counts toward the cap).
|
|
1166
|
-
if (options.cap !== undefined && earlier.some(one => (one.suite ?? null) === now && one.status !== "completed")) {
|
|
1167
|
-
waiting.push(`${name} (an earlier attempt still running)`);
|
|
1168
|
-
continue;
|
|
1169
|
-
}
|
|
1170
|
-
// The cap counts every failed attempt of the job, in the current run and in the runs it superseded.
|
|
1171
|
-
const flaked = earlier.filter(one => one.status === "completed" && one.conclusion !== "success");
|
|
1172
|
-
if (options.cap !== undefined && flaked.length > options.cap) {
|
|
1173
|
-
failed.push(`${name} (failed ${flaked.length} times before it passed)`);
|
|
1174
|
-
continue;
|
|
1175
|
-
}
|
|
1176
|
-
failures.push(...flaked.map(one => ({ id: one.id, name, conclusion: one.conclusion })));
|
|
1177
1385
|
}
|
|
1386
|
+
// Every failed attempt on the commit that no current job stands on — an earlier attempt of a job, a job a newer run
|
|
1387
|
+
// renamed, dropped, or never ran again — is classified before anything is superseded (eval-harness review 1, finding
|
|
1388
|
+
// 1): a renamed or vanished job never erases a failure. An automatic approval tolerates only confirmed
|
|
1389
|
+
// infrastructure (GitHub's job record shows the runner failing before any workflow step ran), and waits out any
|
|
1390
|
+
// earlier attempt still running.
|
|
1391
|
+
const history = valid.filter(one => !standing.has(one));
|
|
1392
|
+
if (options.auto === true) {
|
|
1393
|
+
for (const one of history)
|
|
1394
|
+
if (one.status !== "completed")
|
|
1395
|
+
waiting.push(`${one.name} (an earlier attempt still running)`);
|
|
1396
|
+
}
|
|
1397
|
+
const flaked = history.filter(one => one.status === "completed" && one.conclusion !== "success");
|
|
1398
|
+
if (options.auto === true) {
|
|
1399
|
+
const unexplained = new Map();
|
|
1400
|
+
for (const one of flaked)
|
|
1401
|
+
if (one.beforeTests !== true)
|
|
1402
|
+
unexplained.set(one.name, (unexplained.get(one.name) ?? 0) + 1);
|
|
1403
|
+
for (const [name, count] of unexplained)
|
|
1404
|
+
failed.push(`${name} (failed ${count === 1 ? "once" : `${count} times`} on this commit, and GitHub doesn't show its runner failing before the tests ran)`);
|
|
1405
|
+
}
|
|
1406
|
+
const infrastructure = flaked.filter(one => one.beforeTests === true).length;
|
|
1407
|
+
failures.push(...flaked.map(one => ({ id: one.id, name: one.name, conclusion: one.conclusion })));
|
|
1408
|
+
if (options.auto === true && infrastructure > INFRA_RERUNS_PER_CANDIDATE)
|
|
1409
|
+
failed.push(`GitHub's runners failed ${infrastructure} times on this commit before the tests ran (at most ${INFRA_RERUNS_PER_CANDIDATE} are rerun)`);
|
|
1178
1410
|
for (const name of required)
|
|
1179
1411
|
if (!judged.has(name))
|
|
1180
1412
|
waiting.push(`${name} (not started)`);
|
|
@@ -1182,3 +1414,26 @@ export function prChecksVerdict(runs, head, required, options = {}) {
|
|
|
1182
1414
|
return { state: "failed", failed };
|
|
1183
1415
|
return waiting.length > 0 ? { state: "pending", waiting } : { state: "passed", failures };
|
|
1184
1416
|
}
|
|
1417
|
+
/**
|
|
1418
|
+
* What a failed release check means and what happens next, by its class (check-failure.ts), in words. Only confirmed
|
|
1419
|
+
* infrastructure reruns on its own; nothing else is rerun to pass.
|
|
1420
|
+
*/
|
|
1421
|
+
export function checkFailedWords(input) {
|
|
1422
|
+
const short = input.sha.slice(0, 7), again = `\`toolroll release ${input.branch} --repo ${input.checkout} --new\``;
|
|
1423
|
+
switch (input.class) {
|
|
1424
|
+
case "deterministic":
|
|
1425
|
+
return `The Full release check of ${short} failed (run ${input.run}): ${input.why}. That is a failure in the code or its tests, so it isn't run again: fix the branch, then release the new commit with ${again}.`;
|
|
1426
|
+
case "agent-behaviour":
|
|
1427
|
+
return `The Full release check of ${short} failed (run ${input.run}): ${input.why}. Real-model journeys are counted, never rerun until they pass, so a person decides: fix the branch and release the new commit with ${again}, or approve this commit again with ${again}.`;
|
|
1428
|
+
case "infrastructure":
|
|
1429
|
+
return `The Full release check of ${short} failed (run ${input.run}): ${input.why}. It was already run again as often as a commit is (${INFRA_RERUNS_PER_CANDIDATE} times), so a person decides: approve this commit again with ${again}.`;
|
|
1430
|
+
case "unknown":
|
|
1431
|
+
return `The Full release check of ${short} failed (run ${input.run}) with no failure Toolroll could explain: ${input.why}. It isn't rerun to pass. To look into it, \`toolroll task regate ${input.taskId}\` runs it again on the same commit, but a pass then doesn't erase this failure: a person decides, and approves this commit again with ${again}, or fixes the branch and releases the new commit the same way.`;
|
|
1432
|
+
default:
|
|
1433
|
+
return `The Full release check of ${short} did not pass (run ${input.run}): ${input.why}. Fix the branch and release the new commit with ${again}.`;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/** Whether GitHub's workflow-run `path` names exactly `file` (it may carry the ref it ran from, after an `@`). */
|
|
1437
|
+
export function workflowIs(path, file) {
|
|
1438
|
+
return typeof path === "string" && (path === file || path.startsWith(`${file}@`));
|
|
1439
|
+
}
|
package/dist/review-context.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createHash } from "node:crypto";
|
|
|
45
45
|
import { hasForbiddenControls } from "./decision.js";
|
|
46
46
|
import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
47
47
|
import { parseProof } from "./proof.js";
|
|
48
|
+
import { noLazyFetch } from "./teardown-git.js";
|
|
48
49
|
/** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
|
|
49
50
|
* redacted) text; the aggregate is the sum over every stored item. */
|
|
50
51
|
export const LEGACY_REVIEW_CONTEXT_LIMITS = {
|
|
@@ -468,11 +469,12 @@ export function citesSuppliedProvenance(note, supplied) {
|
|
|
468
469
|
return true;
|
|
469
470
|
return false;
|
|
470
471
|
}
|
|
471
|
-
|
|
472
|
+
/** Read flags: --no-lazy-fetch where git has it (the hardened runner refuses a partial clone where it doesn't). */
|
|
473
|
+
const gitRead = () => ["--literal-pathspecs", ...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks"];
|
|
472
474
|
async function treeEntries(git, cwd, revision, paths) {
|
|
473
475
|
if (paths.length === 0)
|
|
474
476
|
return new Map();
|
|
475
|
-
const listed = await git("git", [...
|
|
477
|
+
const listed = await git("git", [...gitRead(), "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
476
478
|
if (listed.code !== 0)
|
|
477
479
|
return null;
|
|
478
480
|
const entries = new Map();
|
|
@@ -570,7 +572,7 @@ async function ancestorPatch(store, git, args, inventory, path) {
|
|
|
570
572
|
if (!base || !head || !SHA1.test(base) || !SHA1.test(head))
|
|
571
573
|
return fail(`run #${runId} has no exact patch endpoints`);
|
|
572
574
|
for (const from of new Set([base, previousHead ?? base])) {
|
|
573
|
-
const ancestor = await git("git", [...
|
|
575
|
+
const ancestor = await git("git", [...gitRead(), "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
|
|
574
576
|
if (ancestor.code !== 0)
|
|
575
577
|
return fail(`run #${runId}'s patch ancestry could not be verified`);
|
|
576
578
|
}
|
|
@@ -780,7 +782,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
780
782
|
const sourceCriteria = new Map((sourceScopeVerified ? sourceScope.acceptance : []).map(one => [one.id, one]));
|
|
781
783
|
// 3. Ancestry: is the source head an ancestor of this head?
|
|
782
784
|
if (sourceHead !== null) {
|
|
783
|
-
const check = await git("git", [...
|
|
785
|
+
const check = await git("git", [...gitRead(), "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
|
|
784
786
|
if (check.code === 0)
|
|
785
787
|
inventory.ancestry = { verified: true, detail: `${sourceHead} is an ancestor of ${args.head}` };
|
|
786
788
|
else if (check.code === 1) {
|
|
@@ -832,7 +834,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
832
834
|
if (generalPaths.length === 0 && sourceScopeVerified) {
|
|
833
835
|
const paths = sourceScope.touches.filter(path => safeContextPath(path.replace(/\/$/, "")) && !/[?*\[\]]/.test(path));
|
|
834
836
|
if (paths.length > 0) {
|
|
835
|
-
const listed = await git("git", [...
|
|
837
|
+
const listed = await git("git", [...gitRead(), "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
|
|
836
838
|
if (listed.code === 0)
|
|
837
839
|
generalPaths.push(...[...new Set(listed.stdout.split("\0").filter(safeContextPath))].sort());
|
|
838
840
|
else
|
|
@@ -949,7 +951,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
949
951
|
gap("aggregate-limit", `${entry.size} bytes would cross the ${REVIEW_CONTEXT_LIMITS.aggregateBytes}-byte aggregate limit`);
|
|
950
952
|
continue;
|
|
951
953
|
}
|
|
952
|
-
const read = await git("git", [...
|
|
954
|
+
const read = await git("git", [...gitRead(), "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
|
|
953
955
|
if (read.code !== 0) {
|
|
954
956
|
gap("capture-failed", `blob ${entry.object} could not be read (git exit ${read.code})`);
|
|
955
957
|
continue;
|
package/dist/runner.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* again. Reaping on the second tells you a *machine* is gone, and everything
|
|
20
20
|
* it was holding — claims and worktrees alike — needs recovering together.
|
|
21
21
|
*/
|
|
22
|
+
import { mintSecret } from "./secret-mint.js";
|
|
22
23
|
import { isAcpProvider } from "./acp-agents.js";
|
|
23
24
|
import { verifyLaunch } from "./acp-attest.js";
|
|
24
25
|
import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
|
|
@@ -352,7 +353,7 @@ export function recoverDead(store, now, livenessMs = DEFAULT_LIVENESS_MS) {
|
|
|
352
353
|
}
|
|
353
354
|
/** 32 bytes of randomness, urlsafe, so it survives being pasted anywhere. */
|
|
354
355
|
function mintToken() {
|
|
355
|
-
return
|
|
356
|
+
return mintSecret(32);
|
|
356
357
|
}
|
|
357
358
|
export function hashToken(token) {
|
|
358
359
|
return createHash("sha256").update(token, "utf8").digest("hex");
|