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.
Files changed (102) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +28 -8
  5. package/dist/assignment.js +3 -0
  6. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/builder.d.ts +14 -0
  10. package/dist/builder.js +140 -41
  11. package/dist/chat-decide.js +4 -0
  12. package/dist/check-failure.d.ts +92 -0
  13. package/dist/check-failure.js +267 -0
  14. package/dist/check-progress.d.ts +28 -1
  15. package/dist/check-progress.js +105 -6
  16. package/dist/check-report.d.ts +52 -6
  17. package/dist/check-report.js +193 -8
  18. package/dist/checkout-cleanup.js +2 -2
  19. package/dist/cli.js +8 -5
  20. package/dist/coding-handoff.d.ts +1 -0
  21. package/dist/coding-handoff.js +20 -11
  22. package/dist/commit-extract.d.ts +6 -0
  23. package/dist/commit-extract.js +63 -8
  24. package/dist/console-chrome.d.ts +41 -0
  25. package/dist/console-chrome.js +166 -0
  26. package/dist/contracts/release-check.d.ts +12 -0
  27. package/dist/contracts/release-check.js +17 -0
  28. package/dist/contracts/verification-receipt.d.ts +20 -2
  29. package/dist/contracts/verification-receipt.js +15 -3
  30. package/dist/contracts/verification-view.d.ts +8 -0
  31. package/dist/controller-supervisor.d.ts +8 -0
  32. package/dist/controller-supervisor.js +33 -1
  33. package/dist/demo.js +3 -2
  34. package/dist/deploy-gate.js +5 -0
  35. package/dist/dispose.d.ts +3 -0
  36. package/dist/dispose.js +6 -3
  37. package/dist/doctor.d.ts +7 -0
  38. package/dist/doctor.js +10 -2
  39. package/dist/envelope.js +2 -1
  40. package/dist/evidence.js +4 -1
  41. package/dist/exec.d.ts +7 -0
  42. package/dist/exec.js +7 -1
  43. package/dist/git-floor.d.ts +16 -0
  44. package/dist/git-floor.js +36 -0
  45. package/dist/grant.js +5 -1
  46. package/dist/guides.js +4 -3
  47. package/dist/operate.d.ts +13 -0
  48. package/dist/operate.js +773 -319
  49. package/dist/prepared-evidence.d.ts +2 -1
  50. package/dist/prepared-evidence.js +8 -3
  51. package/dist/project-knowledge.js +6 -2
  52. package/dist/project-learning.js +2 -2
  53. package/dist/proof.js +6 -0
  54. package/dist/read-executor-worker.js +8 -4
  55. package/dist/read-executor.d.ts +18 -1
  56. package/dist/read-executor.js +8 -0
  57. package/dist/release-adapters.d.ts +48 -1
  58. package/dist/release-adapters.js +343 -10
  59. package/dist/release-authority.js +6 -2
  60. package/dist/release-auto.d.ts +38 -7
  61. package/dist/release-auto.js +131 -15
  62. package/dist/release-card.d.ts +15 -3
  63. package/dist/release-card.js +25 -9
  64. package/dist/release-ci-hold.d.ts +41 -0
  65. package/dist/release-ci-hold.js +53 -0
  66. package/dist/release-cli.js +21 -12
  67. package/dist/release-coverage.js +5 -2
  68. package/dist/release-recheck.d.ts +1 -1
  69. package/dist/release-recheck.js +6 -1
  70. package/dist/release.d.ts +108 -11
  71. package/dist/release.js +294 -39
  72. package/dist/review-context.js +8 -6
  73. package/dist/runner.js +2 -1
  74. package/dist/sandbox-runtime.d.ts +8 -0
  75. package/dist/sandbox-runtime.js +14 -2
  76. package/dist/scope.js +7 -1
  77. package/dist/secret-mint.d.ts +1 -0
  78. package/dist/secret-mint.js +14 -0
  79. package/dist/serve.js +101 -51
  80. package/dist/server/chat.js +5 -5
  81. package/dist/server/render-tasks.d.ts +2 -0
  82. package/dist/server/render-tasks.js +2 -1
  83. package/dist/server/request-context.d.ts +6 -0
  84. package/dist/server/runtime.d.ts +2 -0
  85. package/dist/server/task-views.js +7 -2
  86. package/dist/setup-guide.d.ts +2 -2
  87. package/dist/storage.d.ts +5 -0
  88. package/dist/storage.js +28 -1
  89. package/dist/store.d.ts +10 -0
  90. package/dist/store.js +25 -1
  91. package/dist/task-base.d.ts +4 -0
  92. package/dist/task-base.js +22 -2
  93. package/dist/teardown-git.d.ts +52 -3
  94. package/dist/teardown-git.js +143 -31
  95. package/dist/test-infra.js +1 -1
  96. package/dist/up-processes.d.ts +137 -0
  97. package/dist/up-processes.js +165 -0
  98. package/dist/up-role-child.d.ts +1 -0
  99. package/dist/up-role-child.js +77 -0
  100. package/dist/verification-evidence.d.ts +1 -0
  101. package/dist/verification-evidence.js +13 -1
  102. package/package.json +2 -1
@@ -14,13 +14,16 @@ import { parse as parseYaml } from "yaml";
14
14
  import { sleep } from "./sleep.js";
15
15
  // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
16
16
  const sqlite = () => createRequire(import.meta.url)("node:sqlite");
