toolroll 0.9.74 → 0.9.76
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 +80 -0
- package/dist/agent-fence.js +8 -0
- 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.d.ts +3 -0
- package/dist/assignment.js +27 -8
- package/dist/batch-checks.js +3 -1
- package/dist/browser/workspace.js +27 -27
- package/dist/browser-workspace.d.ts +1 -1
- package/dist/builder.js +1 -1
- package/dist/chat-acceptance.d.ts +4 -1
- package/dist/chat-acceptance.js +13 -3
- package/dist/cli.js +1 -1
- package/dist/coding-handoff.js +4 -3
- package/dist/coding-ui.js +2 -2
- package/dist/coding-workspace.js +23 -9
- package/dist/contracts/console-api.d.ts +6 -1
- package/dist/contracts/console-api.js +11 -0
- package/dist/control-ui.d.ts +3 -2
- package/dist/control-ui.js +7 -2
- package/dist/demo.js +5 -3
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +3 -2
- package/dist/desktop-update.d.ts +4 -1
- package/dist/desktop-update.js +43 -9
- package/dist/discover.js +6 -1
- package/dist/first-run.d.ts +8 -5
- package/dist/first-run.js +42 -7
- package/dist/flow-card-task.js +3 -2
- package/dist/flow-unattended.d.ts +2 -0
- package/dist/flow-unattended.js +12 -1
- 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 +3 -1
- package/dist/integrations.d.ts +4 -3
- package/dist/integrations.js +5 -6
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-follow.js +3 -1
- package/dist/openrouter-models.d.ts +5 -2
- package/dist/openrouter-models.js +7 -4
- package/dist/operate.js +9 -1
- package/dist/planner.js +5 -1
- package/dist/project-knowledge.js +1 -1
- package/dist/project-learning.js +65 -7
- package/dist/project-tools.js +19 -1
- package/dist/pull-request-flow.js +4 -0
- package/dist/pull-request-ui.d.ts +1 -1
- package/dist/pull-request-ui.js +5 -1
- package/dist/push.d.ts +2 -0
- package/dist/push.js +24 -19
- package/dist/read-only-request.d.ts +4 -0
- package/dist/read-only-request.js +15 -0
- package/dist/release-adapters.d.ts +24 -2
- package/dist/release-adapters.js +136 -10
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +64 -0
- package/dist/release-auto.d.ts +153 -0
- package/dist/release-auto.js +496 -0
- package/dist/release-cli.js +56 -2
- package/dist/release-coverage.js +1 -1
- package/dist/release.d.ts +105 -1
- package/dist/release.js +229 -9
- package/dist/repository-context.js +5 -2
- package/dist/result-follow-ups.js +23 -2
- package/dist/result-review.js +5 -1
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +44 -3
- package/dist/scout.js +5 -1
- package/dist/serve.js +162 -6
- package/dist/server/agents-live-filed.d.ts +17 -0
- package/dist/server/agents-live-filed.js +97 -0
- package/dist/server/agents-live-room.js +4 -1
- package/dist/server/chat.js +31 -23
- package/dist/server/http.d.ts +5 -0
- package/dist/server/last-checks.d.ts +17 -0
- package/dist/server/last-checks.js +22 -0
- package/dist/server/pages.js +18 -4
- package/dist/server/people-tokens.js +7 -4
- package/dist/server/render-pages.d.ts +2 -1
- package/dist/server/render-pages.js +28 -24
- package/dist/server/render-results.d.ts +11 -2
- package/dist/server/render-results.js +54 -12
- package/dist/server/render-settings.js +3 -3
- package/dist/server/render-tasks.d.ts +10 -0
- package/dist/server/render-tasks.js +30 -17
- package/dist/server/route-table.js +17 -1
- package/dist/server/runtime.d.ts +16 -1
- package/dist/server/settings.js +111 -31
- package/dist/server/task-views.js +8 -1
- package/dist/server/tasks.js +11 -2
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +1 -1
- package/dist/storage.js +2 -1
- package/dist/store.d.ts +4 -1
- package/dist/store.js +52 -13
- package/dist/subagent-work.js +3 -0
- package/dist/task-base.js +1 -1
- package/dist/task-status.d.ts +28 -1
- package/dist/task-status.js +93 -11
- package/dist/teardown-git.d.ts +59 -5
- package/dist/teardown-git.js +1022 -90
- package/dist/telegram-progress.js +4 -4
- package/dist/telegram-status.js +8 -3
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/tool-secret-store.js +8 -0
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +4 -3
- package/dist/toolroll-update.js +10 -9
- package/dist/work-index.js +29 -12
- package/dist/workspace-ui.d.ts +5 -0
- package/dist/workspace-ui.js +38 -10
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +1 -1
package/dist/release.d.ts
CHANGED
|
@@ -42,12 +42,28 @@ export type ReleaseStep = typeof RELEASE_STEPS[number];
|
|
|
42
42
|
type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
|
|
43
43
|
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
|
|
44
44
|
export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
|
|
45
|
-
/** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known
|
|
45
|
+
/** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known; `id` its check
|
|
46
|
+
* run id, `suite` its check suite (one workflow run: its re-runs stay in it) and `head` the commit GitHub filed it
|
|
47
|
+
* under, when known. */
|
|
46
48
|
export type CheckRun = {
|
|
47
49
|
name: string;
|
|
48
50
|
status: string;
|
|
49
51
|
conclusion: string | null;
|
|
50
52
|
app?: string | null;
|
|
53
|
+
id?: number | null;
|
|
54
|
+
suite?: number | null;
|
|
55
|
+
head?: string | null;
|
|
56
|
+
/** The workflow (its id, from GitHub's workflow-run API) that this check's suite is a run of, when known. */
|
|
57
|
+
workflow?: number | null;
|
|
58
|
+
};
|
|
59
|
+
/** How many failed attempts of one CI check, and failed release-check runs, an automatic approval tolerates (flakes on a
|
|
60
|
+
* pinned commit and digest), before a person decides (auto-approve-green.md). */
|
|
61
|
+
export declare const AUTO_FLAKE_CAP = 2;
|
|
62
|
+
/** One failed attempt that a later attempt of the same job passed. */
|
|
63
|
+
export type CheckFailure = {
|
|
64
|
+
id: number;
|
|
65
|
+
name: string;
|
|
66
|
+
conclusion: string | null;
|
|
51
67
|
};
|
|
52
68
|
export type PullRequest = {
|
|
53
69
|
number: number;
|
|
@@ -215,6 +231,64 @@ export type ReleaseAuthorityAdapter = {
|
|
|
215
231
|
* the authority could not be stopped (a stale epoch, a merge already reserved): the coordinator fails closed. */
|
|
216
232
|
needsApproval(authority: number, reason: string, epoch: number): Promise<boolean>;
|
|
217
233
|
operations(authority: number): Promise<OperationView[]>;
|
|
234
|
+
/** Releases that approve themselves when green (docs/plans/auto-approve-green.md): the plane's own seals, each proven
|
|
235
|
+
* again inside the plane. Absent: a plane that can't, and every release asks a person as before. */
|
|
236
|
+
auto?: AutoReleaseAdapter;
|
|
237
|
+
};
|
|
238
|
+
/** The plane's automatic release approval, as the coordinator asks it. Every answer is the plane's own: the coordinator
|
|
239
|
+
* hands it ids, never evidence (the plane reads GitHub CI itself for `approve`). */
|
|
240
|
+
export type AutoReleaseAdapter = {
|
|
241
|
+
/** The project's "Approve automatically when checks pass", read now (off when unreadable). */
|
|
242
|
+
setting(taskId: string): Promise<boolean>;
|
|
243
|
+
/** Seal `auto-check` so the release check runs before anyone approves. */
|
|
244
|
+
authorizeCheck(input: {
|
|
245
|
+
taskId: string;
|
|
246
|
+
digest: string;
|
|
247
|
+
candidate: string;
|
|
248
|
+
}): Promise<{
|
|
249
|
+
ok: true;
|
|
250
|
+
} | {
|
|
251
|
+
ok: false;
|
|
252
|
+
why: string;
|
|
253
|
+
}>;
|
|
254
|
+
/** Whether the `auto-check` seal still stands (the setting on, its evidence, the digest). */
|
|
255
|
+
checking(taskId: string): Promise<{
|
|
256
|
+
ok: true;
|
|
257
|
+
} | {
|
|
258
|
+
ok: false;
|
|
259
|
+
why: string;
|
|
260
|
+
}>;
|
|
261
|
+
/** Observe CI on the candidate and, when everything passed, seal `auto-green` (opening the release's authority). */
|
|
262
|
+
approve(input: {
|
|
263
|
+
taskId: string;
|
|
264
|
+
digest: string;
|
|
265
|
+
candidate: string;
|
|
266
|
+
run: number;
|
|
267
|
+
receipt: string;
|
|
268
|
+
}): Promise<{
|
|
269
|
+
ok: true;
|
|
270
|
+
by: string;
|
|
271
|
+
at: string;
|
|
272
|
+
ci: {
|
|
273
|
+
id: number;
|
|
274
|
+
name: string;
|
|
275
|
+
}[];
|
|
276
|
+
} | {
|
|
277
|
+
ok: false;
|
|
278
|
+
why: string;
|
|
279
|
+
}>;
|
|
280
|
+
/** The automatic road ended without a release: the task asks a person, and the owner is told why. */
|
|
281
|
+
giveUp(taskId: string, why: string): Promise<void>;
|
|
282
|
+
/** The release check failed while checking automatically: the owner is told; the commit may be checked again (a
|
|
283
|
+
* regate of the same commit and digest, within the plane's cap on failed runs). */
|
|
284
|
+
checkFailed(taskId: string, why: string): Promise<void>;
|
|
285
|
+
/** "Released 0.9.x automatically". */
|
|
286
|
+
released(taskId: string, tag: string): Promise<void>;
|
|
287
|
+
/** CI stopped passing after the merge of an automatic release: hold its tag, publication and Homebrew until a person
|
|
288
|
+
* lets it go on (`toolroll release publish-ok`). Durable, in the plane. */
|
|
289
|
+
holdPublish(taskId: string, why: string): Promise<void>;
|
|
290
|
+
/** Why the release's publication waits for a person, or null. Read from the plane, whatever the journal says. */
|
|
291
|
+
publishHeld(taskId: string): Promise<string | null>;
|
|
218
292
|
};
|
|
219
293
|
export type ReleaseAdapters = {
|
|
220
294
|
/** One approval per release (release-once.md); absent on a plane that can't carry. */
|
|
@@ -304,6 +378,9 @@ export type ReleaseAdapters = {
|
|
|
304
378
|
authenticated(): Promise<boolean>;
|
|
305
379
|
pullRequest(repo: string, branch: string): Promise<PullRequest | null>;
|
|
306
380
|
checks(repo: string, sha: string): Promise<CheckRun[]>;
|
|
381
|
+
/** Whether GitHub's check history of this commit can be read whole (fewer check suites than the check-runs
|
|
382
|
+
* endpoint's ceiling). Absent or false: an automatic release fails closed; a person's is unaffected. */
|
|
383
|
+
complete?(repo: string, sha: string): Promise<boolean>;
|
|
307
384
|
merge(repo: string, pr: number, head: string, message?: string): Promise<string>;
|
|
308
385
|
tag(repo: string, tag: string): Promise<string | null>;
|
|
309
386
|
createTag(repo: string, tag: string, sha: string): Promise<void>;
|
|
@@ -406,6 +483,13 @@ export type ReleaseJournal = {
|
|
|
406
483
|
needsApproval?: boolean;
|
|
407
484
|
/** This journal's task was picked fresh after a CI failure after the deploy: an id that never existed, never another. */
|
|
408
485
|
freshTask?: boolean;
|
|
486
|
+
/** The automatic road (auto-approve-green.md): `checking` once the plane sealed `auto-check`, `approved` once it sealed
|
|
487
|
+
* `auto-green`, `refused` (and why) when it wouldn't — then a person approves, as before. */
|
|
488
|
+
auto?: {
|
|
489
|
+
state: "checking" | "approved" | "refused";
|
|
490
|
+
why?: string;
|
|
491
|
+
ci?: number[];
|
|
492
|
+
} | null;
|
|
409
493
|
/** When each step was about to change something outside this machine: what a resume must look for before acting. */
|
|
410
494
|
intents: Partial<Record<ReleaseStep, string>>;
|
|
411
495
|
stopped: {
|
|
@@ -476,4 +560,24 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
|
|
|
476
560
|
export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
|
|
477
561
|
/** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
|
|
478
562
|
export declare function transientNetworkError(error: unknown): boolean;
|
|
563
|
+
/**
|
|
564
|
+
* The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
|
|
565
|
+
* each reported attempt must be filed under `head` with a check-run id; a required check counts only from GitHub
|
|
566
|
+
* Actions (an app that names a check alike can't stand in for it); attempts are grouped by the job they belong to — the
|
|
567
|
+
* app, its check suite (a workflow run, whose re-runs stay in it) and the name — and every group is judged by its
|
|
568
|
+
* latest attempt; every required check needs a group. With `cap`, a group whose earlier attempts failed more than `cap`
|
|
569
|
+
* times fails too (an automatic approval's flake allowance); the failures it tolerated are returned.
|
|
570
|
+
*/
|
|
571
|
+
export declare function prChecksVerdict(runs: readonly CheckRun[], head: string, required: readonly string[], options?: {
|
|
572
|
+
cap?: number;
|
|
573
|
+
}): {
|
|
574
|
+
state: "passed";
|
|
575
|
+
failures: CheckFailure[];
|
|
576
|
+
} | {
|
|
577
|
+
state: "pending";
|
|
578
|
+
waiting: string[];
|
|
579
|
+
} | {
|
|
580
|
+
state: "failed";
|
|
581
|
+
failed: string[];
|
|
582
|
+
};
|
|
479
583
|
export {};
|
package/dist/release.js
CHANGED
|
@@ -51,12 +51,18 @@ export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet",
|
|
|
51
51
|
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
|
|
52
52
|
export const RELEASE_LIMITS = { gate: 60, approval: 24 * 60, check: 120, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
|
|
53
53
|
const POLL_MS = 15_000;
|
|
54
|
+
/** How many failed attempts of one CI check, and failed release-check runs, an automatic approval tolerates (flakes on a
|
|
55
|
+
* pinned commit and digest), before a person decides (auto-approve-green.md). */
|
|
56
|
+
export const AUTO_FLAKE_CAP = 2;
|
|
54
57
|
/** A stop the release reports: the step it stopped at and why, in words. */
|
|
55
58
|
class Stop extends Error {
|
|
56
59
|
reason;
|
|
57
|
-
|
|
60
|
+
build;
|
|
61
|
+
/** `build`: the release build itself failed before its check (not a failed check). */
|
|
62
|
+
constructor(reason, message, build = false) {
|
|
58
63
|
super(message);
|
|
59
64
|
this.reason = reason;
|
|
65
|
+
this.build = build;
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
/** The branch moved before the deploy reservation: carry the approval to the new head, or file it for a person. */
|
|
@@ -367,14 +373,50 @@ export async function runRelease(options, adapters) {
|
|
|
367
373
|
* release needs a person's fresh approval, even for the same commit. It is persisted (the authority stops; the
|
|
368
374
|
* journal says so) before the release stops; `--new` files the commit for a person again.
|
|
369
375
|
*/
|
|
370
|
-
const ciFailedAfterDeploy = async (what) => {
|
|
376
|
+
const ciFailedAfterDeploy = async (what, deploy = "after") => {
|
|
371
377
|
j.needsApproval = true;
|
|
372
378
|
save();
|
|
379
|
+
// An automatic approval never outlives a CI failure: the owner is told, and a person approves again.
|
|
380
|
+
if (j.auto?.state === "approved")
|
|
381
|
+
await rel?.auto?.giveUp(j.task.id, deploy === "after" ? `${what}, after the deploy` : what);
|
|
373
382
|
// Fail closed: the authority must have stopped; otherwise its approval could still carry the commit.
|
|
374
|
-
if (rel !== undefined && j.authority && (epoch === null || !await rel.needsApproval(j.authority.id, "a pull request check failed after the deploy", epoch))) {
|
|
383
|
+
if (rel !== undefined && j.authority && (epoch === null || !await rel.needsApproval(j.authority.id, deploy === "after" ? "a pull request check failed after the deploy" : "a pull request check failed before the deploy", epoch))) {
|
|
375
384
|
throw new Stop("lease", `${what}. Nothing was merged, but this release's approval couldn't be stopped (another coordinator holds it, or a merge is reserved). Check it with \`toolroll release ${j.branch} --repo ${j.checkout}\` before going on.`);
|
|
376
385
|
}
|
|
377
|
-
throw new Stop("ci-failed",
|
|
386
|
+
throw new Stop("ci-failed", deploy === "after"
|
|
387
|
+
? `${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
|
+
: `${what}. Nothing was deployed or merged. This commit needs a person's approval: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
389
|
+
};
|
|
390
|
+
/** An automatic approval's CI allowance: at most AUTO_FLAKE_CAP failed attempts per job; a person's has none. */
|
|
391
|
+
const autoCap = () => (j.auto?.state === "approved" ? { cap: AUTO_FLAKE_CAP } : {});
|
|
392
|
+
/**
|
|
393
|
+
* An automatically approved release stands only while its CI does (auto-approve-green.md): the complete, current
|
|
394
|
+
* 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, past the flake cap) stops the
|
|
396
|
+
* release, revokes its approval and tells the owner; the commit then needs a person.
|
|
397
|
+
*/
|
|
398
|
+
const autoCiHolds = async (before) => {
|
|
399
|
+
if (j.auto?.state !== "approved")
|
|
400
|
+
return;
|
|
401
|
+
const read = await adapters.github.checks(j.github, j.candidate.sha);
|
|
402
|
+
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, { cap: AUTO_FLAKE_CAP }) : { state: "failed", failed: ["GitHub's check history of this commit can't be read whole"] };
|
|
404
|
+
if (verdict.state === "passed")
|
|
405
|
+
return;
|
|
406
|
+
if (before === "the tag") {
|
|
407
|
+
// Already merged: nothing to revoke. It isn't tagged or published, and a person decides — durably, in the plane.
|
|
408
|
+
const why = `Pull request checks on ${j.candidate.sha.slice(0, 7)} stopped passing after the merge`;
|
|
409
|
+
await rel?.auto?.holdPublish(j.task.id, why);
|
|
410
|
+
await rel?.auto?.giveUp(j.task.id, why);
|
|
411
|
+
throw new Stop("ci-failed", `Pull request checks on ${j.candidate.sha.slice(0, 7)} are no longer all passing: ${(verdict.state === "failed" ? verdict.failed : verdict.waiting).join(", ")}. It was merged, but nothing was tagged or published; a person decides how to go on.`);
|
|
412
|
+
}
|
|
413
|
+
await ciFailedAfterDeploy(`Pull request checks on ${j.candidate.sha.slice(0, 7)} are no longer all passing before ${before}: ${(verdict.state === "failed" ? verdict.failed : verdict.waiting).join(", ")}`, before === "the deploy" && j.deploy === null ? "before" : "after");
|
|
414
|
+
};
|
|
415
|
+
/** A publication held for a person (the plane's record) stops the tag, the publication and Homebrew. */
|
|
416
|
+
const publishHold = async () => {
|
|
417
|
+
const held = rel?.auto === undefined ? null : await rel.auto.publishHeld(j.task.id);
|
|
418
|
+
if (held !== null)
|
|
419
|
+
throw new Stop("approval", `${held}. It was merged; nothing more is tagged, published or sent to Homebrew until a person lets it go on: \`toolroll release publish-ok ${j.task.id} --as <you> --token <password>\`, then rerun.`);
|
|
378
420
|
};
|
|
379
421
|
/** identity(), on a plane that keeps release authority; a legacy plane keeps the old rules. */
|
|
380
422
|
const watch = async (action) => { if (rel !== undefined)
|
|
@@ -409,6 +451,7 @@ export async function runRelease(options, adapters) {
|
|
|
409
451
|
j.check = null;
|
|
410
452
|
j.completion = null;
|
|
411
453
|
j.authority = null;
|
|
454
|
+
j.auto = null;
|
|
412
455
|
epoch = null;
|
|
413
456
|
delete j.intents.gate;
|
|
414
457
|
delete j.intents.check;
|
|
@@ -417,6 +460,18 @@ export async function runRelease(options, adapters) {
|
|
|
417
460
|
j.stopped = null;
|
|
418
461
|
save();
|
|
419
462
|
};
|
|
463
|
+
/**
|
|
464
|
+
* The automatic road ended without an approval: the plane takes its `auto-check` back (the task asks a person, its
|
|
465
|
+
* passing check kept) and tells the owner; the release waits at approval for a person, as without the setting.
|
|
466
|
+
*/
|
|
467
|
+
const autoRefused = async (why) => {
|
|
468
|
+
j.auto = { state: "refused", why };
|
|
469
|
+
j.check = null;
|
|
470
|
+
j.step = "approval";
|
|
471
|
+
save();
|
|
472
|
+
await rel?.auto?.giveUp(j.task.id, why);
|
|
473
|
+
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
|
+
};
|
|
420
475
|
/**
|
|
421
476
|
* The task is approved for `action`: a release task (a carried commit, or any task once the release has an
|
|
422
477
|
* authority) only when the live-release predicate applies to it AND accepts it — fail closed, whatever its own seal
|
|
@@ -425,6 +480,13 @@ export async function runRelease(options, adapters) {
|
|
|
425
480
|
const approved = async (gate, action = "dispatch") => {
|
|
426
481
|
if (gate.candidate !== j.candidate.sha || gate.scopeDigest !== j.task.scopeDigest)
|
|
427
482
|
throw new Stop("scope-changed", `Task ${j.task.id}'s candidate or scope changed after approval.`);
|
|
483
|
+
// Checking automatically: the plane's `auto-check` seal must still stand (the setting on, its evidence, this digest).
|
|
484
|
+
if (j.auto?.state === "checking" && !j.authority) {
|
|
485
|
+
const standing = rel?.auto === undefined ? { ok: false, why: "this plane can't approve releases automatically" } : await rel.auto.checking(j.task.id);
|
|
486
|
+
if (standing.ok)
|
|
487
|
+
return;
|
|
488
|
+
return await autoRefused(standing.why);
|
|
489
|
+
}
|
|
428
490
|
if (rel !== undefined && (j.authority || gate.approval?.basis === "carried")) {
|
|
429
491
|
const live = await rel.live(j.task.id, action);
|
|
430
492
|
if (live.applies && live.ok)
|
|
@@ -502,6 +564,29 @@ export async function runRelease(options, adapters) {
|
|
|
502
564
|
break;
|
|
503
565
|
}
|
|
504
566
|
case "approval": {
|
|
567
|
+
// Approve automatically when checks pass (auto-approve-green.md): with the project's setting on, the plane seals
|
|
568
|
+
// `auto-check` so the release check runs first; the approval itself is sealed after it passed. A fresh task
|
|
569
|
+
// after a CI failure after the deploy, or an automatic refusal, always asks a person.
|
|
570
|
+
const auto = rel?.auto;
|
|
571
|
+
if (auto !== undefined && j.freshTask !== true && j.auto?.state !== "refused" && j.task.scopeDigest !== null && await auto.setting(j.task.id)) {
|
|
572
|
+
await watch("dispatch");
|
|
573
|
+
const gate = await adapters.toolroll.gate(j.task.id);
|
|
574
|
+
if (gate.scopeDigest !== j.task.scopeDigest)
|
|
575
|
+
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.`);
|
|
576
|
+
if (!ownerApproval(gate, j.task.scopeDigest).ok) {
|
|
577
|
+
const authorized = await auto.authorizeCheck({ taskId: j.task.id, digest: j.task.scopeDigest, candidate: j.candidate.sha });
|
|
578
|
+
if (authorized.ok) {
|
|
579
|
+
j.auto = { state: "checking" };
|
|
580
|
+
say(` Approving automatically once CI and the release check pass on ${j.candidate.sha.slice(0, 7)} (this project's release approval setting)`);
|
|
581
|
+
advance("check");
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
j.auto = { state: "refused", why: authorized.why };
|
|
585
|
+
save();
|
|
586
|
+
await auto.giveUp(j.task.id, authorized.why);
|
|
587
|
+
say(` Not approved automatically: ${authorized.why}. A person approves it.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
505
590
|
say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
|
|
506
591
|
const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
|
|
507
592
|
await watch("dispatch");
|
|
@@ -530,7 +615,7 @@ export async function runRelease(options, adapters) {
|
|
|
530
615
|
break;
|
|
531
616
|
}
|
|
532
617
|
case "check": {
|
|
533
|
-
const
|
|
618
|
+
const checkProbe = async () => {
|
|
534
619
|
// Before acting on an old candidate's failure: a moved head restarts the release first.
|
|
535
620
|
await watch("dispatch");
|
|
536
621
|
const gate = await adapters.toolroll.gate(j.task.id);
|
|
@@ -539,7 +624,7 @@ export async function runRelease(options, adapters) {
|
|
|
539
624
|
if (gate.pending === true && gate.result?.check !== "passed")
|
|
540
625
|
return null;
|
|
541
626
|
if (gate.failed !== null)
|
|
542
|
-
throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new
|
|
627
|
+
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);
|
|
543
628
|
if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
|
|
544
629
|
return null;
|
|
545
630
|
// The build records a prepared candidate as a commit of its own (its message names the task): it must hold
|
|
@@ -552,9 +637,42 @@ export async function runRelease(options, adapters) {
|
|
|
552
637
|
if (gate.result.worktree === null)
|
|
553
638
|
throw new Stop("worktree", `Run ${gate.result.run} names no checkout to deploy from.`);
|
|
554
639
|
return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree, head };
|
|
640
|
+
};
|
|
641
|
+
const result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
642
|
+
try {
|
|
643
|
+
return await checkProbe();
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
// A failed check (or a changed candidate) ends the automatic road: the owner is told, and a person decides.
|
|
647
|
+
// A failed check is told, and stays checkable again on the same commit (a flake); anything else ends the road.
|
|
648
|
+
if (error instanceof Stop && j.auto?.state === "checking" && error.reason === "check-failed" && !error.build)
|
|
649
|
+
await rel?.auto?.checkFailed(j.task.id, error.message.replace(/\.$/, ""));
|
|
650
|
+
else if (error instanceof Stop && j.auto?.state === "checking" && error.reason !== "approval")
|
|
651
|
+
await rel?.auto?.giveUp(j.task.id, error.message.replace(/\.$/, ""));
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
555
654
|
});
|
|
556
655
|
j.check = result;
|
|
557
656
|
say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
657
|
+
if (j.auto?.state === "checking" && !j.authority) {
|
|
658
|
+
// The release approval, sealed only now: the plane reads CI on the exact commit itself and proves the check.
|
|
659
|
+
const sealed = rel?.auto === undefined ? { ok: false, why: "this plane can't approve releases automatically" }
|
|
660
|
+
: await rel.auto.approve({ taskId: j.task.id, digest: j.task.scopeDigest, candidate: j.candidate.sha, run: result.run, receipt: result.receipt });
|
|
661
|
+
if (!sealed.ok)
|
|
662
|
+
await autoRefused(sealed.why);
|
|
663
|
+
else {
|
|
664
|
+
j.auto = { state: "approved", ci: sealed.ci.map(one => one.id) };
|
|
665
|
+
j.approval = { by: `${sealed.by} (automatic)`, at: sealed.at };
|
|
666
|
+
const view = await rel.authority(j.task.id);
|
|
667
|
+
if (view === null)
|
|
668
|
+
throw new Stop("release", `The automatic approval of ${j.task.id} opened no release record; a person approves it.`);
|
|
669
|
+
j.authority = { id: view.id, generation: view.generation };
|
|
670
|
+
j.landed = view.landed;
|
|
671
|
+
save();
|
|
672
|
+
await hold();
|
|
673
|
+
say(` Approved automatically: CI and the release check passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
558
676
|
advance("complete");
|
|
559
677
|
break;
|
|
560
678
|
}
|
|
@@ -597,6 +715,8 @@ export async function runRelease(options, adapters) {
|
|
|
597
715
|
const authority = await rel.authority(j.task.id);
|
|
598
716
|
if (authority !== null && authority.state === "open") {
|
|
599
717
|
await watch("deploy");
|
|
718
|
+
// Every deploy reservation, whatever the journal saved: CI must still be green on the exact commit.
|
|
719
|
+
await autoCiHolds("the deploy");
|
|
600
720
|
const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
601
721
|
if (!reserved.ok)
|
|
602
722
|
throw new Stop("release", `The deploy couldn't be reserved: ${reserved.why}`);
|
|
@@ -609,6 +729,8 @@ export async function runRelease(options, adapters) {
|
|
|
609
729
|
// The last moment a moved head restarts; then the deploy reservation freezes the candidate in the store.
|
|
610
730
|
await watch("deploy");
|
|
611
731
|
await pinned();
|
|
732
|
+
// Every deploy issued, whatever the journal saved (a retry after a rollback included): CI still green.
|
|
733
|
+
await autoCiHolds("the deploy");
|
|
612
734
|
let operation, secret = "";
|
|
613
735
|
if (rel !== undefined && j.authority && epoch !== null) {
|
|
614
736
|
const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
@@ -648,6 +770,7 @@ export async function runRelease(options, adapters) {
|
|
|
648
770
|
}
|
|
649
771
|
case "link": {
|
|
650
772
|
const runtime = j.deploy.runtime;
|
|
773
|
+
await autoCiHolds("the CLI names move");
|
|
651
774
|
if (await served(adapters, builtHead()) !== runtime)
|
|
652
775
|
throw new Stop("drift", `The console and worker no longer run ${runtime}; the CLI names were not moved.`);
|
|
653
776
|
if (j.links === undefined || j.links === null) {
|
|
@@ -670,7 +793,7 @@ export async function runRelease(options, adapters) {
|
|
|
670
793
|
case "ci": {
|
|
671
794
|
await waitFor("ci", `the pull request checks on ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
672
795
|
await pinned();
|
|
673
|
-
const verdict =
|
|
796
|
+
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, autoCap());
|
|
674
797
|
if (verdict.state === "failed")
|
|
675
798
|
await ciFailedAfterDeploy(`Pull request checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}`);
|
|
676
799
|
return verdict.state === "passed" ? true : null;
|
|
@@ -699,7 +822,7 @@ export async function runRelease(options, adapters) {
|
|
|
699
822
|
if (!await adapters.repo.contains(j.candidate.sha, main))
|
|
700
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.`);
|
|
701
824
|
// The checks again, at the moment of merging: a rerun that failed since must not merge.
|
|
702
|
-
const verdict =
|
|
825
|
+
const verdict = prChecksVerdict(await adapters.github.checks(j.github, j.candidate.sha), j.candidate.sha, REQUIRED_PR_CHECKS, autoCap());
|
|
703
826
|
if (verdict.state !== "passed")
|
|
704
827
|
await ciFailedAfterDeploy(`Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}`);
|
|
705
828
|
let operation;
|
|
@@ -747,10 +870,12 @@ export async function runRelease(options, adapters) {
|
|
|
747
870
|
}
|
|
748
871
|
case "tag": {
|
|
749
872
|
const merge = j.merge;
|
|
873
|
+
await publishHold();
|
|
750
874
|
const at = await adapters.github.tag(j.github, j.tag);
|
|
751
875
|
if (at !== null && at !== merge.sha)
|
|
752
876
|
throw new Stop("conflict", `${j.tag} already points at ${at.slice(0, 7)}, not the merge ${merge.sha.slice(0, 7)}.`);
|
|
753
877
|
if (at === null) {
|
|
878
|
+
await autoCiHolds("the tag");
|
|
754
879
|
intend("tag");
|
|
755
880
|
await adapters.github.createTag(j.github, j.tag, merge.sha);
|
|
756
881
|
}
|
|
@@ -759,6 +884,7 @@ export async function runRelease(options, adapters) {
|
|
|
759
884
|
break;
|
|
760
885
|
}
|
|
761
886
|
case "publish": {
|
|
887
|
+
await publishHold();
|
|
762
888
|
if (j.intents.publish === undefined)
|
|
763
889
|
intend("publish");
|
|
764
890
|
await waitFor("publish", `npm, the GitHub release and the tag's checks for ${j.tag}`, async () => {
|
|
@@ -776,6 +902,7 @@ export async function runRelease(options, adapters) {
|
|
|
776
902
|
break;
|
|
777
903
|
}
|
|
778
904
|
case "homebrew": {
|
|
905
|
+
await publishHold();
|
|
779
906
|
const brew = adapters.homebrew;
|
|
780
907
|
let state = await brew.state(j.packageVersion);
|
|
781
908
|
if (state.kind === "missing") {
|
|
@@ -825,6 +952,12 @@ export async function runRelease(options, adapters) {
|
|
|
825
952
|
}
|
|
826
953
|
}
|
|
827
954
|
say(`Released ${j.tag}: deployed, merged as ${j.merge.sha.slice(0, 7)}, tagged, published and on Homebrew.`);
|
|
955
|
+
if (j.auto?.state === "approved") {
|
|
956
|
+
try {
|
|
957
|
+
await rel?.auto?.released(j.task.id, j.tag);
|
|
958
|
+
}
|
|
959
|
+
catch { /* the release is done; the note is best effort */ }
|
|
960
|
+
}
|
|
828
961
|
for (const line of landedWords(j.landed ?? []))
|
|
829
962
|
say(line);
|
|
830
963
|
return { ok: true, journal: j, file };
|
|
@@ -930,7 +1063,7 @@ async function earlyChecks(adapters, j, wait) {
|
|
|
930
1063
|
unread = true;
|
|
931
1064
|
return null;
|
|
932
1065
|
}
|
|
933
|
-
const verdict =
|
|
1066
|
+
const verdict = prChecksVerdict(runs.filter(run => required.includes(run.name)), j.candidate.sha, required);
|
|
934
1067
|
if (verdict.state === "failed") {
|
|
935
1068
|
throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}. Nothing was filed or sent for approval. 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).`);
|
|
936
1069
|
}
|
|
@@ -962,3 +1095,90 @@ export function transientNetworkError(error) {
|
|
|
962
1095
|
function networkWords(error) {
|
|
963
1096
|
return TRANSIENT_NETWORK.exec(String(error?.message ?? error))?.[0] ?? "a network failure";
|
|
964
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* The pull request's checks on exactly `head`, every attempt (the adapter asks GitHub for all of them, filter=all):
|
|
1100
|
+
* each reported attempt must be filed under `head` with a check-run id; a required check counts only from GitHub
|
|
1101
|
+
* 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
|
+
* 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 `cap`, a group whose earlier attempts failed more than `cap`
|
|
1104
|
+
* times fails too (an automatic approval's flake allowance); the failures it tolerated are returned.
|
|
1105
|
+
*/
|
|
1106
|
+
export function prChecksVerdict(runs, head, required, options = {}) {
|
|
1107
|
+
const failed = [], waiting = [], failures = [];
|
|
1108
|
+
const valid = [];
|
|
1109
|
+
for (const run of runs) {
|
|
1110
|
+
if (run.head !== head) {
|
|
1111
|
+
failed.push(`${run.name} (reported for ${run.head == null ? "no commit" : run.head.slice(0, 7)}, not ${head.slice(0, 7)})`);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (typeof run.id !== "number" || !Number.isSafeInteger(run.id) || run.id <= 0) {
|
|
1115
|
+
failed.push(`${run.name} (no check run id)`);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (required.includes(run.name) && run.app !== "github-actions") {
|
|
1119
|
+
failed.push(`${run.name} (reported by ${run.app ?? "an unknown app"}, not GitHub Actions)`);
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
if (run.app === "github-actions" && (typeof run.suite !== "number" || !Number.isSafeInteger(run.suite))) {
|
|
1123
|
+
failed.push(`${run.name} (no workflow run)`);
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
valid.push(run);
|
|
1127
|
+
}
|
|
1128
|
+
// Supersession by trusted workflow identity (GitHub's workflow-run API, read by the adapter): a newer run of the same
|
|
1129
|
+
// workflow on this commit replaces the older ones — its jobs are the current ones. A suite whose workflow isn't known
|
|
1130
|
+
// is its own workflow (nothing supersedes it), and another app's suites stand on their own.
|
|
1131
|
+
const workflowOf = (run) => run.app === "github-actions" && typeof run.workflow === "number" ? `w:${run.workflow}` : `s:${run.app ?? ""}:${run.suite ?? ""}`;
|
|
1132
|
+
const current = new Map();
|
|
1133
|
+
for (const run of valid) {
|
|
1134
|
+
const key = workflowOf(run), suite = run.suite ?? null;
|
|
1135
|
+
const was = current.get(key);
|
|
1136
|
+
if (was === undefined || (suite !== null && (was === null || suite > was)))
|
|
1137
|
+
current.set(key, suite);
|
|
1138
|
+
}
|
|
1139
|
+
// Jobs: a workflow and a name, every attempt in every suite of it; judged by the latest attempt in the current suite.
|
|
1140
|
+
const groups = new Map();
|
|
1141
|
+
for (const run of valid) {
|
|
1142
|
+
const key = JSON.stringify([workflowOf(run), run.name]);
|
|
1143
|
+
groups.set(key, [...(groups.get(key) ?? []), run]);
|
|
1144
|
+
}
|
|
1145
|
+
const judged = new Set();
|
|
1146
|
+
for (const attempts of groups.values()) {
|
|
1147
|
+
const ordered = [...attempts].sort((a, b) => a.id - b.id);
|
|
1148
|
+
const now = current.get(workflowOf(ordered[0])) ?? null;
|
|
1149
|
+
const live = ordered.filter(one => (one.suite ?? null) === now);
|
|
1150
|
+
// A job only an older run had (the newer run no longer has it) is history: not judged, and doesn't stand for a
|
|
1151
|
+
// required check.
|
|
1152
|
+
if (live.length === 0)
|
|
1153
|
+
continue;
|
|
1154
|
+
const latest = live.at(-1), name = latest.name;
|
|
1155
|
+
judged.add(name);
|
|
1156
|
+
if (latest.status !== "completed") {
|
|
1157
|
+
waiting.push(`${name} (${latest.status})`);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (latest.conclusion !== "success") {
|
|
1161
|
+
failed.push(`${name} (${latest.conclusion ?? "no conclusion"})`);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
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
|
+
}
|
|
1178
|
+
for (const name of required)
|
|
1179
|
+
if (!judged.has(name))
|
|
1180
|
+
waiting.push(`${name} (not started)`);
|
|
1181
|
+
if (failed.length > 0)
|
|
1182
|
+
return { state: "failed", failed };
|
|
1183
|
+
return waiting.length > 0 ? { state: "pending", waiting } : { state: "passed", failures };
|
|
1184
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Optional repository context. Callers admit project access; this module grants no authority.
|
|
2
2
|
* The index is disposable. Every read hashes the current bounded tracked source set;
|
|
3
3
|
* a missing/changed/unreadable index falls back to ordinary text search, never a task gate. */
|
|
4
|
+
import { checkoutGitSync } from './teardown-git.js';
|
|
4
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
8
|
import { scanForSecrets } from './evidence.js';
|
|
@@ -12,7 +12,10 @@ const CODE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
|
12
12
|
const TEXT = /\.(?:[cm]?[jt]sx?|md|txt|css|html|json)$/i;
|
|
13
13
|
const MAX_NODES = 16_000, MAX_EDGES = 12_000;
|
|
14
14
|
const sha = (value) => createHash('sha256').update(value).digest('hex');
|
|
15
|
-
|
|
15
|
+
// Read-only and bounded, and always the hardened checkout git (a crew worktree is agent-written, and a page read or a
|
|
16
|
+
// lead's refresh reads it alike): no filter, hook, program or network, its config and info frozen in a view of the
|
|
17
|
+
// plane's own before they're inspected (teardown-git.ts checkoutGitSync).
|
|
18
|
+
const git = (repo, args) => checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000 }).trimEnd();
|
|
16
19
|
const inside = (root, path) => { const r = relative(root, path); return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
|
|
17
20
|
function capture(request) {
|
|
18
21
|
const repo = realpathSync(request.repo);
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { belongsToRelease } from "./release-authority.js";
|
|
6
6
|
import { run as execRun } from "./exec.js";
|
|
7
|
+
import { hardenedGit } from "./teardown-git.js";
|
|
7
8
|
import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
|
|
8
9
|
import { runWithIsolatedDatabase } from "./child-database.js";
|
|
9
10
|
import { boundStreamHeadTail, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
@@ -46,6 +47,25 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
|
|
|
46
47
|
catch { /* an unreadable receipt upgrades nothing */ }
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
// A pass stands only while its saved log still verifies and the command it ran is still the approved one, the same
|
|
51
|
+
// bar the build's own check receipt meets (verification-evidence.ts): otherwise it can't be read as a pass now.
|
|
52
|
+
const repo = root === undefined ? null : repoOfRun(store, runId);
|
|
53
|
+
const logVerifies = (artifactId) => {
|
|
54
|
+
if (root === undefined || artifactId === null)
|
|
55
|
+
return false;
|
|
56
|
+
const artifact = store.getArtifact(artifactId);
|
|
57
|
+
return artifact !== null && artifact.run === runId && artifact.kind === "structured-output" && artifact.capture === FOLLOW_UP_LOG && artifact.captureStatus !== "failed" && readVerifiedArtifact(root, artifact).ok;
|
|
58
|
+
};
|
|
59
|
+
const stillApproved = (level, digest) => {
|
|
60
|
+
if (repo === null)
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
return checkCommandFor(store, repo, level).command?.digest === digest;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
49
69
|
return rows.filter(row => row.action === CHECKS_REQUESTED && (row.outcome === "quick" || row.outcome === "full")).map(request => {
|
|
50
70
|
const [head = "", why = "person", digest = "", recordedBase = ""] = (request.detail ?? "").split(" · ");
|
|
51
71
|
const started = rows.find(row => row.action === CHECKS_STARTED && row.outcome === String(request.id)) ?? null;
|
|
@@ -59,7 +79,7 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
|
|
|
59
79
|
// With the evidence at hand, only a sealed receipt can say passed: for this result's own commit, and,
|
|
60
80
|
// for a batch, a batch that names this result.
|
|
61
81
|
if (status === "passed" && root !== undefined && (sealed === null || !sealed.ran || sealed.exitCode !== 0 || (sealed.head !== null && sealed.head !== head)
|
|
62
|
-
|| (base.why === "batch" && sealed.batch === null))) {
|
|
82
|
+
|| (base.why === "batch" && sealed.batch === null) || !logVerifies(sealed.log) || !stillApproved(base.level, digest))) {
|
|
63
83
|
return { ...base, state: "not-run", note: "The saved check result could not be verified." };
|
|
64
84
|
}
|
|
65
85
|
const exit = /exit (\d+)/.exec(finished.detail ?? "");
|
|
@@ -142,7 +162,8 @@ export function waitingRequestsOfAnyKind(store, now) {
|
|
|
142
162
|
export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
143
163
|
const now = deps.now ?? (() => new Date());
|
|
144
164
|
const runner = deps.runner ?? execRun;
|
|
145
|
-
|
|
165
|
+
// The project's repository and a checkout of a build's commit, both agent-writable: the hardened git (teardown-git.ts).
|
|
166
|
+
const git = deps.git ?? hardenedGit(execRun);
|
|
146
167
|
const asked = store.handle.prepare("SELECT run_id FROM action_ledger WHERE id = ? AND action = ?").get(request, CHECKS_REQUESTED);
|
|
147
168
|
if (asked === undefined)
|
|
148
169
|
return null;
|
package/dist/result-review.js
CHANGED
|
@@ -28,7 +28,11 @@ export function evidenceResultStatusOf(result, publication, evidence) {
|
|
|
28
28
|
stored = {
|
|
29
29
|
token: "evidence-damaged",
|
|
30
30
|
label: "Result saved, but some saved material is unavailable",
|
|
31
|
-
|
|
31
|
+
// Never "passed" when the check itself can't be read now (task-status.ts approvedCheckOf).
|
|
32
|
+
detail: recorded.token === "check-unreadable" ? `Some saved material is missing, damaged, or could not be read. ${recorded.detail}`
|
|
33
|
+
: `Some saved material is missing, damaged, or could not be read. ${result.verdict === "verified" && result.checks !== undefined && ["ready-to-review", "pr-opened", "merge-observed", "pr-closed"].includes(recorded.token)
|
|
34
|
+
? "The machine's verdict at completion is unchanged: the approved check passed against it then."
|
|
35
|
+
: result.verdict === "verified" ? "Open the result to see its checks." : `The machine's verdict at completion is unchanged: ${recorded.label.toLowerCase()}.`}`,
|
|
32
36
|
tone: "problem",
|
|
33
37
|
action: { label: "See what is missing", kind: "open-result" },
|
|
34
38
|
};
|