Public: anyone can read this; the crew can change it.
Verify after landing: the full suite runs on main on a schedule, names the culprit, and proposes the revert #7813
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7786-verify-after-landing-the-full-suite-runs → main 4 commits fork
21 files
changed +1276
−20
docs/OPERATING.md +2
Diff not shown — a line 2,328 characters wide (limit 2,000). +2
View filedocs/design/areas/boards.md +4
| @@ -8,3 +8,7 @@ Drawn from existing components only: `card` with an icon head, `row` per hold | ||
| 8 | 8 | repositories the viewer administers and shows when there are none, because it |
| 9 | 9 | holds the form. A person who administers a repository but is not a forge |
| 10 | 10 | admin sees `/boards` as this card alone. |
| 11 | ||
| 12 | ## Verified after landing (#7786) | |
| 13 | ||
| 14 | The card under the Pipeline table (`id="verified"`) adds no class: it is a `card()` with an `<h2>`, one `<p>` per repository in the default text colour, the repository and its run as plain `<a>` links, `small` for the run link. It is server-rendered, needs no JavaScript, and has no accent of its own. |
forge.json +1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | "check": { |
| 18 | 18 | "fast": "bun scripts/gate.ts && mkdir -p .forge-cache && GOMEMLIMIT=2GiB ./node_modules/typescript/bin/tsc --noEmit --incremental --tsBuildInfoFile .forge-cache/tsc.tsbuildinfo && bun scripts/test-shards.ts --timeout 60000 --tier fast --diff $FORGE_CHECK_BASE..HEAD --learned \"$FORGE_CHECK_LEARNED\"", |
| 19 | 19 | "run": "bun scripts/gate.ts && mkdir -p .forge-cache && GOMEMLIMIT=2GiB ./node_modules/typescript/bin/tsc --noEmit --incremental --tsBuildInfoFile .forge-cache/tsc.tsbuildinfo", |
| 20 | "verify": "bun scripts/gate.ts && bun scripts/test-shards.ts --timeout 60000 --gate --impact --keep-going --nice", | |
| 20 | 21 | "timeout": 7200, |
| 21 | 22 | "limits": { |
| 22 | 23 | "memory": 8192, |
scripts/test-shards.ts +56 −8
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * bun scripts/test-shards.ts [bun test flags…] --tier fast --diff <base>..<head> |
| 8 | 8 | * bun scripts/test-shards.ts --record [bun test flags…] test/a.test.ts … |
| 9 | 9 | * bun scripts/test-shards.ts [bun test flags…] -- a b … (test/a.test.ts …) |
| 10 | * bun scripts/test-shards.ts [bun test flags…] --gate --impact --keep-going --nice | |
| 10 | 11 | * |
| 11 | 12 | * `--gate` runs the files `scripts/gate.ts` admits (every test file not |
| 12 | 13 | * excluded), which is how |
| @@ -26,6 +27,14 @@ | ||
| 26 | 27 | * clock — unless every red in it is a budget test, which the forge re-runs |
| 27 | 28 | * once after the gate (#7354). Exits non-zero if any shard does. |
| 28 | 29 | * |
| 30 | * `--keep-going` is the verification after landing (#7786): no shard stops | |
| 31 | * the rest, a file red on its own is run once more alone (green then, it | |
| 32 | * is named flaky and does not fail the run), and a measuring run writes | |
| 33 | * the impact of every file that ended green even when another is red. | |
| 34 | * `--nice` runs it all at the lowest CPU priority, so the checks a landing | |
| 35 | * waits on go first. `FORGE_CHECK_ONLY` (comma-separated test files, set | |
| 36 | * by the forge for a bisection step) keeps only those files. | |
| 37 | * | |
| 29 | 38 | * The whole gate, unsliced (`--gate` on the full tier, as `check.run`), |
| 30 | 39 | * runs one file per `bun test`, heaviest first into whichever worker is |
| 31 | 40 | * free, so every file is timed alone; when it is green it writes those |
| @@ -40,7 +49,7 @@ | ||
| 40 | 49 | * table is rewritten from those times when every file is green. |
| 41 | 50 | */ |
| 42 | 51 | |
| 43 | import { availableParallelism, tmpdir, totalmem } from "node:os"; | |
| 52 | import { availableParallelism, setPriority, tmpdir, totalmem } from "node:os"; | |
| 44 | 53 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 45 | 54 | import { join } from "node:path"; |
| 46 | 55 | import { resolveLimits } from "../src/apps/contain.ts"; |
| @@ -74,6 +83,7 @@ import { importsReader, readImpact } from "./tier-input.ts"; | ||
| 74 | 83 | import { fastSet, duration } from "../src/checks/tiers.ts"; |
| 75 | 84 | import { CHECK_CACHE } from "../src/checks/cache.ts"; |
| 76 | 85 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 86 | import { FLAKY_ALONE, onlyFiles } from "../src/checks/verify.ts"; | |
| 77 | 87 | |
| 78 | 88 | const TIMES = new URL("./test-times.json", import.meta.url).pathname; |
| 79 | 89 | // The forge hands the map over in the checkout (`src/checks/impact-store.ts`): |
| @@ -90,6 +100,15 @@ const argv = process.argv.slice(2); | ||
| 90 | 100 | const record = argv.includes("--record"); |
| 91 | 101 | const gate = argv.includes("--gate"); |
| 92 | 102 | const measure = argv.includes("--impact"); |
| 103 | const keepGoing = argv.includes("--keep-going"); | |
| 104 | const nice = argv.includes("--nice"); | |
| 105 | // Every `bun test` this spawns inherits it. | |
| 106 | if (nice) | |
| 107 | try { | |
| 108 | setPriority(19); | |
| 109 | } catch (error) { | |
| 110 | console.log(`test-shards: --nice refused (${String(error)}); running at normal priority`); | |
| 111 | } | |
| 93 | 112 | |
| 94 | 113 | /** |
| 95 | 114 | * Measured seconds where a table has them, bytes scaled to seconds where |
| @@ -175,7 +194,7 @@ function takeValue( | ||
| 175 | 194 | } |
| 176 | 195 | |
| 177 | 196 | const tierArg = takeValue( |
| 178 | argv.filter((arg) => arg !== "--record" && arg !== "--gate" && arg !== "--impact"), | |
| 197 | argv.filter((arg) => !["--record", "--gate", "--impact", "--keep-going", "--nice"].includes(arg)), | |
| 179 | 198 | "--tier", |
| 180 | 199 | ); |
| 181 | 200 | const diffArg = takeValue(tierArg.rest, "--diff"); |
| @@ -288,6 +307,13 @@ if (tier === "fast") { | ||
| 288 | 307 | files = plan.files; |
| 289 | 308 | } |
| 290 | 309 | |
| 310 | const only = onlyFiles(process.env["FORGE_CHECK_ONLY"]); | |
| 311 | if (only !== undefined) { | |
| 312 | const named = new Set(only); | |
| 313 | files = files.filter((file) => named.has(file) || named.has(file.replace(/^\.\//, ""))); | |
| 314 | console.log(`test-shards: FORGE_CHECK_ONLY keeps ${files.length} of ${only.length} named file(s)`); | |
| 315 | } | |
| 316 | ||
| 291 | 317 | if (files.length === 0) { |
| 292 | 318 | console.error("test-shards: name the test files to run, or --gate"); |
| 293 | 319 | process.exit(2); |
| @@ -505,7 +531,22 @@ async function runShard( | ||
| 505 | 531 | : undefined; |
| 506 | 532 | const first = await run(paths, cover, through); |
| 507 | 533 | let code = first.code; |
| 508 | const output = first.output; | |
| 534 | let output = first.output; | |
| 535 | // Kept going, a file red on its own is run once more alone: green then, | |
| 536 | // it was a flake, and its first output (the `(fail)` lines) is set aside. | |
| 537 | if (code !== 0 && keepGoing && slice.length === 1) { | |
| 538 | // The red run's coverage is not the green one's. | |
| 539 | if (covDir) { | |
| 540 | rmSync(covDir, { recursive: true, force: true }); | |
| 541 | mkdirSync(covDir); | |
| 542 | } | |
| 543 | const again = await run(paths, cover, through); | |
| 544 | if (again.code === 0) { | |
| 545 | code = 0; | |
| 546 | output = [...again.output, `test-shards: ${FLAKY_ALONE} ${slice[0]}\n`]; | |
| 547 | through?.(output.at(-1)!); | |
| 548 | } else output = again.output; | |
| 549 | } | |
| 509 | 550 | // A sliced shard that went red: each failing file (the first few) is run |
| 510 | 551 | // once alone, same env and flags. One that passes alone was red only |
| 511 | 552 | // beside others, and does not fail the check; the log names it. |
| @@ -534,7 +575,10 @@ async function runShard( | ||
| 534 | 575 | ? `${text.endsWith("\n") || text === "" ? "" : "\n"}── shard ${where} done (exit ${code}, ${seconds}s)\n` |
| 535 | 576 | : `\n── shard ${where} (exit ${code}, ${seconds}s): ${slice.join(" ")}\n${text}${text.endsWith("\n") ? "" : "\n"}`, |
| 536 | 577 | ); |
| 537 | if (covDir) { | |
| 578 | if (covDir && keepGoing && code !== 0) { | |
| 579 | // A red file's coverage is not its impact; the map keeps its old row. | |
| 580 | rmSync(covDir, { recursive: true, force: true }); | |
| 581 | } else if (covDir) { | |
| 538 | 582 | try { |
| 539 | 583 | measured.set(slice[0]!, executedFiles(readFileSync(join(covDir, "lcov.info"), "utf8"))); |
| 540 | 584 | } catch { |
| @@ -563,8 +607,9 @@ async function pool(width: number): Promise<Timed[]> { | ||
| 563 | 607 | const index = next++; |
| 564 | 608 | if (stop) break; |
| 565 | 609 | results[index] = await runShard(slices[index]!, index, width === 1); |
| 566 | // A shard red only on budget tests lets the rest run (#7354). | |
| 567 | if (stopsTheRest(results[index]!)) { | |
| 610 | // A shard red only on budget tests lets the rest run (#7354); | |
| 611 | // a verification after landing runs everything (#7786). | |
| 612 | if (!keepGoing && stopsTheRest(results[index]!)) { | |
| 568 | 613 | stop = true; |
| 569 | 614 | halted = true; |
| 570 | 615 | for (const child of children) child.kill("SIGTERM"); |
| @@ -622,8 +667,11 @@ if (record || timed) { | ||
| 622 | 667 | } |
| 623 | 668 | } |
| 624 | 669 | if (measure) { |
| 625 | if (ending.code !== 0 || unmeasured.size > 0) { | |
| 626 | console.log(`test-shards: not writing ${IMPACT_NAME}: the run was not green and measured`); | |
| 670 | if ((ending.code !== 0 && !keepGoing) || unmeasured.size > 0 || measured.size === 0) { | |
| 671 | console.log( | |
| 672 | `test-shards: not writing ${IMPACT_NAME}: the run was not green and measured` + | |
| 673 | (unmeasured.size > 0 ? ` (no coverage from ${[...unmeasured].join(", ")})` : ""), | |
| 674 | ); | |
| 627 | 675 | } else { |
| 628 | 676 | // A partial run refreshes what it ran and forgets nothing else. |
| 629 | 677 | let old = new Map<string, string[]>(); |
src/apps/manifest.ts +9
| @@ -311,6 +311,12 @@ export interface CheckManifest { | ||
| 311 | 311 | readonly fast: string | null; |
| 312 | 312 | /** The full tier, run once when a pull lands. Defaults to `run`. */ |
| 313 | 313 | readonly full: string; |
| 314 | /** | |
| 315 | * The verification after landing (#7786): the whole suite, run on the | |
| 316 | * default branch on a schedule, never as a landing's requirement. `null`: | |
| 317 | * nothing verifies main after it lands. | |
| 318 | */ | |
| 319 | readonly verify: string | null; | |
| 314 | 320 | /** Seconds the check may take before it fails. */ |
| 315 | 321 | readonly timeout: number; |
| 316 | 322 | /** Hosts its steps may reach besides the forge and the npm registry. */ |
| @@ -347,6 +353,8 @@ export function parseCheck( | ||
| 347 | 353 | if (!fast.ok) return fast; |
| 348 | 354 | const full = command(check, "full", "check"); |
| 349 | 355 | if (!full.ok) return full; |
| 356 | const verify = command(check, "verify", "check"); | |
| 357 | if (!verify.ok) return verify; | |
| 350 | 358 | const pkg = packageJson === undefined ? undefined : readPackage(packageJson); |
| 351 | 359 | if (pkg !== undefined && !pkg.ok) return pkg; |
| 352 | 360 | const timeout = check["timeout"]; |
| @@ -368,6 +376,7 @@ export function parseCheck( | ||
| 368 | 376 | run: run.value, |
| 369 | 377 | fast: fast.value || null, |
| 370 | 378 | full: full.value || run.value, |
| 379 | verify: verify.value || null, | |
| 371 | 380 | egress: egress.value, |
| 372 | 381 | limits: limits.value, |
| 373 | 382 | install: |
src/checks/culprit.ts added +211
| @@ -0,0 +1,211 @@ | ||
| 1 | /** | |
| 2 | * What a finished verification after landing does next (#7786, | |
| 3 | * `checks/verify.ts`). Green: nothing. Red, with the test files it failed | |
| 4 | * named: the range since the last green verification is bisected one step | |
| 5 | * at a time — each step a run of only those files, in the verification | |
| 6 | * lane — until one commit is left. That commit is the culprit: the landed | |
| 7 | * pull it is gets its revert proposed (a person lands it, as every revert), | |
| 8 | * a push no pull carried gets one issue. A red whose files were already red | |
| 9 | * at the verification before it was named there and starts nothing. | |
| 10 | * | |
| 11 | * Called by the checks service with the way it asks for a run; the answer | |
| 12 | * is the lines for the finished run's log. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { revertPull } from "../actions/revert.ts"; | |
| 17 | import { createIssue } from "../core/issues.ts"; | |
| 18 | import { getPull, type Pull } from "../core/pulls.ts"; | |
| 19 | import type { Repo } from "../core/repos.ts"; | |
| 20 | import { getRun, type Run } from "../core/runs.ts"; | |
| 21 | import { getUserById } from "../core/users.ts"; | |
| 22 | import { runGit } from "../git/env.ts"; | |
| 23 | import { isSafeOid } from "../git/guard.ts"; | |
| 24 | import { repoDir } from "../git/repos.ts"; | |
| 25 | import { everReverted } from "./revert_red.ts"; | |
| 26 | import { | |
| 27 | BISECT_ENV, | |
| 28 | BISECT_PREFIX, | |
| 29 | bisectOrigin, | |
| 30 | MAX_RANGE, | |
| 31 | maxSteps, | |
| 32 | nextStep, | |
| 33 | VERIFY_ENV, | |
| 34 | } from "./verify.ts"; | |
| 35 | ||
| 36 | /** How the service asks for a bisection step. */ | |
| 37 | export type Ask = (repo: Repo, env: string, commitOid: string, reason: string) => Promise<Run | undefined>; | |
| 38 | ||
| 39 | /** The machine says so: the author is the owner, the text is not theirs. */ | |
| 40 | const FILED_BY = "_Filed by the forge's verification after landing, not written by a person._"; | |
| 41 | ||
| 42 | const short = (oid: string): string => oid.slice(0, 7); | |
| 43 | ||
| 44 | ||
| 45 | /** The last green verification before run `below`. */ | |
| 46 | function greenBefore(app: App, repoId: string, below: string): Run | undefined { | |
| 47 | const row = app.db | |
| 48 | .query<{ id: string }, [string, string]>( | |
| 49 | `SELECT id FROM run WHERE repo_id = ?1 AND kind = 'check' AND env = '${VERIFY_ENV}' | |
| 50 | AND state = 'ok' AND id < ?2 ORDER BY id DESC LIMIT 1`, | |
| 51 | ) | |
| 52 | .get(repoId, below); | |
| 53 | return row === null ? undefined : getRun(app.db, row.id); | |
| 54 | } | |
| 55 | ||
| 56 | /** The finished verification just before run `below`, green or red. */ | |
| 57 | function finishedBefore(app: App, repoId: string, below: string): Run | undefined { | |
| 58 | const row = app.db | |
| 59 | .query<{ id: string }, [string, string]>( | |
| 60 | `SELECT id FROM run WHERE repo_id = ?1 AND kind = 'check' AND env = '${VERIFY_ENV}' | |
| 61 | AND state IN ('ok','failed') AND id < ?2 ORDER BY id DESC LIMIT 1`, | |
| 62 | ) | |
| 63 | .get(repoId, below); | |
| 64 | return row === null ? undefined : getRun(app.db, row.id); | |
| 65 | } | |
| 66 | ||
| 67 | /** What each finished step of `origin`'s bisection said, by commit: green or not. */ | |
| 68 | function stepsOf(app: App, repoId: string, origin: string): { green: Map<string, boolean>; vague: Run | undefined } { | |
| 69 | const rows = app.db | |
| 70 | .query<{ id: string }, [string, string]>( | |
| 71 | `SELECT id FROM run WHERE repo_id = ?1 AND kind = 'check' AND env = '${BISECT_ENV}' | |
| 72 | AND reason = ?2 AND state IN ('ok','failed') ORDER BY id`, | |
| 73 | ) | |
| 74 | .all(repoId, `${BISECT_PREFIX}${origin}`); | |
| 75 | const green = new Map<string, boolean>(); | |
| 76 | let vague: Run | undefined; | |
| 77 | for (const { id } of rows) { | |
| 78 | const step = getRun(app.db, id); | |
| 79 | if (step === undefined) continue; | |
| 80 | // A step red for a reason that names no test (an install, the budget) | |
| 81 | // says nothing about the files: the bisection cannot go on from it. | |
| 82 | if (step.state === "failed" && (step.checkDetail?.files ?? []).length === 0) vague = step; | |
| 83 | green.set(step.commitOid, step.state === "ok"); | |
| 84 | } | |
| 85 | return { green, vague }; | |
| 86 | } | |
| 87 | ||
| 88 | /** First-parent commits after `green` up to `red`, oldest first; undefined when git cannot say. */ | |
| 89 | async function rangeOf(app: App, repo: Repo, green: string, red: string): Promise<string[] | undefined> { | |
| 90 | if (!isSafeOid(green) || !isSafeOid(red)) return undefined; | |
| 91 | const log = await runGit(["rev-list", "--first-parent", "--reverse", `${green}..${red}`], { | |
| 92 | cwd: repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 93 | }); | |
| 94 | return log.ok ? log.value.split("\n").filter(Boolean) : undefined; | |
| 95 | } | |
| 96 | ||
| 97 | /** The pull whose landing is `oid` on the default branch, if one is. */ | |
| 98 | function landingOf(app: App, repo: Repo, oid: string): Pull | undefined { | |
| 99 | const row = app.db | |
| 100 | .query<{ number: number }, [string, string, string]>( | |
| 101 | `SELECT i.number FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 102 | WHERE i.repo_id = ?1 AND p.merged_oid = ?2 AND p.base_branch = ?3`, | |
| 103 | ) | |
| 104 | .get(repo.id, oid, repo.defaultBranch); | |
| 105 | return row === null ? undefined : getPull(app.db, repo.id, row.number); | |
| 106 | } | |
| 107 | ||
| 108 | /** What the red is, in a sentence: its first failing test and how many more. */ | |
| 109 | function redSays(red: Run): string { | |
| 110 | const names = red.checkDetail?.names ?? []; | |
| 111 | const files = red.checkDetail?.files ?? []; | |
| 112 | if (names[0] !== undefined) | |
| 113 | return `"${names[0]}"${names.length > 1 ? ` and ${names.length - 1} more` : ""} in ${files.map((f) => `\`${f}\``).join(", ")}`; | |
| 114 | return files.map((f) => `\`${f}\``).join(", "); | |
| 115 | } | |
| 116 | ||
| 117 | /** One open issue per title: a second red of the same thing adds nothing. */ | |
| 118 | function fileOnce(app: App, repo: Repo, title: string, body: string): string { | |
| 119 | const open = app.db | |
| 120 | .query<{ number: number }, [string, string]>( | |
| 121 | "SELECT number FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open' LIMIT 1", | |
| 122 | ) | |
| 123 | .get(repo.id, title); | |
| 124 | if (open !== null) return `already filed: #${open.number}`; | |
| 125 | const made = createIssue(app.db, { repoId: repo.id, authorId: repo.ownerId, title, body }); | |
| 126 | if (!made.ok) return `not filed: ${made.error}`; | |
| 127 | app.bus.append({ | |
| 128 | kind: "issue.opened", | |
| 129 | repoId: repo.id, | |
| 130 | actorId: repo.ownerId, | |
| 131 | payload: { number: made.value.number }, | |
| 132 | }); | |
| 133 | return `filed #${made.value.number}: ${title}`; | |
| 134 | } | |
| 135 | ||
| 136 | /** Names `culprit` as what turned `red` red: the revert of its pull, or one issue. */ | |
| 137 | async function name(app: App, repo: Repo, red: Run, culprit: string, green: string, tried: number): Promise<string> { | |
| 138 | const files = red.checkDetail?.files ?? []; | |
| 139 | const how = | |
| 140 | tried === 0 | |
| 141 | ? `the one commit since main was last verified green at ${short(green)}` | |
| 142 | : `found by bisecting ${short(green)}..${short(red.commitOid)} in ${tried} step${tried === 1 ? "" : "s"}`; | |
| 143 | const pull = landingOf(app, repo, culprit); | |
| 144 | if (pull !== undefined) { | |
| 145 | if (everReverted(app, pull.id)) return `culprit #${pull.number} (${short(culprit)}): its revert was already proposed`; | |
| 146 | const owner = getUserById(app.db, repo.ownerId); | |
| 147 | const body = | |
| 148 | `Reverts #${pull.number}: verifying main after landing found it broke ${redSays(red)} — ${how}.\n\n` + | |
| 149 | `Main's verification ${red.id} is red at ${short(red.commitOid)}; each failed test fails alone too.\n\n` + | |
| 150 | `Proposed by the forge; a person lands it.\n\nFiles: ${files.join(", ")}`; | |
| 151 | const made = await revertPull(app, owner, repo, pull, body); | |
| 152 | return made.ok | |
| 153 | ? `culprit #${pull.number} (${short(culprit)}): proposed its revert, #${made.value.number}` | |
| 154 | : `culprit #${pull.number} (${short(culprit)}): the revert could not be proposed (${made.error})`; | |
| 155 | } | |
| 156 | const title = `Red after landing: ${short(culprit)} broke ${files[0] ?? "main"}`; | |
| 157 | return `culprit ${short(culprit)}, landed by no pull: ${fileOnce( | |
| 158 | app, | |
| 159 | repo, | |
| 160 | title, | |
| 161 | `${FILED_BY}\n\nCommit ${culprit} broke ${redSays(red)} — ${how}. No pull carried it, so there is no revert pull to propose: revert or fix it by hand.\n\nFiles: ${files.join(", ")}`, | |
| 162 | )}`; | |
| 163 | } | |
| 164 | ||
| 165 | /** A range the bisection will not walk, named in one issue. */ | |
| 166 | function unbisected(app: App, repo: Repo, red: Run, why: string): string { | |
| 167 | const files = red.checkDetail?.files ?? []; | |
| 168 | return `not bisected (${why}): ${fileOnce( | |
| 169 | app, | |
| 170 | repo, | |
| 171 | `Red after landing: ${files[0] ?? "main"} fails on ${repo.defaultBranch}`, | |
| 172 | `${FILED_BY}\n\nMain's verification ${red.id} is red at ${short(red.commitOid)}: ${redSays(red)}, failing alone too. The forge did not name the landing that broke it: ${why}.\n\nFiles: ${files.join(", ")}`, | |
| 173 | )}`; | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * The next thing after `run`, a finished verification or bisection step, | |
| 178 | * as lines for its log. Never throws into the check. | |
| 179 | */ | |
| 180 | export async function afterVerify(app: App, repo: Repo, run: Run, ask: Ask): Promise<string[]> { | |
| 181 | const red = run.env === VERIFY_ENV ? run : getRun(app.db, bisectOrigin(run.reason) ?? ""); | |
| 182 | if (red === undefined || red.state !== "failed") return []; | |
| 183 | const files = red.checkDetail?.files ?? []; | |
| 184 | if (files.length === 0) return ["not bisected: the red names no failed test file"]; | |
| 185 | if (run === red) { | |
| 186 | const before = finishedBefore(app, repo.id, red.id); | |
| 187 | const known = before?.state === "failed" ? (before.checkDetail?.files ?? []) : []; | |
| 188 | if (known.length > 0 && files.every((f) => known.includes(f))) | |
| 189 | return [`not bisected: ${files.join(", ")} already failed at ${short(before!.commitOid)}, named there`]; | |
| 190 | } | |
| 191 | const green = greenBefore(app, repo.id, red.id); | |
| 192 | if (green === undefined) return [unbisected(app, repo, red, "main was never verified green before")]; | |
| 193 | const range = await rangeOf(app, repo, green.commitOid, red.commitOid); | |
| 194 | if (range === undefined || range.length === 0) | |
| 195 | return [unbisected(app, repo, red, `git could not list ${short(green.commitOid)}..${short(red.commitOid)}`)]; | |
| 196 | if (range.length > MAX_RANGE) | |
| 197 | return [unbisected(app, repo, red, `${range.length} commits since the last green is over ${MAX_RANGE}`)]; | |
| 198 | const { green: tried, vague } = stepsOf(app, repo.id, red.id); | |
| 199 | if (vague !== undefined) | |
| 200 | return [unbisected(app, repo, red, `the step at ${short(vague.commitOid)} failed without naming a test`)]; | |
| 201 | const step = nextStep(range, tried); | |
| 202 | if (step === undefined) return []; | |
| 203 | if (step.kind === "culprit") return [await name(app, repo, red, step.commit, green.commitOid, tried.size)]; | |
| 204 | const asked = await ask(repo, BISECT_ENV, step.commit, `${BISECT_PREFIX}${red.id}`); | |
| 205 | return asked === undefined | |
| 206 | ? [`bisect: ${short(step.commit)} could not be asked for`] | |
| 207 | : [ | |
| 208 | `bisect: trying ${short(step.commit)}, ${range.length} commit(s) since ${short(green.commitOid)}, ` + | |
| 209 | `at most ${maxSteps(range.length)} step(s); run ${asked.id}`, | |
| 210 | ]; | |
| 211 | } |
src/checks/revert_red.ts +1 −1
| @@ -35,7 +35,7 @@ function greenBefore(app: App, repoId: string, branch: string, below: string): s | ||
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | /** Whether any pull, in any state, already takes `pullId` back. */ |
| 38 | function everReverted(app: App, pullId: string): boolean { | |
| 38 | export function everReverted(app: App, pullId: string): boolean { | |
| 39 | 39 | return ( |
| 40 | 40 | app.db |
| 41 | 41 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM pull WHERE reverts_issue_id = ?1") |
src/checks/service.ts +173 −10
| @@ -64,6 +64,18 @@ import { ASKED, redMain, unheld } from "./red_main.ts"; | ||
| 64 | 64 | import { proposeRevert } from "./revert_red.ts"; |
| 65 | 65 | import { rebasesFirst } from "./finishing_first.ts"; |
| 66 | 66 | import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts"; |
| 67 | import { afterVerify } from "./culprit.ts"; | |
| 68 | import { | |
| 69 | bisectOrigin, | |
| 70 | failedFiles, | |
| 71 | inVerifyLane, | |
| 72 | onlyEnv, | |
| 73 | VERIFY_ENV, | |
| 74 | VERIFY_EVERY_MS, | |
| 75 | VERIFY_REASON, | |
| 76 | VERIFY_SWEEP_MS, | |
| 77 | verifyDue, | |
| 78 | } from "./verify.ts"; | |
| 67 | 79 | import { getIssueById } from "../core/issues.ts"; |
| 68 | 80 | import { BUDGETS, budgetSummary } from "../kernel/budgets.ts"; |
| 69 | 81 | import { transact } from "../kernel/db.ts"; |
| @@ -144,6 +156,12 @@ export interface ChecksOptions { | ||
| 144 | 156 | readonly job?: JobOptions | undefined; |
| 145 | 157 | /** How long a transient install failure waits for its retry. @default INSTALL_RETRY_MS */ |
| 146 | 158 | readonly installRetryMs?: number | undefined; |
| 159 | /** | |
| 160 | * How often the default branch is verified after landing at most | |
| 161 | * (`checks/verify.ts`). Unset, no timer asks; `verifyDue` still holds | |
| 162 | * to `VERIFY_EVERY_MS`. The forge sets it to `VERIFY_EVERY_MS`. | |
| 163 | */ | |
| 164 | readonly verifyEveryMs?: number | undefined; | |
| 147 | 165 | } |
| 148 | 166 | |
| 149 | 167 | /** A gate's answer from the lane: `interrupted` is no verdict (#3155). */ |
| @@ -200,6 +218,12 @@ export interface ChecksHandle { | ||
| 200 | 218 | spareSlots(): number; |
| 201 | 219 | /** The gate checks take their turn at; a hosted child's checks share it. */ |
| 202 | 220 | readonly lane: Gate; |
| 221 | /** | |
| 222 | * Ask for every verification after landing that is due now | |
| 223 | * (`checks/verify.ts`); the answer is the runs asked for. The forge's | |
| 224 | * own timer calls it when `verifyEveryMs` is set. | |
| 225 | */ | |
| 226 | verifyDue(now?: number): Promise<Run[]>; | |
| 203 | 227 | } |
| 204 | 228 | |
| 205 | 229 | /** |
| @@ -421,6 +445,9 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 421 | 445 | >(); |
| 422 | 446 | /** Checks already told why they wait, so the log says it once. */ |
| 423 | 447 | const waiting = new Set<string>(); |
| 448 | /** The verification-lane run in flight, which holds no check slot. */ | |
| 449 | let verifying: string | undefined; | |
| 450 | const slotted = (): number => inFlight.size - (verifying === undefined ? 0 : 1); | |
| 424 | 451 | const settle = options.landSettleMs ?? 0; |
| 425 | 452 | let stopped = false; |
| 426 | 453 | |
| @@ -455,6 +482,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 455 | 482 | ]); |
| 456 | 483 | const declared = parseCheck(forgeJson, packageJson); |
| 457 | 484 | if (declared.ok && declared.value === undefined) return undefined; |
| 485 | // Main is verified after landing only where it says how (#7786). | |
| 486 | if (env === VERIFY_ENV && (!declared.ok || declared.value?.verify == null)) return undefined; | |
| 458 | 487 | // One check per environment at a time: a newer commit supersedes what |
| 459 | 488 | // is still waiting, and the same commit is not checked twice. Looked for |
| 460 | 489 | // and written in one write transaction, so a second forge process over |
| @@ -507,7 +536,9 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 507 | 536 | if (inFlight.has(run.id)) return; |
| 508 | 537 | waiting.delete(run.id); |
| 509 | 538 | const abort = new AbortController(); |
| 510 | const promise = execute(run, repo, abort.signal) | |
| 539 | const low = inVerifyLane(run.env); | |
| 540 | if (low) verifying = run.id; | |
| 541 | const promise = execute(run, repo, abort.signal, !low) | |
| 511 | 542 | .catch((error) => |
| 512 | 543 | log.error("check failed to execute", { |
| 513 | 544 | run: run.id, |
| @@ -516,6 +547,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 516 | 547 | ) |
| 517 | 548 | .finally(() => { |
| 518 | 549 | inFlight.delete(run.id); |
| 550 | if (verifying === run.id) verifying = undefined; | |
| 519 | 551 | // The slot this run held is free again: give it to whichever queued |
| 520 | 552 | // check now deserves it most, not whoever asked first. |
| 521 | 553 | schedule(); |
| @@ -565,14 +597,16 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 565 | 597 | if (stopped) return; |
| 566 | 598 | const limit = capacity(); |
| 567 | 599 | if (options.gate === undefined) gate.resize(limit); |
| 600 | startVerify(); | |
| 568 | 601 | // Every merge-queue gate in flight holds a slot, a line's first too (#6376). |
| 569 | while (inFlight.size < limit - gatesInFlight() - proofsInFlight()) { | |
| 602 | while (slotted() < limit - gatesInFlight() - proofsInFlight()) { | |
| 570 | 603 | // A pull's check waits while its main is red (`checks/red_main.ts`). |
| 571 | 604 | const queued = unheld( |
| 572 | 605 | listActive(app.db, "check").filter( |
| 573 | 606 | (run) => |
| 574 | 607 | run.state === "queued" && |
| 575 | 608 | !inFlight.has(run.id) && |
| 609 | !inVerifyLane(run.env) && | |
| 576 | 610 | (run.notBefore === null || Date.parse(run.notBefore) <= Date.now()), |
| 577 | 611 | ), |
| 578 | 612 | (repoId) => redMain(app.db, repoId), |
| @@ -585,7 +619,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 585 | 619 | const { rebases, rest: others } = rebasesFirst(app.db, queued); |
| 586 | 620 | // While a gate is about to start, the last free slot is its: a pull's |
| 587 | 621 | // check waits for it, a rebase's and a main check do not. |
| 588 | const held = holdsLastSlot(limit - gatesInFlight() - proofsInFlight() - inFlight.size, gateQueued()); | |
| 622 | const held = holdsLastSlot(limit - gatesInFlight() - proofsInFlight() - slotted(), gateQueued()); | |
| 589 | 623 | const next = startable(rebases) ?? startable(held ? others.filter((run) => run.issueId === null) : others); |
| 590 | 624 | if (!next) return; |
| 591 | 625 | const repo = getRepoById(app.db, next.repoId); |
| @@ -594,6 +628,65 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 594 | 628 | } |
| 595 | 629 | } |
| 596 | 630 | |
| 631 | /** | |
| 632 | * The verification lane (#7786): one run at a time, forge-wide, beside | |
| 633 | * the check slots rather than in one, so no landing and no pull's check | |
| 634 | * ever waits for it. A bisection step goes before a new verification. | |
| 635 | */ | |
| 636 | function startVerify(): void { | |
| 637 | if (verifying !== undefined) return; | |
| 638 | const queued = listActive(app.db, "check").filter( | |
| 639 | (run) => run.state === "queued" && inVerifyLane(run.env) && !inFlight.has(run.id), | |
| 640 | ); | |
| 641 | const next = | |
| 642 | queued.find((run) => bisectOrigin(run.reason) !== undefined) ?? queued[0]; | |
| 643 | if (next === undefined) return; | |
| 644 | const repo = getRepoById(app.db, next.repoId); | |
| 645 | if (repo !== undefined) start(next, repo); | |
| 646 | } | |
| 647 | ||
| 648 | /** Every verification due now: main moved since the last, and that one is old enough. */ | |
| 649 | async function askVerifications(now = Date.now()): Promise<Run[]> { | |
| 650 | const everyMs = options.verifyEveryMs ?? VERIFY_EVERY_MS; | |
| 651 | const since = new Date(now - 24 * 3_600_000).toISOString(); | |
| 652 | // The newest default-branch check of each repository that had one today. | |
| 653 | const tips = app.db | |
| 654 | .query<{ repo_id: string; commit_oid: string }, [string]>( | |
| 655 | `SELECT r.repo_id, r.commit_oid FROM run r JOIN repo ON repo.id = r.repo_id | |
| 656 | WHERE r.id IN (SELECT MAX(x.id) FROM run x JOIN repo y ON y.id = x.repo_id | |
| 657 | WHERE x.kind = 'check' AND x.issue_id IS NULL | |
| 658 | AND x.env = y.default_branch AND x.created_at >= ?1 | |
| 659 | GROUP BY x.repo_id)`, | |
| 660 | ) | |
| 661 | .all(since); | |
| 662 | const asked: Run[] = []; | |
| 663 | for (const tip of tips) { | |
| 664 | const last = app.db | |
| 665 | .query<{ commit_oid: string; created_at: string }, [string]>( | |
| 666 | `SELECT commit_oid, created_at FROM run | |
| 667 | WHERE repo_id = ?1 AND kind = 'check' AND env = '${VERIFY_ENV}' | |
| 668 | ORDER BY id DESC LIMIT 1`, | |
| 669 | ) | |
| 670 | .get(tip.repo_id); | |
| 671 | const active = listActive(app.db, "check").some( | |
| 672 | (run) => run.repoId === tip.repo_id && inVerifyLane(run.env), | |
| 673 | ); | |
| 674 | const due = verifyDue({ | |
| 675 | tip: tip.commit_oid, | |
| 676 | last: last === null ? undefined : { commitOid: last.commit_oid, createdAt: last.created_at }, | |
| 677 | active, | |
| 678 | now, | |
| 679 | everyMs, | |
| 680 | }); | |
| 681 | if (!due) continue; | |
| 682 | const repo = getRepoById(app.db, tip.repo_id); | |
| 683 | if (repo === undefined) continue; | |
| 684 | const run = await request(repo, VERIFY_ENV, tip.commit_oid, VERIFY_REASON); | |
| 685 | if (run !== undefined) asked.push(run); | |
| 686 | } | |
| 687 | return asked; | |
| 688 | } | |
| 689 | ||
| 597 | 690 | /** |
| 598 | 691 | * An open pull whose head was never checked — the event that should have |
| 599 | 692 | * asked landed while a forge was on its way out, or before this forge |
| @@ -639,8 +732,10 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 639 | 732 | run: Run, |
| 640 | 733 | repo: Repo, |
| 641 | 734 | signal: AbortSignal, |
| 735 | slot = true, | |
| 642 | 736 | ): Promise<void> { |
| 643 | const release = await gate.acquire(); | |
| 737 | // The verification lane takes no slot of the gate (`startVerify`). | |
| 738 | const release = slot ? await gate.acquire() : () => {}; | |
| 644 | 739 | try { |
| 645 | 740 | // A slot that opens while this forge is stopping is not taken: the run |
| 646 | 741 | // stays queued for the forge that carries on. |
| @@ -755,6 +850,11 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 755 | 850 | proposeRevert(app, repo.id).catch((error) => |
| 756 | 851 | log.error("revert proposal failed", { error: String(error) }), |
| 757 | 852 | ); |
| 853 | // A red verification after landing names its culprit (#7786). | |
| 854 | if (done && inVerifyLane(run.env)) | |
| 855 | await afterVerify(app, repo, done, (r, env, oid, reason) => request(r, env, oid, reason)) | |
| 856 | .then((lines) => lines.forEach(write)) | |
| 857 | .catch((error) => write(`culprit not named: ${String(error)}`)); | |
| 758 | 858 | } finally { |
| 759 | 859 | release(); |
| 760 | 860 | } |
| @@ -776,6 +876,28 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 776 | 876 | run.issueId === null ? undefined : getIssueById(app.db, run.issueId); |
| 777 | 877 | const pull = |
| 778 | 878 | issue?.kind === "pull" ? getPull(app.db, repo.id, issue.number) : undefined; |
| 879 | if (inVerifyLane(run.env)) { | |
| 880 | // A bisection step runs only the red's failed files, with the red's | |
| 881 | // own command: an older commit may not say how to verify (#7786). | |
| 882 | const origin = run.env === VERIFY_ENV ? undefined : getRun(app.db, bisectOrigin(run.reason) ?? ""); | |
| 883 | if (run.env !== VERIFY_ENV && origin === undefined) | |
| 884 | return { ok: false, error: "the red verification this step bisects is gone" }; | |
| 885 | return runTier({ | |
| 886 | bare, | |
| 887 | repo, | |
| 888 | id: run.id, | |
| 889 | runId: run.id, | |
| 890 | commitOid: run.commitOid, | |
| 891 | tier: "verify", | |
| 892 | ...(origin === undefined | |
| 893 | ? { keepImpact: true } | |
| 894 | : { only: origin.checkDetail?.files ?? [], commandsFrom: origin.commitOid }), | |
| 895 | fileOrderDependent: origin === undefined, | |
| 896 | isMain: true, | |
| 897 | write, | |
| 898 | signal, | |
| 899 | }); | |
| 900 | } | |
| 779 | 901 | const outcome = await runTier({ |
| 780 | 902 | bare, |
| 781 | 903 | repo, |
| @@ -802,8 +924,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 802 | 924 | readonly runId?: string; |
| 803 | 925 | readonly commitOid: string; |
| 804 | 926 | readonly pull?: Pull | undefined; |
| 805 | /** `run`: a push to a branch; `fast`: a pull's push; `full`: on land. */ | |
| 806 | readonly tier: "run" | "fast" | "full"; | |
| 927 | /** `run`: a push to a branch; `fast`: a pull's push; `full`: on land; `verify`: main after landing. */ | |
| 928 | readonly tier: "run" | "fast" | "full" | "verify"; | |
| 929 | /** A bisection step: only these test files run (`FORGE_CHECK_ONLY`). */ | |
| 930 | readonly only?: readonly string[]; | |
| 931 | /** A bisection step: the commit whose `check.verify` it runs. */ | |
| 932 | readonly commandsFrom?: string; | |
| 807 | 933 | /** A check of the default branch's own push: its cache is kept. */ |
| 808 | 934 | readonly keep?: boolean; |
| 809 | 935 | /** A green run keeps the impact map it measured; `full` always does. */ |
| @@ -902,12 +1028,20 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 902 | 1028 | ); |
| 903 | 1029 | } |
| 904 | 1030 | } |
| 1031 | if (x.commandsFrom !== undefined) { | |
| 1032 | const from = parseCheck(await readFileAt(bare, x.commandsFrom, "forge.json"), undefined); | |
| 1033 | if (from.ok && from.value !== undefined) manifest = { ...manifest, verify: from.value.verify, timeout: from.value.timeout }; | |
| 1034 | } | |
| 1035 | if (x.tier === "verify" && manifest.verify === null) | |
| 1036 | return { ok: false, error: "this commit declares no check.verify" }; | |
| 905 | 1037 | const command = |
| 906 | 1038 | x.tier === "full" |
| 907 | 1039 | ? manifest.full |
| 908 | 1040 | : x.tier === "fast" |
| 909 | 1041 | ? (manifest.fast ?? manifest.run) |
| 910 | : manifest.run; | |
| 1042 | : x.tier === "verify" | |
| 1043 | ? manifest.verify! | |
| 1044 | : manifest.run; | |
| 911 | 1045 | // The fast tier diffs what the pull changes against the base's tip, |
| 912 | 1046 | // which the clone is given as a repository of its own: `origin/main` is |
| 913 | 1047 | // not a ref there. |
| @@ -917,7 +1051,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 917 | 1051 | const learned = |
| 918 | 1052 | base === undefined ? "" : readLearned(app.config.dataDir, x.pull!.id).join(","); |
| 919 | 1053 | const tree = await runGit(["rev-parse", `${run.commitOid}^{tree}`], { cwd: bare }); |
| 920 | const key = tree.ok | |
| 1054 | // A bisection step runs a slice: its green proves nothing of the whole. | |
| 1055 | const key = tree.ok && x.only === undefined | |
| 921 | 1056 | ? checkKey({ |
| 922 | 1057 | repoId: repo.id, |
| 923 | 1058 | treeOid: tree.value.trim(), |
| @@ -1023,6 +1158,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1023 | 1158 | FORGE_CHECK: run.id, |
| 1024 | 1159 | FORGE_COMMIT: x.commitOid, |
| 1025 | 1160 | FORGE_CHECK_IMPACT: IMPACT_FILE, |
| 1161 | ...(x.only === undefined ? {} : { FORGE_CHECK_ONLY: onlyEnv(x.only) }), | |
| 1026 | 1162 | // The cell's CPU limit, which `availableParallelism()` may not |
| 1027 | 1163 | // report inside it; the shard count follows this (#2257). |
| 1028 | 1164 | FORGE_CELL_CPUS: String(cellCpus), |
| @@ -1255,7 +1391,24 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1255 | 1391 | ran.error, |
| 1256 | 1392 | ); |
| 1257 | 1393 | const killed = killedStep(ran.error); |
| 1258 | return { ok: false, error: killed ? ran.error : summary, detail, stage: checkStage(ran.error), ...(killed ? { killed } : {}) }; | |
| 1394 | // A red verification keeps the map it measured all the same, and | |
| 1395 | // names the files it failed in for the bisection (#7786). | |
| 1396 | const files = x.tier === "verify" ? failedFiles(checkLines) : []; | |
| 1397 | if (x.tier === "verify" && x.keepImpact === true) { | |
| 1398 | try { | |
| 1399 | const kept = keepImpact(app.config.dataDir, repo.id, dir); | |
| 1400 | if (kept !== undefined) write(kept); | |
| 1401 | } catch (error) { | |
| 1402 | write(`impact map not kept: ${String(error)}`); | |
| 1403 | } | |
| 1404 | } | |
| 1405 | return { | |
| 1406 | ok: false, | |
| 1407 | error: killed ? ran.error : summary, | |
| 1408 | detail: files.length > 0 ? { ...(detail ?? { passed: 0, failed: files.length, names: [] }), files } : detail, | |
| 1409 | stage: checkStage(ran.error), | |
| 1410 | ...(killed ? { killed } : {}), | |
| 1411 | }; | |
| 1259 | 1412 | } |
| 1260 | 1413 | // Only a green check of the default branch is kept for the next. |
| 1261 | 1414 | if ( |
| @@ -1460,6 +1613,14 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1460 | 1613 | const timer = setInterval(() => { |
| 1461 | 1614 | if (!stopped) schedule(); |
| 1462 | 1615 | }, TICK_MS); |
| 1616 | // Main is verified after landing on a schedule, when the forge says how often. | |
| 1617 | const sweeper = | |
| 1618 | options.verifyEveryMs === undefined | |
| 1619 | ? undefined | |
| 1620 | : setInterval(() => { | |
| 1621 | if (!stopped) | |
| 1622 | askVerifications().catch((error) => log.error("verification not asked", { error: String(error) })); | |
| 1623 | }, Math.min(VERIFY_SWEEP_MS, options.verifyEveryMs)); | |
| 1463 | 1624 | // A gate's slot frees: the pull check held for it starts now, not at the tick. |
| 1464 | 1625 | const unwatch = watchGates(schedule); |
| 1465 | 1626 | schedule(); |
| @@ -1529,11 +1690,13 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1529 | 1690 | for (const g of gates.values()) if (g.commitOid === commitOid) return g.done; |
| 1530 | 1691 | return undefined; |
| 1531 | 1692 | }, |
| 1532 | spareSlots: () => Math.max(0, capacity() - PULL_RESERVE - inFlight.size - extraGates() - proofsInFlight()), | |
| 1693 | spareSlots: () => Math.max(0, capacity() - PULL_RESERVE - slotted() - extraGates() - proofsInFlight()), | |
| 1694 | verifyDue: (now) => askVerifications(now), | |
| 1533 | 1695 | lane: gate, |
| 1534 | 1696 | stop: async (stopOptions) => { |
| 1535 | 1697 | stopped = true; |
| 1536 | 1698 | clearInterval(timer); |
| 1699 | if (sweeper !== undefined) clearInterval(sweeper); | |
| 1537 | 1700 | unsubscribe(); |
| 1538 | 1701 | unwatch(); |
| 1539 | 1702 | // A roll waits for the checks under way, up to the budget, and only |
src/checks/verify.ts added +139
| @@ -0,0 +1,139 @@ | ||
| 1 | /** | |
| 2 | * Verify after landing (#7786). Landing is judged on lint and types; the | |
| 3 | * whole suite runs after, on the default branch, on a schedule: at most | |
| 4 | * once per {@link VERIFY_EVERY_MS}, only when main has moved since the last | |
| 5 | * verification, in a lane of its own that no landing waits on. Its run | |
| 6 | * measures the impact map (`--impact`) the fast tier selects by. | |
| 7 | * | |
| 8 | * A verification that stays red after its failed tests are re-run alone | |
| 9 | * names the landing that broke it: the first-parent commits between the | |
| 10 | * last green verification and the red one are bisected, each step running | |
| 11 | * only the failed files, until one commit is left. That commit is a landed | |
| 12 | * pull (its revert is proposed) or a push no pull carried (one issue). | |
| 13 | * | |
| 14 | * Plain data in, plain data out: when one is due, which commit to try | |
| 15 | * next, which files a log says failed. The service runs them. | |
| 16 | */ | |
| 17 | ||
| 18 | /** The environment a scheduled verification of the default branch runs in. */ | |
| 19 | export const VERIFY_ENV = "verify"; | |
| 20 | /** The environment a bisection step runs in. */ | |
| 21 | export const BISECT_ENV = "verify-bisect"; | |
| 22 | /** A bisection step's reason: this prefix and the id of the red verification it serves. */ | |
| 23 | export const BISECT_PREFIX = "bisect:"; | |
| 24 | /** The reason a scheduled verification carries. */ | |
| 25 | export const VERIFY_REASON = "schedule"; | |
| 26 | ||
| 27 | /** At most one verification per repository this often. */ | |
| 28 | export const VERIFY_EVERY_MS = 30 * 60_000; | |
| 29 | /** How often the forge looks for a verification that is due. */ | |
| 30 | export const VERIFY_SWEEP_MS = 60_000; | |
| 31 | /** A range wider than this is not bisected: it is named in one issue. */ | |
| 32 | export const MAX_RANGE = 256; | |
| 33 | /** At most this many failed files are carried into a bisection. */ | |
| 34 | export const MAX_FILES = 20; | |
| 35 | ||
| 36 | /** Whether a check run belongs to the verification lane. */ | |
| 37 | export const inVerifyLane = (env: string): boolean => env === VERIFY_ENV || env === BISECT_ENV; | |
| 38 | ||
| 39 | /** The red verification a bisection step serves, from its reason. */ | |
| 40 | export function bisectOrigin(reason: string): string | undefined { | |
| 41 | if (!reason.startsWith(BISECT_PREFIX)) return undefined; | |
| 42 | const id = reason.slice(BISECT_PREFIX.length); | |
| 43 | return /^[0-9A-Za-z]{1,40}$/.test(id) ? id : undefined; | |
| 44 | } | |
| 45 | ||
| 46 | export interface Due { | |
| 47 | /** The default branch's newest checked commit. */ | |
| 48 | readonly tip: string; | |
| 49 | /** The last verification asked for, if any. */ | |
| 50 | readonly last?: { readonly commitOid: string; readonly createdAt: string } | undefined; | |
| 51 | /** A verification or bisection is queued or running for this repository. */ | |
| 52 | readonly active: boolean; | |
| 53 | readonly now: number; | |
| 54 | readonly everyMs: number; | |
| 55 | } | |
| 56 | ||
| 57 | /** Whether to ask for a verification of `tip` now. */ | |
| 58 | export function verifyDue(d: Due): boolean { | |
| 59 | if (d.active || d.tip === "") return false; | |
| 60 | if (d.last === undefined) return true; | |
| 61 | if (d.last.commitOid === d.tip) return false; | |
| 62 | return d.now - Date.parse(d.last.createdAt) >= d.everyMs; | |
| 63 | } | |
| 64 | ||
| 65 | export type Step = | |
| 66 | | { readonly kind: "test"; readonly commit: string } | |
| 67 | | { readonly kind: "culprit"; readonly commit: string }; | |
| 68 | ||
| 69 | /** | |
| 70 | * The next step of a bisection. `range` is the first-parent commits after | |
| 71 | * the last green verification, oldest first; its last is the red one. | |
| 72 | * `green` maps a commit already tried to whether the failed files passed | |
| 73 | * there. The culprit is the oldest red commit whose parent in the range is | |
| 74 | * green (or is the green verification itself). | |
| 75 | */ | |
| 76 | export function nextStep(range: readonly string[], green: ReadonlyMap<string, boolean>): Step | undefined { | |
| 77 | if (range.length === 0) return undefined; | |
| 78 | let lo = -1; | |
| 79 | for (let i = 0; i < range.length - 1; i++) if (green.get(range[i]!) === true) lo = i; | |
| 80 | let hi = range.length - 1; | |
| 81 | for (let i = lo + 1; i < hi; i++) | |
| 82 | if (green.get(range[i]!) === false) { | |
| 83 | hi = i; | |
| 84 | break; | |
| 85 | } | |
| 86 | if (hi - lo === 1) return { kind: "culprit", commit: range[hi]! }; | |
| 87 | return { kind: "test", commit: range[Math.floor((lo + hi) / 2)]! }; | |
| 88 | } | |
| 89 | ||
| 90 | /** The most steps a range of `n` commits can take: ⌈log₂ n⌉. */ | |
| 91 | export const maxSteps = (n: number): number => (n <= 1 ? 0 : Math.ceil(Math.log2(n))); | |
| 92 | ||
| 93 | const HEADER = /^(\S+\.test\.[cm]?[jt]sx?):$/; | |
| 94 | const FAILED = /^\(fail\) /; | |
| 95 | const PATH = /^[\w@.+][\w@./+-]{0,199}$/; | |
| 96 | ||
| 97 | /** What `scripts/test-shards.ts --keep-going` says of a file red once and green alone. */ | |
| 98 | export const FLAKY_ALONE = "red once, green alone:"; | |
| 99 | ||
| 100 | /** | |
| 101 | * The test files a `bun test` log printed a failure under, once each, in | |
| 102 | * order, at most {@link MAX_FILES}. A path that could be read as a flag or | |
| 103 | * climbs out of the checkout is left out: the log is repository output. | |
| 104 | * A file the runner says went green alone is not one of them. | |
| 105 | */ | |
| 106 | export function failedFiles(lines: readonly string[]): string[] { | |
| 107 | const found: string[] = []; | |
| 108 | const flaky = new Set<string>(); | |
| 109 | let current: string | undefined; | |
| 110 | for (const line of lines) { | |
| 111 | const at = line.indexOf(FLAKY_ALONE); | |
| 112 | if (at >= 0) flaky.add(line.slice(at + FLAKY_ALONE.length).trim().replace(/^\.\//, "")); | |
| 113 | } | |
| 114 | for (const line of lines) { | |
| 115 | const header = HEADER.exec(line); | |
| 116 | if (header) current = header[1]!.replace(/^\.\//, ""); | |
| 117 | else if (FAILED.test(line) && current !== undefined && safeFile(current) && !flaky.has(current) && !found.includes(current)) { | |
| 118 | found.push(current); | |
| 119 | if (found.length >= MAX_FILES) break; | |
| 120 | } | |
| 121 | } | |
| 122 | return found; | |
| 123 | } | |
| 124 | ||
| 125 | /** A test file path the forge hands a check to run alone. */ | |
| 126 | export const safeFile = (path: string): boolean => PATH.test(path) && !path.split("/").includes(".."); | |
| 127 | ||
| 128 | /** `FORGE_CHECK_ONLY`: the files a bisection step runs, comma-separated. */ | |
| 129 | export const onlyEnv = (files: readonly string[]): string => files.filter(safeFile).join(","); | |
| 130 | ||
| 131 | /** The files `FORGE_CHECK_ONLY` names, the unsafe left out; undefined when unset. */ | |
| 132 | export function onlyFiles(value: string | undefined): string[] | undefined { | |
| 133 | if (value === undefined) return undefined; | |
| 134 | return value | |
| 135 | .split(",") | |
| 136 | .map((file) => file.trim()) | |
| 137 | .filter((file) => file !== "" && safeFile(file)) | |
| 138 | .slice(0, MAX_FILES); | |
| 139 | } |
src/core/migration-pins/20261101130000-run-verify.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 3b443eee32968424d43c9328895e24d661770c98baaa37864d24cbe182d73fc2 |
src/core/migrations/20261101130000-run-verify.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // Main verified after landing (#7786): its runs are checks in the `verify` | |
| 2 | // and `verify-bisect` environments. The scheduler and /boards' Pipeline ask | |
| 3 | // for a repository's newest ones; this keeps that a seek, not a walk of | |
| 4 | // every check the repository ever ran. | |
| 5 | export default ` | |
| 6 | CREATE INDEX IF NOT EXISTS run_verify ON run(repo_id, env, id DESC); | |
| 7 | `; |
src/core/runs.ts +2
| @@ -45,6 +45,8 @@ export interface CheckDetail { | ||
| 45 | 45 | readonly passed: number; |
| 46 | 46 | readonly failed: number; |
| 47 | 47 | readonly names: readonly string[]; |
| 48 | /** The test files the failures printed under, when a verification after landing failed (#7786). */ | |
| 49 | readonly files?: readonly string[]; | |
| 48 | 50 | /** The check machine's free disk share (0..1) when a red finished (#5453). */ |
| 49 | 51 | readonly diskFree?: number; |
| 50 | 52 | } |
src/flow/verified.ts added +123
| @@ -0,0 +1,123 @@ | ||
| 1 | /** | |
| 2 | * How long main has gone unverified (#7786): for each repository whose | |
| 3 | * main is verified after landing (`checks/verify.ts`), the time since the | |
| 4 | * first landing the last green verification does not cover. /boards' | |
| 5 | * Pipeline card says it, one line per repository, linking the run. | |
| 6 | * | |
| 7 | * "Unverified since" is when the oldest default-branch check after the | |
| 8 | * last green verification's commit was asked for — that commit landed | |
| 9 | * then. Verified at the tip, it is nothing; never verified green, it is | |
| 10 | * since the first verification was asked for. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { getRepoById } from "../core/repos.ts"; | |
| 16 | ||
| 17 | export interface VerifiedRow { | |
| 18 | /** `owner/name`. */ | |
| 19 | readonly repo: string; | |
| 20 | readonly public: boolean; | |
| 21 | /** When main stopped being verified, or null when its tip is verified green. */ | |
| 22 | readonly unverifiedSince: string | null; | |
| 23 | /** The last green verification: its run and commit. */ | |
| 24 | readonly green: { readonly runId: string; readonly commitOid: string; readonly at: string } | null; | |
| 25 | /** The newest verification, any state. */ | |
| 26 | readonly last: { readonly runId: string; readonly state: string; readonly commitOid: string } | null; | |
| 27 | } | |
| 28 | ||
| 29 | interface RunRow { | |
| 30 | id: string; | |
| 31 | commit_oid: string; | |
| 32 | state: string; | |
| 33 | created_at: string; | |
| 34 | finished_at: string | null; | |
| 35 | } | |
| 36 | ||
| 37 | /** Each repository with a verification, newest first; private ones for the owner only. */ | |
| 38 | export function readVerified(app: App, owner: boolean): VerifiedRow[] { | |
| 39 | const ids = app.db | |
| 40 | .query<{ repo_id: string }, []>( | |
| 41 | `SELECT DISTINCT repo_id FROM run WHERE kind = 'check' AND env = 'verify'`, | |
| 42 | ) | |
| 43 | .all(); | |
| 44 | const rows: VerifiedRow[] = []; | |
| 45 | for (const { repo_id } of ids) { | |
| 46 | const repo = getRepoById(app.db, repo_id); | |
| 47 | if (repo === undefined || (!owner && repo.visibility !== "public")) continue; | |
| 48 | rows.push(verifiedOf(app.db, repo.id, repo.defaultBranch, `${repo.ownerName}/${repo.name}`, repo.visibility === "public")); | |
| 49 | } | |
| 50 | return rows.sort((a, b) => (a.unverifiedSince ?? "~").localeCompare(b.unverifiedSince ?? "~")); | |
| 51 | } | |
| 52 | ||
| 53 | /** One repository's row. */ | |
| 54 | export function verifiedOf(db: Database, repoId: string, branch: string, name: string, isPublic: boolean): VerifiedRow { | |
| 55 | // A state of "" is any state. | |
| 56 | const verify = (state: string): RunRow | null => | |
| 57 | db | |
| 58 | .query<RunRow, [string, string]>( | |
| 59 | `SELECT id, commit_oid, state, created_at, finished_at FROM run | |
| 60 | WHERE repo_id = ?1 AND kind = 'check' AND env = 'verify' AND (?2 = '' OR state = ?2) | |
| 61 | ORDER BY id DESC LIMIT 1`, | |
| 62 | ) | |
| 63 | .get(repoId, state); | |
| 64 | const green = verify("ok"); | |
| 65 | const last = verify(""); | |
| 66 | // The newest default-branch check of the green commit; what came after it landed after. | |
| 67 | const covered = | |
| 68 | green === null | |
| 69 | ? null | |
| 70 | : db | |
| 71 | .query<{ id: string }, [string, string, string]>( | |
| 72 | `SELECT MAX(id) AS id FROM run WHERE repo_id = ?1 AND kind = 'check' | |
| 73 | AND issue_id IS NULL AND env = ?2 AND commit_oid = ?3`, | |
| 74 | ) | |
| 75 | .get(repoId, branch, green.commit_oid); | |
| 76 | const since = | |
| 77 | green === null | |
| 78 | ? (db | |
| 79 | .query<{ created_at: string }, [string]>( | |
| 80 | `SELECT MIN(created_at) AS created_at FROM run | |
| 81 | WHERE repo_id = ?1 AND kind = 'check' AND env = 'verify'`, | |
| 82 | ) | |
| 83 | .get(repoId)?.created_at ?? null) | |
| 84 | : (db | |
| 85 | .query<{ created_at: string }, [string, string, string, string]>( | |
| 86 | `SELECT created_at FROM run WHERE repo_id = ?1 AND kind = 'check' AND issue_id IS NULL | |
| 87 | AND env = ?2 AND id > ?3 AND commit_oid != ?4 ORDER BY id LIMIT 1`, | |
| 88 | ) | |
| 89 | .get(repoId, branch, covered?.id ?? green.id, green.commit_oid)?.created_at ?? null); | |
| 90 | return { | |
| 91 | repo: name, | |
| 92 | public: isPublic, | |
| 93 | unverifiedSince: since, | |
| 94 | green: green === null ? null : { runId: green.id, commitOid: green.commit_oid, at: green.finished_at ?? green.created_at }, | |
| 95 | last: last === null ? null : { runId: last.id, state: last.state, commitOid: last.commit_oid }, | |
| 96 | }; | |
| 97 | } | |
| 98 | ||
| 99 | const short = (oid: string): string => oid.slice(0, 7); | |
| 100 | ||
| 101 | /** A span in whole minutes under an hour, else hours to one decimal: 7 min, 2.5 h. */ | |
| 102 | export const spanWords = (ms: number): string => | |
| 103 | ms < 3_600_000 ? `${Math.floor(Math.max(0, ms) / 60_000)} min` : `${(ms / 3_600_000).toFixed(1)} h`; | |
| 104 | ||
| 105 | /** The sentence for a row: how long main has gone unverified, and what the last verification said. */ | |
| 106 | export function verifiedSays(row: VerifiedRow, now: number): string { | |
| 107 | const lastSays = | |
| 108 | row.last === null | |
| 109 | ? "" | |
| 110 | : row.last.state === "ok" | |
| 111 | ? "" | |
| 112 | : row.last.state === "failed" | |
| 113 | ? `; last verification red at ${short(row.last.commitOid)}` | |
| 114 | : row.last.state === "running" || row.last.state === "queued" | |
| 115 | ? `; verifying ${short(row.last.commitOid)} now` | |
| 116 | : ""; | |
| 117 | if (row.unverifiedSince === null && row.green !== null) | |
| 118 | return `main verified at ${short(row.green.commitOid)}, ${spanWords(Math.max(0, now - Date.parse(row.green.at)))} ago${lastSays}`; | |
| 119 | const span = row.unverifiedSince === null ? "—" : spanWords(Math.max(0, now - Date.parse(row.unverifiedSince))); | |
| 120 | return row.green === null | |
| 121 | ? `main never verified green, ${span} since the first verification${lastSays}` | |
| 122 | : `main unverified for ${span} since ${short(row.green.commitOid)} was verified green${lastSays}`; | |
| 123 | } |
src/main.ts +3
| @@ -35,6 +35,7 @@ import { listApps } from "./core/apps.ts"; | ||
| 35 | 35 | import { holdAppHosts, releaseAppHosts } from "./apps/holding.ts"; |
| 36 | 36 | import { resolveAppDomain } from "./apps/host.ts"; |
| 37 | 37 | import { LAND_SETTLE_MS, requeueAbandonedChecks, startChecks } from "./checks/service.ts"; |
| 38 | import { VERIFY_EVERY_MS } from "./checks/verify.ts"; | |
| 38 | 39 | import { RED_HOLD_MS } from "./checks/red_main.ts"; |
| 39 | 40 | import { stopQueue } from "./checks/queue.ts"; |
| 40 | 41 | import { startLooks } from "./looks/service.ts"; |
| @@ -407,6 +408,8 @@ const checks = | ||
| 407 | 408 | // before cutting them off: a full gate takes about ten minutes, and |
| 408 | 409 | // the pod's grace (charts/app, 720 s) is longer still (#6722). |
| 409 | 410 | drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 660) * 1000, |
| 411 | // Main is verified after landing at most this often (#7786). | |
| 412 | verifyEveryMs: VERIFY_EVERY_MS, | |
| 410 | 413 | }); |
| 411 | 414 | // Migrated repositories kept as a read-only mirror push to their source. |
| 412 | 415 | const mirrorOut = startMirrorOut(app); |
src/system/telemetry.ts +3
| @@ -16,6 +16,7 @@ import { type QueueReading, readQueues } from "../flow/queue.ts"; | ||
| 16 | 16 | import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts"; |
| 17 | 17 | import { requestedSays, requestedWaits } from "../flow/requested.ts"; |
| 18 | 18 | import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts"; |
| 19 | import { readVerified, verifiedSays } from "../flow/verified.ts"; | |
| 19 | 20 | import type { Reader } from "../actions/telemetry.ts"; |
| 20 | 21 | import { agentView, live } from "./live.ts"; |
| 21 | 22 | import type { SystemView } from "./view.ts"; |
| @@ -38,6 +39,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 38 | 39 | const parked = readParked(app.db, now, reader.kind === "agent"); |
| 39 | 40 | return { |
| 40 | 41 | says: verdict(stages, now.getTime()), |
| 42 | // How long each main has gone unverified after landing (#7786); private repositories for the owner only. | |
| 43 | verified: readVerified(app, reader.kind === "owner").map((row) => ({ ...row, says: verifiedSays(row, now.getTime()) })), | |
| 41 | 44 | // Issues the crew parked in the last 24 h and 7 d, the newest three named (#6471). |
| 42 | 45 | parked: { says: parkedSays(parked), ...parked }, |
| 43 | 46 | // Open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets (#5935). |
src/tui/forge.ts +2
| @@ -56,6 +56,7 @@ import { capacityScreen, mayCapacity } from "./screens/capacity.ts"; | ||
| 56 | 56 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 57 | 57 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 58 | 58 | import { readHeld } from "../flow/held.ts"; |
| 59 | import { readVerified } from "../flow/verified.ts"; | |
| 59 | 60 | import { memoryScreen } from "./screens/system.ts"; |
| 60 | 61 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; |
| 61 | 62 | import { queuesFor, systemFor } from "../system/telemetry.ts"; |
| @@ -298,6 +299,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 298 | 299 | flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader, new Date(), { batches: true }) } : undefined, |
| 299 | 300 | readHeld(session.app, flowReader.kind === "owner"), |
| 300 | 301 | forgeCeiling(session.app.config), |
| 302 | readVerified(session.app, flowReader.kind === "owner"), | |
| 301 | 303 | ))); |
| 302 | 304 | }, |
| 303 | 305 | }); |
src/tui/screens/flow.ts +8 −1
| @@ -12,7 +12,8 @@ | ||
| 12 | 12 | * conflicted pulls, the idle crew pulls (#6479) and the pulls with |
| 13 | 13 | * changes requested waiting on a builder and on a reviewer (`flow/requested.ts`, #2172); then each |
| 14 | 14 | * autonomous repository's ready list, startable beside the count per |
| 15 | * filter that holds the rest (`flow/held.ts`, #2244), as the flow panel draws it. | |
| 15 | * filter that holds the rest (`flow/held.ts`, #2244), as the flow panel draws it; | |
| 16 | * last, how long each main has gone unverified after landing (`flow/verified.ts`, #7786). | |
| 16 | 17 | * Read-only; Enter, Esc or q closes it. |
| 17 | 18 | */ |
| 18 | 19 | |
| @@ -23,6 +24,7 @@ import { pullCounts } from "../../crew/hot_files.ts"; | ||
| 23 | 24 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 24 | 25 | import { ago } from "../../feed/rank.ts"; |
| 25 | 26 | import type { HeldRow } from "../../flow/held.ts"; |
| 27 | import { verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; | |
| 26 | 28 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 27 | 29 | import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts"; |
| 28 | 30 | import { pileSays } from "../../flow/pile.ts"; |
| @@ -60,6 +62,8 @@ export function flowLines( | ||
| 60 | 62 | held?: readonly HeldRow[], |
| 61 | 63 | /** The forge's daily ceiling, which the idle-crew alarm needs to know it is not paused on purpose. */ |
| 62 | 64 | ceilingUsd: number | null = null, |
| 65 | /** How long each main has gone unverified after landing (`readVerified`, already cut to what the reader may see). */ | |
| 66 | verified?: readonly VerifiedRow[], | |
| 63 | 67 | ): string[] { |
| 64 | 68 | const stages = readPipeline(db, now); |
| 65 | 69 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| @@ -122,6 +126,9 @@ export function flowLines( | ||
| 122 | 126 | ...(reviewing === null ? [] : ["", ...wrap(reviewing, inner)]), |
| 123 | 127 | ...(warming === "" ? [] : ["", ...warming.split("\n").flatMap((l) => wrap(l, inner))]), |
| 124 | 128 | ...(held === undefined ? [] : ["", ...heldLines(held, inner)]), |
| 129 | ...(verified === undefined || verified.length === 0 | |
| 130 | ? [] | |
| 131 | : ["", ...verified.flatMap((r) => wrap(`${r.repo}: ${verifiedSays(r, now.getTime())}`, inner))]), | |
| 125 | 132 | "", |
| 126 | 133 | ...duplicateLines(dups, inner), |
| 127 | 134 | ]; |
src/web/pages/flow-panel.ts +23
| @@ -43,6 +43,7 @@ import { buildYieldSays } from "../../flow/build_yield.ts"; | ||
| 43 | 43 | import { reviewLatencySays } from "../../flow/review_latency.ts"; |
| 44 | 44 | import { openerCounts } from "../../flow/openers.ts"; |
| 45 | 45 | import { readHeld } from "../../flow/held.ts"; |
| 46 | import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; | |
| 46 | 47 | import type { App } from "../../app.ts"; |
| 47 | 48 | import { html, type Html } from "../html.ts"; |
| 48 | 49 | import { gatesCard } from "./boards-gates.ts"; |
| @@ -211,6 +212,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 211 | 212 | </div>`, |
| 212 | 213 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, |
| 213 | 214 | )} |
| 215 | ${verifiedCard(readVerified(app, reader.kind === "owner"), now)} | |
| 214 | 216 | ${hotFilesCard(app, reader)} |
| 215 | 217 | ${heldCard(readHeld(app, reader.kind === "owner", now))} |
| 216 | 218 | ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))} |
| @@ -219,6 +221,27 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 219 | 221 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 220 | 222 | } |
| 221 | 223 | |
| 224 | /** | |
| 225 | * How long main has gone unverified, per repository whose main is verified | |
| 226 | * after landing (#7786): the whole suite runs there on a schedule, after | |
| 227 | * landings that were judged on lint and types. | |
| 228 | */ | |
| 229 | function verifiedCard(rows: readonly VerifiedRow[], now: number): Html { | |
| 230 | return card( | |
| 231 | html`<h2>Verified after landing</h2> | |
| 232 | ${rows.length === 0 | |
| 233 | ? html`<p class="muted">No repository verifies its main after landing yet: it declares <span class="mono">check.verify</span> in forge.json.</p>` | |
| 234 | : rows.map( | |
| 235 | (r) => | |
| 236 | html`<p data-verified="${r.repo}" data-unverified-since="${r.unverifiedSince ?? ""}"> | |
| 237 | <a href="/${r.repo}">${r.repo}</a>: ${verifiedSays(r, now)} | |
| 238 | ${r.last === null ? null : html`· <a class="small" href="/${r.repo}/runs/${r.last.runId}">run</a>`} | |
| 239 | </p>`, | |
| 240 | )}`, | |
| 241 | { id: "verified" }, | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 222 | 245 | /** The count, `20+` when the read stopped at its limit. */ |
| 223 | 246 | const idleCount = (idle: readonly IdlePull[]): string => `${idle.length}${idle.length >= IDLE_LIMIT ? "+" : ""}`; |
| 224 | 247 |
test/check-verify.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * The checks service verifies main after landing (#7786): `verifyDue` | |
| 3 | * asks for a run of the declared `check.verify` on main's checked tip, once | |
| 4 | * per move of main; a red that fails alone too names the commit that broke | |
| 5 | * it — here a push no pull carried, so one issue. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 14 | import { VERIFY_ENV } from "../src/checks/verify.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { getRun, listRuns, type Run } from "../src/core/runs.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { readRunLog } from "../src/runs/log.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let alice: User; | |
| 26 | let repo: Repo; | |
| 27 | let checks: ChecksHandle; | |
| 28 | ||
| 29 | const report = (fail: boolean): string => | |
| 30 | fail | |
| 31 | ? `printf 't/a.test.ts:\\n(fail) grp > works\\n 0 pass\\n 1 fail\\n'; exit 1` | |
| 32 | : `printf 't/a.test.ts:\\n(pass) grp > works\\n 1 pass\\n 0 fail\\n'`; | |
| 33 | ||
| 34 | async function push(fail: boolean): Promise<string> { | |
| 35 | const pushed = await commitToBranch(app, alice, repo, { | |
| 36 | message: fail ? "breaks it" : "works", | |
| 37 | files: [ | |
| 38 | { path: "forge.json", content: JSON.stringify({ check: { run: "true", verify: report(fail), install: "" } }) }, | |
| 39 | { | |
| 40 | path: "t/a.test.ts", | |
| 41 | content: `import { test, expect, describe } from "bun:test";\ndescribe("grp", () => { test("works", () => { expect(${fail ? 2 : 1}).toBe(1); }); });\n`, | |
| 42 | }, | |
| 43 | ], | |
| 44 | }); | |
| 45 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 46 | return pushed.value.commitOid; | |
| 47 | } | |
| 48 | ||
| 49 | async function until<T>(read: () => T | undefined, ms = 40_000): Promise<T> { | |
| 50 | const deadline = Date.now() + ms; | |
| 51 | for (;;) { | |
| 52 | const got = read(); | |
| 53 | if (got !== undefined) return got; | |
| 54 | if (Date.now() > deadline) throw new Error("timed out"); | |
| 55 | await Bun.sleep(100); | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | const finished = (env: string, oid: string): Run | undefined => | |
| 60 | listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 61 | (r) => r.env === env && r.commitOid === oid && (r.state === "ok" || r.state === "failed"), | |
| 62 | ); | |
| 63 | ||
| 64 | beforeAll(async () => { | |
| 65 | process.env["FORGE_CELLS"] = "off"; | |
| 66 | dir = await mkdtemp(join(tmpdir(), "check-verify-")); | |
| 67 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 68 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 69 | if (!u.ok) throw new Error(u.error); | |
| 70 | alice = u.value; | |
| 71 | const made = await createRepo(app, alice, { name: "verified", visibility: "public" }); | |
| 72 | if (!made.ok) throw new Error("repo"); | |
| 73 | repo = made.value; | |
| 74 | checks = startChecks(app, { forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 75 | }); | |
| 76 | afterAll(async () => { | |
| 77 | await checks.stop(); | |
| 78 | await rm(dir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a green main is verified once; asked again before it moves, it is not", async () => { | |
| 82 | const oid = await push(false); | |
| 83 | await until(() => finished(repo.defaultBranch, oid)); | |
| 84 | const asked = await checks.verifyDue(); | |
| 85 | expect(asked.map((r) => [r.env, r.commitOid])).toEqual([[VERIFY_ENV, oid]]); | |
| 86 | const run = await until(() => finished(VERIFY_ENV, oid)); | |
| 87 | expect(run.state).toBe("ok"); | |
| 88 | expect(await checks.verifyDue()).toEqual([]); | |
| 89 | }, 60_000); | |
| 90 | ||
| 91 | test("a red verification that fails alone too names the push that broke it in one issue", async () => { | |
| 92 | const oid = await push(true); | |
| 93 | await until(() => finished(repo.defaultBranch, oid)); | |
| 94 | // Main moved, but the last verification is not 30 minutes old yet. | |
| 95 | expect(await checks.verifyDue()).toEqual([]); | |
| 96 | const asked = await checks.verifyDue(Date.now() + 31 * 60_000); | |
| 97 | expect(asked).toHaveLength(1); | |
| 98 | const run = await until(() => finished(VERIFY_ENV, oid)); | |
| 99 | expect(run.state).toBe("failed"); | |
| 100 | expect(getRun(app.db, run.id)?.checkDetail?.files).toEqual(["t/a.test.ts"]); | |
| 101 | const issue = await until(() => | |
| 102 | app.db | |
| 103 | .query<{ title: string; body: string }, [string]>("SELECT title, body FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND title LIKE 'Red after landing:%'") | |
| 104 | .get(repo.id) ?? undefined, | |
| 105 | ); | |
| 106 | expect(issue.title).toContain(oid.slice(0, 7)); | |
| 107 | const log = readRunLog(app.config.dataDir, run.id).lines.join("\n"); | |
| 108 | expect(log).toContain("fails alone too"); | |
| 109 | expect(log).toContain("landed by no pull"); | |
| 110 | }, 60_000); |
test/shards-keep-going.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * `scripts/test-shards.ts --keep-going`, the verification after landing | |
| 3 | * (#7786): a red file stops nothing, a file red once and green alone is | |
| 4 | * named flaky and passes, the impact of every green file is written though | |
| 5 | * another is red, and `FORGE_CHECK_ONLY` keeps only the files it names. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { FLAKY_ALONE } from "../src/checks/verify.ts"; | |
| 12 | ||
| 13 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 14 | let dir: string; | |
| 15 | ||
| 16 | beforeAll(() => { | |
| 17 | dir = mkdtempSync(join(tmpdir(), "shards-keep-going-")); | |
| 18 | mkdirSync(join(dir, "test")); | |
| 19 | writeFileSync(join(dir, "lib.ts"), "export const one = (): number => 1;\n"); | |
| 20 | // The heaviest by bytes runs first, so the red is first and the rest still run. | |
| 21 | writeFileSync( | |
| 22 | join(dir, "test", "a.test.ts"), | |
| 23 | `import { test, expect } from "bun:test";\n${"// padding so this file is heaviest\n".repeat(40)}test("red", () => { expect(1).toBe(2); });\n`, | |
| 24 | ); | |
| 25 | writeFileSync( | |
| 26 | join(dir, "test", "b.test.ts"), | |
| 27 | `import { test, expect } from "bun:test";\nimport { one } from "../lib.ts";\ntest("green", () => { expect(one()).toBe(1); });\n`, | |
| 28 | ); | |
| 29 | // Red the first time it runs, green after: a flake. | |
| 30 | writeFileSync( | |
| 31 | join(dir, "test", "c.test.ts"), | |
| 32 | `import { test, expect } from "bun:test";\nimport { existsSync, writeFileSync } from "node:fs";\nimport { one } from "../lib.ts";\n` + | |
| 33 | `test("flaky", () => { const seen = existsSync("seen"); writeFileSync("seen", ""); expect(seen && one() === 1).toBe(true); });\n`, | |
| 34 | ); | |
| 35 | }); | |
| 36 | afterAll(() => rmSync(dir, { recursive: true, force: true })); | |
| 37 | ||
| 38 | async function shards(args: string[], env: Record<string, string> = {}): Promise<{ code: number; out: string }> { | |
| 39 | const child = Bun.spawn(["bun", script, ...args, "test/a.test.ts", "test/b.test.ts", "test/c.test.ts"], { | |
| 40 | cwd: dir, | |
| 41 | env: { ...process.env, FORGE_CHECK_SHARDS: "1", FORGE_CHECK_IMPACT: ".impact.json", ...env }, | |
| 42 | stdin: "ignore", | |
| 43 | stdout: "pipe", | |
| 44 | stderr: "pipe", | |
| 45 | }); | |
| 46 | const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 47 | return { code: await child.exited, out: out + err }; | |
| 48 | } | |
| 49 | ||
| 50 | describe("--keep-going", () => { | |
| 51 | test("every file runs past the red, the flake passes alone, and the green files' impact is written", async () => { | |
| 52 | rmSync(join(dir, "seen"), { force: true }); | |
| 53 | const { code, out } = await shards(["--keep-going", "--impact"]); | |
| 54 | expect(code).toBe(1); | |
| 55 | expect(out).not.toContain("fail-fast"); | |
| 56 | expect(out).toContain(`${FLAKY_ALONE} test/c.test.ts`); | |
| 57 | expect(out).toContain("Ran 3 tests across 3 files"); | |
| 58 | const map = JSON.parse(readFileSync(join(dir, ".impact.json"), "utf8")) as Record<string, unknown>; | |
| 59 | const tests = JSON.stringify(map); | |
| 60 | expect(tests).toContain("test/b.test.ts"); | |
| 61 | expect(tests).toContain("test/c.test.ts"); | |
| 62 | expect(tests).not.toContain("test/a.test.ts"); | |
| 63 | }, 60_000); | |
| 64 | ||
| 65 | test("without it, the first red stops the rest and no map is written", async () => { | |
| 66 | rmSync(join(dir, "seen"), { force: true }); | |
| 67 | rmSync(join(dir, ".impact.json"), { force: true }); | |
| 68 | const { code, out } = await shards(["--impact"]); | |
| 69 | expect(code).toBe(1); | |
| 70 | expect(out).toContain("fail-fast"); | |
| 71 | expect(existsSync(join(dir, ".impact.json"))).toBe(false); | |
| 72 | }, 60_000); | |
| 73 | ||
| 74 | test("FORGE_CHECK_ONLY runs only the files it names", async () => { | |
| 75 | const { code, out } = await shards(["--keep-going"], { FORGE_CHECK_ONLY: "test/b.test.ts,../x.test.ts" }); | |
| 76 | expect(code).toBe(0); | |
| 77 | expect(out).toContain("FORGE_CHECK_ONLY keeps 1 of 1 named file(s)"); | |
| 78 | expect(out).not.toContain("(fail) red"); | |
| 79 | }, 60_000); | |
| 80 | }); |
test/verify-after-landing.test.ts added +318
| @@ -0,0 +1,318 @@ | ||
| 1 | /** | |
| 2 | * Verify after landing (#7786): main's whole suite runs on a schedule with | |
| 3 | * `--impact`, a red that stays red alone is bisected to the landing that | |
| 4 | * broke it, which gets one revert pull (or, landed by no pull, one issue), | |
| 5 | * and /boards' Pipeline says how long main has gone unverified. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 9 | import { readFileSync } from "node:fs"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { parseCheck } from "../src/apps/manifest.ts"; | |
| 17 | import { afterVerify } from "../src/checks/culprit.ts"; | |
| 18 | import { | |
| 19 | BISECT_ENV, | |
| 20 | BISECT_PREFIX, | |
| 21 | bisectOrigin, | |
| 22 | FLAKY_ALONE, | |
| 23 | failedFiles, | |
| 24 | maxSteps, | |
| 25 | nextStep, | |
| 26 | onlyEnv, | |
| 27 | onlyFiles, | |
| 28 | safeFile, | |
| 29 | VERIFY_ENV, | |
| 30 | VERIFY_EVERY_MS, | |
| 31 | verifyDue, | |
| 32 | } from "../src/checks/verify.ts"; | |
| 33 | import { listPulls, getPull, type Pull } from "../src/core/pulls.ts"; | |
| 34 | import type { Repo } from "../src/core/repos.ts"; | |
| 35 | import { createRun, getRun, type Run } from "../src/core/runs.ts"; | |
| 36 | import { createUser, type User } from "../src/core/users.ts"; | |
| 37 | import { readVerified, spanWords, verifiedSays } from "../src/flow/verified.ts"; | |
| 38 | import { resolveOid } from "../src/git/merge.ts"; | |
| 39 | import { repoDir } from "../src/git/repos.ts"; | |
| 40 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 41 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 42 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 43 | ||
| 44 | describe("when a verification is due", () => { | |
| 45 | const at = Date.parse("2026-11-01T12:00:00Z"); | |
| 46 | const base = { tip: "b", active: false, now: at, everyMs: VERIFY_EVERY_MS }; | |
| 47 | test("never verified: now", () => expect(verifyDue(base)).toBe(true)); | |
| 48 | test("main has not moved: not again", () => | |
| 49 | expect(verifyDue({ ...base, last: { commitOid: "b", createdAt: "2026-11-01T00:00:00Z" } })).toBe(false)); | |
| 50 | test("moved, 29 minutes after the last: not yet; 30: now", () => { | |
| 51 | expect(verifyDue({ ...base, last: { commitOid: "a", createdAt: "2026-11-01T11:31:00Z" } })).toBe(false); | |
| 52 | expect(verifyDue({ ...base, last: { commitOid: "a", createdAt: "2026-11-01T11:30:00Z" } })).toBe(true); | |
| 53 | }); | |
| 54 | test("one already queued or running: never a second", () => expect(verifyDue({ ...base, active: true })).toBe(false)); | |
| 55 | test("every 30 minutes at most", () => expect(VERIFY_EVERY_MS).toBe(1_800_000)); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("bisecting a range", () => { | |
| 59 | // Eight commits after the green; c3 broke it. ⌈log₂ 8⌉ = 3 steps at most. | |
| 60 | const range = ["c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7"]; | |
| 61 | const broken = (c: string): boolean => range.indexOf(c) >= 3; | |
| 62 | ||
| 63 | test("walks to the culprit in at most ⌈log₂ n⌉ steps, each a commit inside the range", () => { | |
| 64 | const tried = new Map<string, boolean>(); | |
| 65 | let step = nextStep(range, tried); | |
| 66 | while (step?.kind === "test") { | |
| 67 | expect(range.slice(0, -1)).toContain(step.commit); | |
| 68 | tried.set(step.commit, !broken(step.commit)); | |
| 69 | step = nextStep(range, tried); | |
| 70 | } | |
| 71 | expect(step).toEqual({ kind: "culprit", commit: "c3" }); | |
| 72 | expect(tried.size).toBeLessThanOrEqual(maxSteps(range.length)); | |
| 73 | expect(maxSteps(8)).toBe(3); | |
| 74 | }); | |
| 75 | ||
| 76 | test("by hand: the first step is the middle, c3; red there, then c1; green, then c2; green: c3", () => { | |
| 77 | const tried = new Map<string, boolean>(); | |
| 78 | expect(nextStep(range, tried)).toEqual({ kind: "test", commit: "c3" }); | |
| 79 | tried.set("c3", false); | |
| 80 | expect(nextStep(range, tried)).toEqual({ kind: "test", commit: "c1" }); | |
| 81 | tried.set("c1", true); | |
| 82 | expect(nextStep(range, tried)).toEqual({ kind: "test", commit: "c2" }); | |
| 83 | tried.set("c2", true); | |
| 84 | expect(nextStep(range, tried)).toEqual({ kind: "culprit", commit: "c3" }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("one commit since the green is the culprit at once", () => | |
| 88 | expect(nextStep(["c0"], new Map())).toEqual({ kind: "culprit", commit: "c0" })); | |
| 89 | ||
| 90 | test("the culprit is the newest when every step is green", () => { | |
| 91 | const tried = new Map([["c0", true], ["c1", true]]); | |
| 92 | expect(nextStep(["c0", "c1", "c2"], tried)).toEqual({ kind: "culprit", commit: "c2" }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a step's reason names its red verification, and nothing else is read as one", () => { | |
| 96 | expect(bisectOrigin(`${BISECT_PREFIX}01abc`)).toBe("01abc"); | |
| 97 | expect(bisectOrigin("bisect:../x")).toBeUndefined(); | |
| 98 | expect(bisectOrigin("schedule")).toBeUndefined(); | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | describe("the files a red names", () => { | |
| 103 | const log = [ | |
| 104 | "test/a.test.ts:", | |
| 105 | "(pass) a > one", | |
| 106 | "test/b.test.ts:", | |
| 107 | "(fail) b > two", | |
| 108 | "(fail) b > three", | |
| 109 | "test/c.test.ts:", | |
| 110 | "(fail) c > four", | |
| 111 | "-rf.test.ts:", | |
| 112 | "(fail) flag", | |
| 113 | "test/d.test.ts:", | |
| 114 | "(fail) d > flaky", | |
| 115 | `test-shards: ${FLAKY_ALONE} ./test/d.test.ts`, | |
| 116 | ]; | |
| 117 | test("each failing file once, in order; one green alone is not one; a flag-like path is dropped", () => | |
| 118 | expect(failedFiles(log)).toEqual(["test/b.test.ts", "test/c.test.ts"])); | |
| 119 | test("a path that reads as a flag, climbs out, or carries a shell word is not a test file to run", () => { | |
| 120 | for (const bad of ["-x.test.ts", "--preload=x.test.ts", "../a.test.ts", "test/../../a.test.ts", "a b.test.ts", "a;rm.test.ts", "$(x).test.ts", ""]) | |
| 121 | expect(safeFile(bad)).toBe(false); | |
| 122 | expect(safeFile("test/a-b.test.ts")).toBe(true); | |
| 123 | expect(failedFiles(["-rf.test.ts:", "(fail) x"])).toEqual([]); | |
| 124 | }); | |
| 125 | test("FORGE_CHECK_ONLY carries only safe paths", () => { | |
| 126 | expect(onlyEnv(["test/a.test.ts", "../etc/passwd", "-x.test.ts"])).toBe("test/a.test.ts"); | |
| 127 | expect(onlyFiles("test/a.test.ts, ../b.test.ts,,test/c.test.ts")).toEqual(["test/a.test.ts", "test/c.test.ts"]); | |
| 128 | expect(onlyFiles(undefined)).toBeUndefined(); | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("the forge's own forge.json verifies main", () => { | |
| 133 | const forgeJson = readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8"); | |
| 134 | test("check.verify runs the whole gate with --impact, without stopping at the first red, at low priority", () => { | |
| 135 | const parsed = parseCheck(forgeJson, undefined); | |
| 136 | if (!parsed.ok || parsed.value === undefined) throw new Error("forge.json has no check"); | |
| 137 | const verify = parsed.value.verify ?? ""; | |
| 138 | for (const flag of ["--gate", "--impact", "--keep-going", "--nice"]) expect(verify).toContain(flag); | |
| 139 | }); | |
| 140 | test("a check that does not say how verifies nothing", () => { | |
| 141 | const parsed = parseCheck(JSON.stringify({ check: { run: "bun test" } }), undefined); | |
| 142 | expect(parsed.ok && parsed.value?.verify).toBeNull(); | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | describe("a red verification names its culprit", () => { | |
| 147 | let dataDir: string; | |
| 148 | let app: App; | |
| 149 | let alice: User; | |
| 150 | let repo: Repo; | |
| 151 | const FILE = "test/thing.test.ts"; | |
| 152 | ||
| 153 | const tip = async (): Promise<string> => | |
| 154 | (await resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), "refs/heads/main")) ?? ""; | |
| 155 | ||
| 156 | const land = async (branch: string): Promise<Pull> => { | |
| 157 | await commitToBranch(app, alice, repo, { | |
| 158 | branch, | |
| 159 | base: "main", | |
| 160 | message: branch, | |
| 161 | files: [{ path: `${branch}.txt`, content: `${branch}\n` }], | |
| 162 | }); | |
| 163 | const opened = await openPull(app, alice, repo, { title: `Change ${branch}`, baseBranch: "main", headBranch: branch }); | |
| 164 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 165 | const merged = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 166 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 167 | return getPull(app.db, repo.id, opened.value.number)!; | |
| 168 | }; | |
| 169 | ||
| 170 | const finish = (run: Run, state: "ok" | "failed"): Run => { | |
| 171 | app.db | |
| 172 | .query("UPDATE run SET state = ?2, check_detail = ?3, finished_at = ?4 WHERE id = ?1") | |
| 173 | .run( | |
| 174 | run.id, | |
| 175 | state, | |
| 176 | state === "ok" ? "" : JSON.stringify({ passed: 0, failed: 1, names: ["thing > works"], files: [FILE] }), | |
| 177 | new Date().toISOString(), | |
| 178 | ); | |
| 179 | return getRun(app.db, run.id)!; | |
| 180 | }; | |
| 181 | ||
| 182 | const verification = async (state: "ok" | "failed"): Promise<Run> => | |
| 183 | finish(createRun(app.db, { repoId: repo.id, kind: "check", env: VERIFY_ENV, commitOid: await tip(), reason: "schedule" }), state); | |
| 184 | ||
| 185 | /** Runs the bisection to its end; a step is red at and after `culprit`. */ | |
| 186 | const bisect = async (red: Run, culprit: string, order: readonly string[]): Promise<{ lines: string[]; steps: number }> => { | |
| 187 | const lines: string[] = []; | |
| 188 | let steps = 0; | |
| 189 | let asked: Run | undefined; | |
| 190 | const ask = async (_r: Repo, env: string, commitOid: string, reason: string): Promise<Run | undefined> => { | |
| 191 | asked = createRun(app.db, { repoId: repo.id, kind: "check", env, commitOid, reason }); | |
| 192 | return asked; | |
| 193 | }; | |
| 194 | let next: Run = red; | |
| 195 | for (let i = 0; i < 10; i++) { | |
| 196 | asked = undefined; | |
| 197 | lines.push(...(await afterVerify(app, repo, next, ask))); | |
| 198 | if (asked === undefined) break; | |
| 199 | const step: Run = asked; | |
| 200 | expect(step.env).toBe(BISECT_ENV); | |
| 201 | steps++; | |
| 202 | next = finish(step, order.indexOf(step.commitOid) >= order.indexOf(culprit) ? "failed" : "ok"); | |
| 203 | } | |
| 204 | return { lines, steps }; | |
| 205 | }; | |
| 206 | ||
| 207 | beforeEach(async () => { | |
| 208 | dataDir = await mkdtemp(join(tmpdir(), "forge-verify-")); | |
| 209 | app = createApp({ dataDir, port: 0 }); | |
| 210 | const created = await createUser(app.db, { | |
| 211 | origin: "seed", | |
| 212 | trusted: true, | |
| 213 | username: "alice", | |
| 214 | email: "[email protected]", | |
| 215 | password: "a-good-passphrase", | |
| 216 | adminIfFirst: true, | |
| 217 | }); | |
| 218 | if (!created.ok) throw new Error(created.error); | |
| 219 | alice = created.value; | |
| 220 | const made = await createRepo(app, alice, { name: "app", visibility: "public" }); | |
| 221 | if (!made.ok) throw new Error(String(made.error)); | |
| 222 | repo = made.value; | |
| 223 | await commitToBranch(app, alice, repo, { branch: "main", message: "base", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 224 | }); | |
| 225 | ||
| 226 | afterEach(async () => { | |
| 227 | app.close(); | |
| 228 | await rm(dataDir, { recursive: true, force: true }); | |
| 229 | }); | |
| 230 | ||
| 231 | test("four landings since the green: the third is named in two steps, and its revert is proposed once", async () => { | |
| 232 | await verification("ok"); | |
| 233 | const pulls: Pull[] = []; | |
| 234 | const oids: string[] = []; | |
| 235 | for (const b of ["one", "two", "three", "four"]) { | |
| 236 | pulls.push(await land(b)); | |
| 237 | oids.push(await tip()); | |
| 238 | } | |
| 239 | const red = await verification("failed"); | |
| 240 | const { lines, steps } = await bisect(red, oids[2]!, oids); | |
| 241 | expect(steps).toBeLessThanOrEqual(maxSteps(4)); | |
| 242 | expect(lines.at(-1)).toContain(`culprit #${pulls[2]!.number}`); | |
| 243 | const reverts = listPulls(app.db, repo.id, { state: "all" }).filter((p) => p.revertsPullId !== null); | |
| 244 | expect(reverts.map((p) => p.revertsPullId)).toEqual([pulls[2]!.id]); | |
| 245 | expect(reverts[0]!.body).toContain(FILE); | |
| 246 | expect(reverts[0]!.body).toContain("thing > works"); | |
| 247 | expect(reverts[0]!.mergedAt).toBeNull(); | |
| 248 | // The same red asked again proposes nothing more. | |
| 249 | expect((await afterVerify(app, repo, red, async () => undefined))[0]).toContain("already proposed"); | |
| 250 | expect(listPulls(app.db, repo.id, { state: "all" }).filter((p) => p.revertsPullId !== null)).toHaveLength(1); | |
| 251 | }); | |
| 252 | ||
| 253 | test("a push no pull carried is named in one issue", async () => { | |
| 254 | await verification("ok"); | |
| 255 | await commitToBranch(app, alice, repo, { branch: "main", message: "direct", files: [{ path: "b.txt", content: "b\n" }] }); | |
| 256 | const red = await verification("failed"); | |
| 257 | const lines = await afterVerify(app, repo, red, async () => undefined); | |
| 258 | expect(lines[0]).toContain("landed by no pull"); | |
| 259 | const issues = app.db | |
| 260 | .query<{ title: string; body: string }, [string]>("SELECT title, body FROM issue WHERE repo_id = ?1 AND kind = 'issue'") | |
| 261 | .all(repo.id); | |
| 262 | expect(issues).toHaveLength(1); | |
| 263 | expect(issues[0]!.body).toContain(FILE); | |
| 264 | // A second red of the same thing files nothing new. | |
| 265 | await afterVerify(app, repo, red, async () => undefined); | |
| 266 | expect(app.db.query("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'issue'").get(repo.id)).toEqual({ n: 1 }); | |
| 267 | }); | |
| 268 | ||
| 269 | test("a red whose files were already red at the verification before starts nothing", async () => { | |
| 270 | await verification("ok"); | |
| 271 | await land("one"); | |
| 272 | await verification("failed"); | |
| 273 | await land("two"); | |
| 274 | const again = await verification("failed"); | |
| 275 | let asked = 0; | |
| 276 | const lines = await afterVerify(app, repo, again, async () => { | |
| 277 | asked++; | |
| 278 | return undefined; | |
| 279 | }); | |
| 280 | expect(asked).toBe(0); | |
| 281 | expect(lines[0]).toContain("already failed"); | |
| 282 | }); | |
| 283 | ||
| 284 | test("/boards says how long main has gone unverified, and a reader who cannot see a private repository does not", async () => { | |
| 285 | createRun(app.db, { repoId: repo.id, kind: "check", env: "main", commitOid: await tip() }); | |
| 286 | const green = await verification("ok"); | |
| 287 | expect(verifiedSays(readVerified(app, true)[0]!, Date.now())).toContain("main verified at"); | |
| 288 | await land("one"); | |
| 289 | // The forge checks main's push of every landing; that check is when it landed. | |
| 290 | createRun(app.db, { repoId: repo.id, kind: "check", env: "main", commitOid: await tip() }); | |
| 291 | const row = readVerified(app, true)[0]!; | |
| 292 | expect(row.green?.runId).toBe(green.id); | |
| 293 | expect(row.unverifiedSince).not.toBeNull(); | |
| 294 | const since = Date.parse(row.unverifiedSince!); | |
| 295 | expect(verifiedSays(row, since + 45 * 60_000)).toContain("main unverified for 45 min"); | |
| 296 | expect(verifiedSays(row, since + 150 * 60_000)).toContain("main unverified for 2.5 h"); | |
| 297 | const page = String(flowPanel(app, { kind: "owner" }, new URL("http://x/boards"), "/boards")); | |
| 298 | expect(page).toContain('id="verified"'); | |
| 299 | expect(page).toContain(`data-verified="alice/app"`); | |
| 300 | expect(page).toContain("main unverified for"); | |
| 301 | // The API's and the MCP flow tool's reading says the same. | |
| 302 | // The terminal's Flow dialog says the same line, for the same reader. | |
| 303 | const said = (rows: ReturnType<typeof readVerified>): string => | |
| 304 | flowLines(app.db, new Date(), undefined, undefined, undefined, null, rows).join("\n"); | |
| 305 | expect(said(readVerified(app, true))).toContain("alice/app: main unverified for"); | |
| 306 | expect(flowFor(app, { kind: "owner" }).verified[0]?.says).toContain("main unverified for"); | |
| 307 | app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [repo.id]); | |
| 308 | expect(readVerified(app, false)).toEqual([]); | |
| 309 | expect(flowFor(app, { kind: "agent" }).verified).toEqual([]); | |
| 310 | expect(said(readVerified(app, false))).not.toContain("alice/app: main"); | |
| 311 | expect(String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards"), "/boards"))).not.toContain('data-verified="alice/app"'); | |
| 312 | }); | |
| 313 | ||
| 314 | test("spans read in minutes under an hour and hours after", () => { | |
| 315 | expect(spanWords(7 * 60_000 + 59_000)).toBe("7 min"); | |
| 316 | expect(spanWords(3_600_000)).toBe("1.0 h"); | |
| 317 | }); | |
| 318 | }); |
new above
offline