17
+ import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./release.js";
17
18
  import { activeUpdateWork } from "./desktop-update-gate.js";
18
19
  import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
19
20
  import { findCliLinks, switchCliLinks } from "./release-links.js";
20
21
  import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, releaseIssuerGone, takeReleaseLease, } from "./store.js";
21
22
  import { acquireDeploymentLock } from "./deploy-lock.js";
22
23
  import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
23
- import { autoPublishHeld, holdAutoPublish, authorizeAutoCheck, giveUpAutoApproval, noteAutoCheckFailed, noteAutoRelease, releaseAutoApproves, sealAutoGreen } from "./release-auto.js";
24
+ import { recheckReleaseTask, releaseCheckStanding } from "./check-failure.js";
25
+ import { noteCiRunning, releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
26
+ import { autoPublishHeld, holdAutoPublish, liftCiHold, authorizeAutoCheck, giveUpAutoApproval, noteAutoCheckFailed, noteAutoRelease, releaseAutoApproves, sealAutoGreen } from "./release-auto.js";
24
27
  import { proveCarry } from "./release-carry.js";
25
28
  import { randomBytes, randomUUID } from "node:crypto";
26
29
  import { deploymentGate } from "./deploy-gate.js";
@@ -242,7 +245,8 @@ export function releaseAdapters(options) {
242
245
  const ciGh = options.tools?.gh ?? releaseTools("").gh;
243
246
  const observeCi = async (sha) => {
244
247
  const observedAt = (options.now ?? (() => new Date()))().toISOString();
245
- const runs = (await readCheckRuns(run, ciGh, releaseRepo().slug, sha)).map(one => ({ id: one.id ?? null, name: one.name, status: one.status, conclusion: one.conclusion, app: one.app ?? null, suite: one.suite ?? null, head: one.head ?? null, workflow: one.workflow ?? null }));
248
+ const runs = (await readCheckRuns(run, ciGh, releaseRepo().slug, sha)).map(one => ({ id: one.id ?? null, name: one.name, status: one.status, conclusion: one.conclusion, app: one.app ?? null, suite: one.suite ?? null, head: one.head ?? null, workflow: one.workflow ?? null, workflowPath: one.workflowPath ?? null,
249
+ ...(one.beforeTests === undefined ? {} : { beforeTests: one.beforeTests }) }));
246
250
  return { head: sha, observedAt, runs, complete: await readSuitesComplete(run, ciGh, releaseRepo().slug, sha) };
247
251
  };
248
252
  const database = options.database ?? databasePath(process.env, homedir());
@@ -369,7 +373,11 @@ export function releaseAdapters(options) {
369
373
  rmSync(extracted.dir, { recursive: true, force: true });
370
374
  }
371
375
  };
372
- const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(run, ciGh, releaseRepo().slug, base, sha), evidenceRoot: join(dirname(database), "evidence") });
376
+ const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(run, ciGh, releaseRepo().slug, base, sha),
377
+ upToDateRequired: () => readUpToDateRequired(run, ciGh, releaseRepo().slug), evidenceRoot: join(dirname(database), "evidence"),
378
+ // A successful Actions job authenticates the job, not the candidate-writable JSON it uploads. Until the
379
+ // executor protects that reporting channel, production releases must take the person's approval road.
380
+ reportsProtected: false });
373
381
  return {
374
382
  ...(existsSync(database) ? { release } : {}),
375
383
  now: options.now ?? (() => new Date()),
@@ -424,8 +432,6 @@ export function releaseAdapters(options) {
424
432
  const result = await git("show", `${sha}:${path}`);
425
433
  return result.code === 0 ? result.stdout : null;
426
434
  },
427
- async mainHead() { return (await must(git("rev-parse", "refs/remotes/origin/main"), "git rev-parse origin/main")).trim(); },
428
- async contains(sha, ancestor) { return (await git("merge-base", "--is-ancestor", ancestor, sha)).code === 0; },
429
435
  },
430
436
  toolroll: {
431
437
  async installed() { return { services: serviceRuntimes(), clis: cliRuntimes() }; },
@@ -571,6 +577,9 @@ export function releaseAdapters(options) {
571
577
  // Every attempt (filter=all), with its app, check suite and commit: the release judges each job by its latest attempt.
572
578
  async checks(repo, sha) { return readCheckRuns(run, ciGh, repo, sha); },
573
579
  async complete(repo, sha) { return readSuitesComplete(run, ciGh, repo, sha); },
580
+ async mainHead(repo) { return readMainHead(run, ciGh, repo); },
581
+ async contains(repo, sha, ancestor) { return readContains(run, ciGh, repo, sha, ancestor); },
582
+ async upToDateRequired(repo) { return readMainProtection(run, ciGh, repo); },
574
583
  async merge(repo, pr, head, message) {
575
584
  // The squash commit's message lists every commit that landed after the approval (release-once.md).
576
585
  const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`, ...(message === undefined ? [] : ["-f", `commit_message=${message}`])));
@@ -746,6 +755,21 @@ auto) {
746
755
  } };
747
756
  return {
748
757
  ...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
758
+ // After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
759
+ ...(auto === undefined ? {} : {
760
+ async recheck(taskId) {
761
+ return withStore(store => {
762
+ const answer = recheckReleaseTask(store, auto.evidenceRoot, taskId, now());
763
+ return answer.action === "rerun" ? { action: "rerun", why: answer.why } : answer.action === "pending" ? { action: "pending" } : { action: "stop", class: answer.class, why: answer.why };
764
+ });
765
+ },
766
+ async standing(taskId, run) {
767
+ return withStore(store => {
768
+ const standing = releaseCheckStanding(store, auto.evidenceRoot, { taskId, run });
769
+ return standing.ok ? { ok: true, retried: standing.retried } : { ok: false, why: standing.why, retried: standing.retried };
770
+ });
771
+ },
772
+ }),
749
773
  async authority(taskId) {
750
774
  return withStore(store => {
751
775
  const found = releaseAuthorityOfTask(store, taskId);
@@ -790,6 +814,8 @@ auto) {
790
814
  /** The plane's automatic release approval through a store (release-auto.ts): what `toolroll release` asks. It hands the
791
815
  * plane ids only; the plane reads CI itself (`observe`) right before it seals. */
792
816
  function autoReleaseAdapter(withStore, now, auto) {
817
+ const reportProblem = () => auto.reportsProtected === true ? null
818
+ : "CI test reports aren't protected from candidate writes, so a person must approve this release";
793
819
  return {
794
820
  async setting(taskId) {
795
821
  return withStore(store => { try {
@@ -801,6 +827,9 @@ function autoReleaseAdapter(withStore, now, auto) {
801
827
  } });
802
828
  },
803
829
  async authorizeCheck(input) {
830
+ const problem = reportProblem();
831
+ if (problem !== null)
832
+ return { ok: false, why: problem };
804
833
  // The comparison base for what the release changes: GitHub's own merge base of the candidate with the release's
805
834
  // base branch (its signed terms), read by the plane — never a task branch or a build's recorded base.
806
835
  const branch = withStore(store => store.getScope(input.taskId)?.releaseTerms?.base ?? null);
@@ -816,9 +845,17 @@ function autoReleaseAdapter(withStore, now, auto) {
816
845
  return withStore(store => { const answer = authorizeAutoCheck(store, { ...input, base, now: now() }); return answer.ok ? { ok: true } : answer; });
817
846
  },
818
847
  async checking(taskId) {
848
+ const problem = reportProblem();
849
+ if (problem !== null)
850
+ return { ok: false, why: problem };
819
851
  return withStore(store => { const answer = autoReleaseApprovalOf(store, taskId, "auto-check"); return answer.ok ? { ok: true } : { ok: false, why: answer.why }; });
820
852
  },
821
853
  async approve(input) {
854
+ const unprotected = reportProblem();
855
+ if (unprotected !== null) {
856
+ withStore(store => giveUpAutoApproval(store, { taskId: input.taskId, why: unprotected, now: now() }));
857
+ return { ok: false, why: unprotected };
858
+ }
822
859
  let ci = null, problem = "";
823
860
  // A short network failure is asked again a few times; GitHub not answering is never a pass.
824
861
  for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
@@ -835,9 +872,36 @@ function autoReleaseAdapter(withStore, now, auto) {
835
872
  return { ok: false, why };
836
873
  }
837
874
  const observed = ci;
838
- return withStore(store => sealAutoGreen(store, { ...input, ci: observed, evidenceRoot: auto.evidenceRoot, now: now() }));
875
+ let upToDate;
876
+ try {
877
+ upToDate = await auto.upToDateRequired();
878
+ }
879
+ catch {
880
+ upToDate = null;
881
+ }
882
+ return withStore(store => sealAutoGreen(store, { ...input, ci: observed, mainUpToDate: upToDate, evidenceRoot: auto.evidenceRoot, now: now() }));
839
883
  },
840
884
  async giveUp(taskId, why) { withStore(store => giveUpAutoApproval(store, { taskId, why, now: now() })); },
885
+ async ciRunning(taskId, candidate) { withStore(store => noteCiRunning(store, { taskId, candidate, now: now() })); },
886
+ async ciPassed(taskId, candidate, ask) {
887
+ // Reconcile the plane's record, not journal flags; without a pending row there is nothing to lift.
888
+ if (!withStore(store => releaseCiHoldOf(store, taskId) !== null || releaseCiRunningOf(store, taskId) !== null))
889
+ return { ok: true };
890
+ // The plane reads CI itself; a short network failure is asked again a few times, and unreadable is never a pass.
891
+ let ci = null, problem = "";
892
+ for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
893
+ try {
894
+ ci = await auto.observe(candidate);
895
+ }
896
+ catch (error) {
897
+ problem = String(error?.message ?? error).slice(0, 200);
898
+ }
899
+ }
900
+ if (ci === null)
901
+ return { ok: false, why: `GitHub CI on ${candidate.slice(0, 7)} couldn't be read (${problem})` };
902
+ const observed = ci;
903
+ return withStore(store => liftCiHold(store, { taskId, candidate, ci: observed, ask, now: now() }));
904
+ },
841
905
  async checkFailed(taskId, why) { withStore(store => noteAutoCheckFailed(store, { taskId, why, now: now() })); },
842
906
  async released(taskId, tag) { withStore(store => noteAutoRelease(store, { taskId, tag, now: now() })); },
843
907
  async holdPublish(taskId, why) { withStore(store => holdAutoPublish(store, { taskId, why, now: now() })); },
@@ -862,16 +926,46 @@ export async function readCheckRuns(run, gh, repo, sha) {
862
926
  // Which workflow each check suite is a run of (GitHub's workflow-run API): a newer run of a workflow supersedes its
863
927
  // older runs on this commit (release.ts prChecksVerdict).
864
928
  const flows = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`,
865
- "--jq", ".workflow_runs[] | {suite: .check_suite_id, workflow: .workflow_id, head: .head_sha}"], { cwd: "/", env: ciEnv() }), "gh api actions/runs");
929
+ "--jq", ".workflow_runs[] | {suite: .check_suite_id, workflow: .workflow_id, path: .path, head: .head_sha, event: .event, branch: .head_branch, bases: [(.pull_requests // [])[] | .base.ref], prs: [(.pull_requests // [])[] | {number, repo: .base.repo.url}]}"], { cwd: "/", env: ciEnv() }), "gh api actions/runs");
930
+ // A pull request's base can be edited after its runs: a run counts only if no pull request it belongs to ever had its
931
+ // base changed (GitHub's timeline), so a run made against another base can't later read as a run into main.
932
+ const retargeted = new Map();
933
+ const everRetargeted = async (pr) => {
934
+ const known = retargeted.get(pr);
935
+ if (known !== undefined)
936
+ return known;
937
+ let changed = true;
938
+ try {
939
+ const events = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/issues/${pr}/timeline?per_page=100`, "--jq", ".[] | .event"], { cwd: "/", env: ciEnv() });
940
+ changed = events.code !== 0 || events.stdout.split("\n").some(line => line.trim() === "base_ref_changed");
941
+ }
942
+ catch {
943
+ changed = true;
944
+ }
945
+ retargeted.set(pr, changed);
946
+ return changed;
947
+ };
866
948
  const workflowOf = new Map();
949
+ // Each suite's workflow file: a required check counts only from the workflow that defines it (release.ts).
950
+ const pathOf = new Map();
867
951
  for (const line of flows.split("\n").filter(Boolean)) {
868
952
  const one = object(JSON.parse(line));
869
- if (!Number.isSafeInteger(one["suite"]) || !Number.isSafeInteger(one["workflow"]) || one["head"] !== sha)
953
+ if (!Number.isSafeInteger(one["suite"]) || !Number.isSafeInteger(one["workflow"]) || one["head"] !== sha || typeof one["path"] !== "string" || one["path"] === "")
870
954
  throw new Error("GitHub answered a workflow run this can't read");
871
955
  const known = workflowOf.get(Number(one["suite"]));
872
- if (known !== undefined && known !== Number(one["workflow"]))
956
+ if (known !== undefined && (known !== Number(one["workflow"]) || pathOf.get(Number(one["suite"])) !== one["path"]))
873
957
  throw new Error("GitHub named two workflows for one check suite");
874
958
  workflowOf.set(Number(one["suite"]), Number(one["workflow"]));
959
+ // Only a run on a pull request into main, or a push to main, binds its workflow file: a pull request into another
960
+ // branch reads that branch's workflow and join verifier, which the candidate may control (security scan of the
961
+ // join). Any other run keeps no path, so no required check counts from it.
962
+ // Each pull request the run belongs to must be this repository's own (a number from another repository names a
963
+ // different pull request here): any other refuses the run.
964
+ const prs = (Array.isArray(one["prs"]) ? one["prs"] : []).map(pr => (pr !== null && typeof pr === "object" ? pr : {}));
965
+ const ours = prs.length > 0 && prs.every(pr => Number.isSafeInteger(pr["number"]) && pr["repo"] === `https://api.github.com/repos/${repo}`);
966
+ const untouched = one["event"] !== "pull_request" || (ours && !(await Promise.all(prs.map(pr => everRetargeted(Number(pr["number"]))))).some(Boolean));
967
+ if (mainTriggered(one) && untouched)
968
+ pathOf.set(Number(one["suite"]), String(one["path"]));
875
969
  }
876
970
  const runs = out.split("\n").filter(Boolean).map(line => {
877
971
  const one = object(JSON.parse(line));
@@ -879,12 +973,64 @@ export async function readCheckRuns(run, gh, repo, sha) {
879
973
  throw new Error("GitHub answered a check run this can't read");
880
974
  const suite = Number.isSafeInteger(one["suite"]) ? Number(one["suite"]) : null;
881
975
  return { id: Number(one["id"]), name: one["name"], status: one["status"], conclusion: textOr(one["conclusion"]), app: textOr(one["app"]),
882
- suite, head: textOr(one["head"]), workflow: suite === null ? null : workflowOf.get(suite) ?? null };
976
+ suite, head: textOr(one["head"]), workflow: suite === null ? null : workflowOf.get(suite) ?? null, workflowPath: suite === null ? null : pathOf.get(suite) ?? null };
883
977
  });
884
978
  if (new Set(runs.map(one => one.id)).size !== runs.length)
885
979
  throw new Error("GitHub answered the same check run twice");
980
+ // Each unsuccessful GitHub Actions attempt: whether GitHub's own job record (a check run's id is its job's) shows it
981
+ // ending before any of the workflow's own steps ran. Only that ever counts as the runner's failure; unreadable is no.
982
+ for (const one of runs) {
983
+ if (one.app !== "github-actions" || one.status !== "completed" || one.conclusion === "success" || one.conclusion === "skipped" || one.conclusion === "neutral")
984
+ continue;
985
+ one.beforeTests = await readJobBeforeTests(run, gh, repo, one.id);
986
+ }
886
987
  return runs.sort((a, b) => a.id - b.id);
887
988
  }
989
+ /**
990
+ * Whether a finished, unsuccessful job ended before any of the workflow's own steps ran: every step after GitHub's own
991
+ * first one ("Set up job", always number 1) never ran (no conclusion, or skipped) — leaving out only GitHub's own
992
+ * closing "Complete job" when it is the last step. Step numbers and outcomes are the runner's records; nothing the
993
+ * candidate prints is read. A job with no steps never reached one. Anything else (a step that ran, even the checkout)
994
+ * is not confirmed infrastructure.
995
+ */
996
+ export function failedBeforeTests(job) {
997
+ if (job.status !== "completed" || job.conclusion === "success" || job.conclusion === null)
998
+ return false;
999
+ const steps = [...job.steps].sort((a, b) => a.number - b.number);
1000
+ if (new Set(steps.map(one => one.number)).size !== steps.length)
1001
+ return false;
1002
+ const last = steps.at(-1);
1003
+ const own = steps.filter(one => one.number > 1 && !(one === last && one.name === "Complete job" && steps.length > 1));
1004
+ return own.every(one => one.status !== "in_progress" && (one.conclusion === null || one.conclusion === "skipped"));
1005
+ }
1006
+ /** GitHub's job record for one check run (its job), read strictly: null when it can't be read whole. */
1007
+ export async function readJobBeforeTests(run, gh, repo, id) {
1008
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !Number.isSafeInteger(id) || id <= 0)
1009
+ return null;
1010
+ let out;
1011
+ try {
1012
+ out = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/actions/jobs/${id}`, "--jq", "{id, status, conclusion, steps: [(.steps // [])[] | {number, name, status, conclusion}]}"], { cwd: "/", env: ciEnv() });
1013
+ }
1014
+ catch {
1015
+ return null;
1016
+ }
1017
+ if (out.code !== 0)
1018
+ return null;
1019
+ let job;
1020
+ try {
1021
+ job = object(JSON.parse(out.stdout));
1022
+ }
1023
+ catch {
1024
+ return null;
1025
+ }
1026
+ const steps = Array.isArray(job["steps"]) ? job["steps"].map(object) : null;
1027
+ if (job["id"] !== id || typeof job["status"] !== "string" || steps === null)
1028
+ return null;
1029
+ if (steps.some(one => !Number.isSafeInteger(one["number"]) || typeof one["status"] !== "string" || typeof one["name"] !== "string"))
1030
+ return null;
1031
+ return failedBeforeTests({ id, status: String(job["status"]), conclusion: textOr(job["conclusion"]),
1032
+ steps: steps.map(one => ({ number: Number(one["number"]), name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) })) });
1033
+ }
888
1034
  /** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
889
1035
  export const CHECK_SUITE_CEILING = 1000;
890
1036
  export async function readSuitesComplete(run, gh, repo, sha) {
@@ -903,3 +1049,190 @@ export async function readMergeBase(run, gh, repo, base, sha) {
903
1049
  throw new Error("GitHub named no merge base");
904
1050
  return out;
905
1051
  }
1052
+ /**
1053
+ * Main's head as GitHub has it now (eval-harness review 1, finding 4): read by the plane's own gh from GitHub's API,
1054
+ * never from a ref in a checkout a build can write.
1055
+ */
1056
+ export async function readMainHead(run, gh, repo, branch = "main") {
1057
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
1058
+ throw new Error("not a repository and branch GitHub can be asked about");
1059
+ const out = (await must(run(gh, ["api", "--hostname", "github.com", `repos/${repo}/git/ref/heads/${branch}`, "--jq", ".object.type + \" \" + .object.sha"], { cwd: "/", env: ciEnv() }), "gh api git/ref")).trim();
1060
+ const match = /^commit ([0-9a-f]{40})$/.exec(out);
1061
+ if (match === null)
1062
+ throw new Error(`GitHub named no commit for ${branch}`);
1063
+ return match[1];
1064
+ }
1065
+ /** Whether `sha` already holds `ancestor` (GitHub's compare: `sha` is not behind it), as GitHub answers now. */
1066
+ export async function readContains(run, gh, repo, sha, ancestor) {
1067
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(ancestor))
1068
+ throw new Error("not a repository and commits GitHub can be asked about");
1069
+ const out = (await must(run(gh, ["api", "--hostname", "github.com", `repos/${repo}/compare/${ancestor}...${sha}`, "--jq", "[.status, .behind_by] | @json"], { cwd: "/", env: ciEnv() }), "gh api compare")).trim();
1070
+ let answer;
1071
+ try {
1072
+ answer = JSON.parse(out);
1073
+ }
1074
+ catch {
1075
+ throw new Error("GitHub's comparison couldn't be read");
1076
+ }
1077
+ if (!Array.isArray(answer) || typeof answer[0] !== "string" || !Number.isSafeInteger(answer[1]))
1078
+ throw new Error("GitHub's comparison couldn't be read");
1079
+ return answer[1] === 0 && (answer[0] === "ahead" || answer[0] === "identical");
1080
+ }
1081
+ /**
1082
+ * Main's protection, judged for the principal that merges (eval-harness review 2, finding 1; review 1, finding 4): the
1083
+ * plane's own gh, which merges as the same account. A layer holds only when it requires branches to be up to date, names
1084
+ * a non-empty set of required checks covering PROTECTION_MINIMUM_CHECKS, bound to GitHub Actions (app 15368), and
1085
+ * can't be bypassed by that account:
1086
+ * classic protection with "Do not allow bypassing the above settings" (`enforce_admins`); an active ruleset whose
1087
+ * bypass list reads whole, names no role, team or organization admin, and says this account never bypasses it. Read
1088
+ * only — Toolroll never changes a repository's settings. A layer that can't be read never holds; null only when nothing
1089
+ * could be read at all.
1090
+ */
1091
+ export async function readMainProtection(run, gh, repo, branch = "main") {
1092
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes(".."))
1093
+ return null;
1094
+ const missing = [];
1095
+ const held = [];
1096
+ let read = 0;
1097
+ const judge = (where, strict, checks, bound, bypass) => {
1098
+ const own = [];
1099
+ if (!strict)
1100
+ own.push(`${where}: "Require branches to be up to date before merging" is off`);
1101
+ if (checks.length === 0)
1102
+ own.push(`${where}: no required status checks`);
1103
+ else {
1104
+ const lacking = PROTECTION_MINIMUM_CHECKS.filter(name => !checks.includes(name));
1105
+ if (lacking.length > 0)
1106
+ own.push(`${where}: required checks lack ${lacking.join(", ")}`);
1107
+ const unbound = PROTECTION_MINIMUM_CHECKS.filter(name => checks.includes(name) && !bound.includes(name));
1108
+ if (unbound.length > 0)
1109
+ own.push(`${where}: required checks must require GitHub Actions (app 15368) as their source: ${unbound.join(", ")}`);
1110
+ }
1111
+ own.push(...bypass.map(why => `${where}: ${why}`));
1112
+ if (own.length === 0)
1113
+ held.push(bound);
1114
+ else
1115
+ missing.push(...own);
1116
+ };
1117
+ const notProtected = (out) => /HTTP 404|Not Found|not protected|not enabled/i.test(`${out.stderr}\n${out.stdout}`);
1118
+ const names = (list, key) => {
1119
+ if (!Array.isArray(list))
1120
+ return null;
1121
+ const out = [];
1122
+ for (const one of list) {
1123
+ const name = key === "" ? one : object(one)[key];
1124
+ if (typeof name !== "string" || name === "")
1125
+ return null;
1126
+ out.push(name);
1127
+ }
1128
+ return out;
1129
+ };
1130
+ const actionsChecks = (list, appKey) => Array.isArray(list)
1131
+ ? list.flatMap(one => { const check = object(one); return check[appKey] === 15368 && typeof check["context"] === "string" ? [check["context"]] : []; }) : [];
1132
+ // Classic branch protection, read whole. Context names alone allow an arbitrary writer to supply their status.
1133
+ const classic = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/branches/${branch}/protection`], { cwd: "/", env: ciEnv() });
1134
+ if (classic.code === 0) {
1135
+ let answer = null;
1136
+ try {
1137
+ answer = JSON.parse(classic.stdout);
1138
+ }
1139
+ catch { /* unreadable below */ }
1140
+ const p = object(answer), checks = p["required_status_checks"];
1141
+ if (answer === null || typeof answer !== "object" || Array.isArray(answer))
1142
+ missing.push("classic protection couldn't be read");
1143
+ else if (checks === undefined || checks === null) {
1144
+ read++;
1145
+ missing.push("classic protection: \"Require status checks to pass before merging\" is off");
1146
+ }
1147
+ else {
1148
+ const rsc = object(checks), contexts = names(rsc["contexts"] ?? [], ""), apps = names(rsc["checks"] ?? [], "context");
1149
+ const admins = p["enforce_admins"];
1150
+ if (contexts === null || apps === null || typeof rsc["strict"] !== "boolean" || admins === undefined || admins === null || typeof object(admins)["enabled"] !== "boolean")
1151
+ missing.push("classic protection couldn't be read");
1152
+ else {
1153
+ read++;
1154
+ judge("classic protection", rsc["strict"] === true, [...new Set([...contexts, ...apps])], actionsChecks(rsc["checks"], "app_id"), object(admins)["enabled"] === true ? [] : ["\"Do not allow bypassing the above settings\" is off, so administrators can merge past it"]);
1155
+ }
1156
+ }
1157
+ }
1158
+ else if (notProtected(classic))
1159
+ read++;
1160
+ else
1161
+ missing.push("classic protection couldn't be read");
1162
+ // Rulesets: every active rule requiring status checks on the branch, then its ruleset's enforcement and bypass list.
1163
+ const rules = await run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/rules/branches/${branch}`,
1164
+ "--jq", ".[] | select(.type == \"required_status_checks\") | @json"], { cwd: "/", env: ciEnv() });
1165
+ if (rules.code !== 0)
1166
+ missing.push("main's rulesets couldn't be read");
1167
+ else {
1168
+ read++;
1169
+ for (const line of rules.stdout.split("\n").filter(one => one.trim() !== "")) {
1170
+ let rule;
1171
+ try {
1172
+ rule = object(JSON.parse(line));
1173
+ }
1174
+ catch {
1175
+ missing.push("a ruleset rule couldn't be read");
1176
+ continue;
1177
+ }
1178
+ const id = rule["ruleset_id"], params = object(rule["parameters"]);
1179
+ const checks = names(params["required_status_checks"], "context");
1180
+ if (!Number.isSafeInteger(id) || Number(id) <= 0 || checks === null) {
1181
+ missing.push("a ruleset rule couldn't be read");
1182
+ continue;
1183
+ }
1184
+ const where = `ruleset ${id}`;
1185
+ const got = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/rulesets/${id}`, "--jq", "{enforcement, target, current_user_can_bypass, bypass_actors} | @json"], { cwd: "/", env: ciEnv() });
1186
+ let set = null;
1187
+ try {
1188
+ if (got.code === 0)
1189
+ set = object(JSON.parse(got.stdout));
1190
+ }
1191
+ catch {
1192
+ set = null;
1193
+ }
1194
+ if (set === null) {
1195
+ missing.push(`${where}: its settings couldn't be read`);
1196
+ continue;
1197
+ }
1198
+ const bypass = [];
1199
+ if (set["enforcement"] !== "active")
1200
+ bypass.push("it isn't active");
1201
+ if (set["target"] !== "branch")
1202
+ bypass.push("it doesn't target branches");
1203
+ if (set["current_user_can_bypass"] !== "never")
1204
+ bypass.push(typeof set["current_user_can_bypass"] === "string"
1205
+ ? `the account that merges can bypass it (${set["current_user_can_bypass"]})` : "whether the account that merges can bypass it couldn't be read");
1206
+ const actors = set["bypass_actors"];
1207
+ if (!Array.isArray(actors))
1208
+ bypass.push("its bypass list couldn't be read");
1209
+ // Only a deploy key never merges as this account; a role, team, app or organization admin might.
1210
+ else if (actors.some(one => object(one)["actor_type"] !== "DeployKey"))
1211
+ bypass.push("its bypass list names a role, team, app or organization admin");
1212
+ judge(where, params["strict_required_status_checks_policy"] === true, checks, actionsChecks(params["required_status_checks"], "integration_id"), bypass);
1213
+ }
1214
+ }
1215
+ if (held.length > 0) {
1216
+ const required = new Set(held.flat());
1217
+ return { effective: true, missing: [], notYetRequired: PROTECTION_REPORT_ONLY_CHECKS.filter(name => !required.has(name)) };
1218
+ }
1219
+ if (read === 0)
1220
+ return null;
1221
+ if (missing.length === 0)
1222
+ missing.push("main has no branch protection or ruleset requiring status checks");
1223
+ return { effective: false, missing, notYetRequired: [] };
1224
+ }
1225
+ /** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
1226
+ export async function readUpToDateRequired(run, gh, repo, branch = "main") {
1227
+ const protection = await readMainProtection(run, gh, repo, branch);
1228
+ return protection === null ? null : protection.effective;
1229
+ }
1230
+ /** Whether a workflow run was triggered on main: a push to main, or a pull request whose every base is main. */
1231
+ function mainTriggered(run) {
1232
+ const bases = Array.isArray(run["bases"]) ? run["bases"] : [];
1233
+ if (run["event"] === "push")
1234
+ return run["branch"] === "main";
1235
+ if (run["event"] === "pull_request")
1236
+ return bases.length > 0 && bases.every(base => base === "main");
1237
+ return false;
1238
+ }
@@ -143,11 +143,15 @@ export const AUTO_EVIDENCE_ACTION = "release auto-approved";
143
143
  /** An automatic seal taken back for good (the setting lowered, a refusal): its outcome names the seal's `auto:<id>`, and
144
144
  * that evidence never stands again. */
145
145
  export const AUTO_WITHDRAWN_ACTION = "release auto-approval withdrawn";
146
+ /** The setting rows (no task; this action) through the ledger's (task_id, action, …) index: a seek to the few rows every
147
+ * project has written, never a walk down the project's whole ledger. The approval predicate reads this once per task row
148
+ * (SQLite evaluates every operand of an OR in a result column), so a walk made each Needs you count ~10x slower (0.9.76). */
149
+ const SETTING_INDEX = "INDEXED BY work_completion";
146
150
  /** Read live at every use. Fail closed: a missing, unreadable or unknown row is `ask`. */
147
151
  export function releaseApprovalSetting(store, repo) {
148
152
  let row;
149
153
  try {
150
- row = store.handle.prepare("SELECT actor, at, outcome FROM action_ledger WHERE repo = ? AND task_id IS NULL AND action = ? ORDER BY id DESC LIMIT 1").get(repo, RELEASE_APPROVAL_ACTION);
154
+ row = store.handle.prepare(`SELECT actor, at, outcome FROM action_ledger ${SETTING_INDEX} WHERE task_id IS NULL AND action = ? AND repo = ? ORDER BY id DESC LIMIT 1`).get(RELEASE_APPROVAL_ACTION, repo);
151
155
  }
152
156
  catch {
153
157
  return { auto: false, setBy: null, at: null };
@@ -158,7 +162,7 @@ export function releaseApprovalSetting(store, repo) {
158
162
  }
159
163
  export const releaseAutoApproves = (store, repo) => releaseApprovalSetting(store, repo).auto;
160
164
  /** SQL: the newest setting row for `repo` (an SQL expression) says `auto`. */
161
- export const releaseAutoSql = (repo) => `COALESCE((SELECT ra_set.outcome = 'auto' FROM action_ledger ra_set WHERE ra_set.repo = ${repo} AND ra_set.task_id IS NULL
165
+ export const releaseAutoSql = (repo) => `COALESCE((SELECT ra_set.outcome = 'auto' FROM action_ledger ra_set ${SETTING_INDEX} WHERE ra_set.repo = ${repo} AND ra_set.task_id IS NULL
162
166
  AND ra_set.action = '${RELEASE_APPROVAL_ACTION}' ORDER BY ra_set.id DESC LIMIT 1), 0)`;
163
167
  /**
164
168
  * SQL over a `task_scope` alias: its automatic seal (`auto-check` or `auto-green`) stands on its own evidence row — this
@@ -19,6 +19,10 @@ export type ObservedCheckRun = {
19
19
  suite?: number | null;
20
20
  head?: string | null;
21
21
  workflow?: number | null;
22
+ /** The workflow's file (GitHub's workflow-run `path`): a required check counts only from ci.yml. */
23
+ workflowPath?: string | null;
24
+ /** GitHub's job record shows this unsuccessful attempt ended before any workflow step ran (release-adapters.ts). */
25
+ beforeTests?: boolean | null;
22
26
  };
23
27
  /** The plane's own look at GitHub CI for one commit (release-adapters.ts makes it, right before the seal). */
24
28
  export type CiObservation = {
@@ -68,15 +72,15 @@ export declare function authorizeAutoCheck(store: Store, input: {
68
72
  * person. Null when the path may ship.
69
73
  */
70
74
  export declare function autoPathProblem(path: string): string | null;
71
- /** Flakes: how many failed attempts one CI job, and how many unsuccessful release-check runs, may have before a person
72
- * decides. The head and digest are pinned, so between attempts only nondeterminism differs. */
73
- export declare const MAX_FAILED_ATTEMPTS = 2;
74
- /** One CI attempt that failed before its job's latest attempt passed, as the evidence records it. */
75
+ /** One CI attempt that failed before its job's latest attempt passed (GitHub showed its runner failing before the
76
+ * tests ran), as the evidence records it. */
75
77
  export type CiFailure = CheckFailure;
76
78
  /**
77
79
  * GitHub CI on the exact head, as the plane read it (every attempt): fresh, every run GitHub Actions, and the same
78
80
  * verdict every later release boundary uses (release.ts prChecksVerdict: exact head, each job by its latest attempt,
79
- * every required check present), with the flake cap. The tolerated failures are returned for the evidence.
81
+ * every required check present), where an earlier failure stands only as confirmed infrastructure (GitHub's job record
82
+ * shows it ended before any workflow step ran), at most INFRA_RERUNS_PER_CANDIDATE of them. The tolerated failures are
83
+ * returned for the evidence.
80
84
  */
81
85
  export declare function ciVerdict(observation: CiObservation, candidate: string, now: Date): {
82
86
  ok: true;
@@ -101,6 +105,8 @@ export declare function sealAutoGreen(store: Store, input: {
101
105
  ci: CiObservation;
102
106
  evidenceRoot: string;
103
107
  now: Date;
108
+ /** Whether main's protection holds for the account that merges (release-adapters.ts readMainProtection), as the plane's gh read it just now (null: unreadable). */
109
+ mainUpToDate: boolean | null;
104
110
  }): {
105
111
  ok: true;
106
112
  by: string;
@@ -117,8 +123,8 @@ export declare function giveUpAutoApproval(store: Store, input: {
117
123
  why: string;
118
124
  now: Date;
119
125
  }): void;
120
- /** A release check failed while checking automatically: tell the owner. The `auto-check` stays, so a regate of the same
121
- * commit and digest (a flake) may still approve itself, within MAX_FAILED_ATTEMPTS failed runs. */
126
+ /** A release check failed while checking automatically: tell the owner. The `auto-check` stays: a rerun the release
127
+ * itself made after confirmed infrastructure may still approve itself; any other failure means a person decides. */
122
128
  export declare function noteAutoCheckFailed(store: Store, input: {
123
129
  taskId: string;
124
130
  why: string;
@@ -151,3 +157,28 @@ export declare function allowAutoPublish(store: Store, input: {
151
157
  }): {
152
158
  ok: true;
153
159
  } | Refusal;
160
+ /**
161
+ * A release filed while its pull request's CI still ran (pipeline-speed.md D2): hold every approval but the automatic
162
+ * road's until the plane reads exact-head CI passing (Codex review round 1, finding 4). Recorded before the task is
163
+ * filed, so no moment exists where the filed task is approvable with CI pending. Idempotent.
164
+ */
165
+ export declare function holdApprovalForCi(store: Store, input: {
166
+ taskId: string;
167
+ candidate: string;
168
+ now: Date;
169
+ }): void;
170
+ /**
171
+ * Lift a release's CI hold (an older coordinator's) or end its "CI still running" note on the plane's own reading of
172
+ * GitHub CI (never the coordinator's word): fresh, whole, every run GitHub Actions, on exactly the held candidate, and
173
+ * every required check passed (release.ts prChecksVerdict, as a person's road judges it: a person decides about earlier
174
+ * attempts). With `ask`, the owner of a held release is told it needs them now. Nothing held or noted is a pass.
175
+ */
176
+ export declare function liftCiHold(store: Store, input: {
177
+ taskId: string;
178
+ candidate: string;
179
+ ci: CiObservation;
180
+ ask: boolean;
181
+ now: Date;
182
+ }): {
183
+ ok: true;
184
+ } | Refusal;