Public: anyone can read this; the crew can change it.
One night of the forge's flake hunt and outside gate is compared with Forgejo's, and the result is written down #9056
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9045-one-night-of-the-forge-s-flake-hunt-and → main 1 commit fork
4 files
changed +254
−1
docs/DEVELOP.md +16
| @@ -243,6 +243,22 @@ it once; passing again, it closes the issue. Same `FORGE_TOKEN` as the hunt. | ||
| 243 | 243 | The Forgejo workflows these replace, and `sync-env`, are covered in |
| 244 | 244 | `docs/features/nightly.md`. |
| 245 | 245 | |
| 246 | **Can the Forgejo jobs be switched off? Not yet.** They go (#9020) once one | |
| 247 | night of each side has been set side by side and agrees: | |
| 248 | `bun scripts/flake-compare.ts --forge hunt.log --forge outside.log --forgejo | |
| 249 | slice-1.log … --forgejo outside.log` reads what each side's run printed (the | |
| 250 | forge's `/resilience/nightly/<job>`, with the job's Resilience line pasted in | |
| 251 | so its `quarantine pull on flake-hunt/<date>` counts; each Forgejo step log) | |
| 252 | and prints what each filed — `Red:` issues opened, updated or closed, `Flaky:` | |
| 253 | issues, broken and quarantined files, the quarantine pull — then every | |
| 254 | difference, naming the file; it exits 1 when there is one | |
| 255 | (`test/flake-compare.test.ts`). One side silent where the other filed is the | |
| 256 | dedupe, not a difference. A flaky file only one side found can be the seed; | |
| 257 | a file red outside the gate on one side and green on the other is the cell | |
| 258 | differing from the `isolated` runner, and blocks the switch until explained. | |
| 259 | No such night has been compared yet: the logs are admin-only on both sides, | |
| 260 | so an admin runs it and posts the output on #9045. | |
| 261 | ||
| 246 | 262 | Bun's own `--randomize --seed=N` drives every hunt and admission run. Not |
| 247 | 263 | used: `--rerun-each` (one seed for every rerun, so ten reruns explore one |
| 248 | 264 | order) and `--parallel` (it changes the load the gate is judged under). |
docs/features/nightly.md +1 −1
| @@ -34,4 +34,4 @@ For one date, set side by side: | ||
| 34 | 34 | - **Forgejo:** the `flake-hunt` and `outside-gate` Actions runs on the mirror (each slice's log, the quarantine job's log). |
| 35 | 35 | - **Forge:** `/resilience/nightly/flake-hunt` and `/resilience/nightly/outside-gate`, and the line above each: what it filed. |
| 36 | 36 | |
| 37 | They agree when the outside run lists the same files green and red, both open, update and close the same `Red:` issues (or one finds the other's already open), and the hunt's flaky files, which vary with the seed, are each filed once. A file red on one side and green on the other is the finding: the cell (no network but the forge and the registry, its CPU and memory caps) differs from the `isolated` runner. | |
| 37 | They agree when the outside run lists the same files green and red, both open, update and close the same `Red:` issues (or one finds the other's already open), and the hunt's flaky files, which vary with the seed, are each filed once. `bun scripts/flake-compare.ts --forge <log>… --forgejo <log>…` does this from the logs and names each difference (`docs/DEVELOP.md`). A file red on one side and green on the other is the finding: the cell (no network but the forge and the registry, its CPU and memory caps) differs from the `isolated` runner. |
scripts/flake-compare.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * One night of the flake hunt and the outside gate, the forge's run beside | |
| 3 | * the Forgejo job's (#9045). Both sides run `scripts/flake-hunt.ts`, so | |
| 4 | * both logs carry the same lines; this reads what each filed out of them | |
| 5 | * and names where they differ. | |
| 6 | * | |
| 7 | * bun scripts/flake-compare.ts --forge a.log [--forge b.log] | |
| 8 | * --forgejo c.log [--forgejo d.log] | |
| 9 | * | |
| 10 | * Each log is a run's whole output (the forge's `/resilience/nightly/<job>`, | |
| 11 | * a Forgejo Actions step log); give each side its hunt and its outside run, | |
| 12 | * in any order. A Forgejo slice log may be passed once per slice. Prints | |
| 13 | * both sides and the differences; exits 1 when there are any. | |
| 14 | * | |
| 15 | * They agree when the outside run sees the same files red and green, the | |
| 16 | * `Red:` issues move the same way (one side finding the other's already | |
| 17 | * open is agreement: it says nothing), and the same files are flaky, | |
| 18 | * broken and quarantined. Plain text in, plain data out. | |
| 19 | */ | |
| 20 | ||
| 21 | import { readFileSync } from "node:fs"; | |
| 22 | ||
| 23 | export type RedAction = { kind: "filed" | "updated" | "closed"; issue: number }; | |
| 24 | ||
| 25 | export type Night = { | |
| 26 | /** Outside the gate: file → red or green. */ | |
| 27 | outside: Map<string, "red" | "green">; | |
| 28 | /** `Red:` issues the outside run opened, updated or closed: file → action. */ | |
| 29 | red: Map<string, RedAction>; | |
| 30 | /** Hunt: file → the `Flaky:` issue filed or found open (null: not filed). */ | |
| 31 | flaky: Map<string, number | null>; | |
| 32 | broken: Set<string>; | |
| 33 | /** Quarantine: file → the issue it was moved under. */ | |
| 34 | quarantined: Map<string, number>; | |
| 35 | /** The quarantine pull's branch, when the log names one. */ | |
| 36 | pull: string | null; | |
| 37 | }; | |
| 38 | ||
| 39 | const TEST = String.raw`(test/[\w./-]+\.test\.ts)`; | |
| 40 | const OUTSIDE = new RegExp(String.raw`(?:^|\s)(red |green): ${TEST}\s*$`); | |
| 41 | const RED = new RegExp(String.raw`(?:^|\s)(filed|updated|closed) #(\d+) for ${TEST}`); | |
| 42 | const FLAKY = new RegExp(String.raw`(?:^|\s)FLAKY ${TEST}:`); | |
| 43 | const FILED = /(?:^|\s)(?:filed|already open as) #(\d+)\s*$/; | |
| 44 | const BROKEN = new RegExp(String.raw`red in every run \(broken, not flaky\): ${TEST}`); | |
| 45 | const QUARANTINED = new RegExp(String.raw`(?:^|\s)quarantined ${TEST} \(#(\d+)\)`); | |
| 46 | const PULL = /flake-hunt\/\d{4}-\d{2}-\d{2}/; | |
| 47 | ||
| 48 | /** What one side's logs say it did. */ | |
| 49 | export function readNight(logs: string[]): Night { | |
| 50 | const night: Night = { | |
| 51 | outside: new Map(), | |
| 52 | red: new Map(), | |
| 53 | flaky: new Map(), | |
| 54 | broken: new Set(), | |
| 55 | quarantined: new Map(), | |
| 56 | pull: null, | |
| 57 | }; | |
| 58 | for (const log of logs) { | |
| 59 | let lastFlaky: string | null = null; | |
| 60 | for (const line of log.split("\n")) { | |
| 61 | let m: RegExpExecArray | null; | |
| 62 | if ((m = OUTSIDE.exec(line))) night.outside.set(m[2]!, m[1] === "green" ? "green" : "red"); | |
| 63 | else if ((m = RED.exec(line))) | |
| 64 | night.red.set(m[3]!, { kind: m[1] as RedAction["kind"], issue: Number(m[2]) }); | |
| 65 | else if ((m = FLAKY.exec(line))) { | |
| 66 | lastFlaky = m[1]!; | |
| 67 | if (!night.flaky.has(lastFlaky)) night.flaky.set(lastFlaky, null); | |
| 68 | } else if ((m = FILED.exec(line)) && lastFlaky) night.flaky.set(lastFlaky, Number(m[1])); | |
| 69 | else if ((m = BROKEN.exec(line))) night.broken.add(m[1]!); | |
| 70 | else if ((m = QUARANTINED.exec(line))) night.quarantined.set(m[1]!, Number(m[2])); | |
| 71 | if ((m = PULL.exec(line))) night.pull = m[0]; | |
| 72 | } | |
| 73 | } | |
| 74 | return night; | |
| 75 | } | |
| 76 | ||
| 77 | const issue = (n: number | null) => (n === null ? "not filed" : `#${n}`); | |
| 78 | ||
| 79 | /** Every way the two nights disagree, one line each; empty when they agree. */ | |
| 80 | export function compareNights(forge: Night, forgejo: Night): string[] { | |
| 81 | const out: string[] = []; | |
| 82 | const sides = [ | |
| 83 | ["forge", forge], | |
| 84 | ["forgejo", forgejo], | |
| 85 | ] as const; | |
| 86 | const union = <T>(a: Iterable<T>, b: Iterable<T>) => [...new Set([...a, ...b])].sort(); | |
| 87 | ||
| 88 | for (const file of union(forge.outside.keys(), forgejo.outside.keys())) { | |
| 89 | const a = forge.outside.get(file); | |
| 90 | const b = forgejo.outside.get(file); | |
| 91 | if (a === b) continue; | |
| 92 | out.push(`outside ${file}: ${a ?? "not run"} on forge, ${b ?? "not run"} on forgejo`); | |
| 93 | } | |
| 94 | for (const file of union(forge.red.keys(), forgejo.red.keys())) { | |
| 95 | const a = forge.red.get(file); | |
| 96 | const b = forgejo.red.get(file); | |
| 97 | // One side acting and the other silent is the dedupe at work, unless the | |
| 98 | // outside runs already disagree on the file (named above). | |
| 99 | if (!a || !b || a.kind === b.kind) continue; | |
| 100 | out.push(`Red: ${file}: forge ${a.kind} #${a.issue}, forgejo ${b.kind} #${b.issue}`); | |
| 101 | } | |
| 102 | for (const file of union(forge.flaky.keys(), forgejo.flaky.keys())) { | |
| 103 | for (const [name, night] of sides) { | |
| 104 | const other = night === forge ? forgejo : forge; | |
| 105 | if (night.flaky.has(file) && !other.flaky.has(file)) | |
| 106 | out.push(`Flaky: ${file}: only ${name} found it (${issue(night.flaky.get(file)!)})`); | |
| 107 | } | |
| 108 | } | |
| 109 | for (const file of union(forge.broken, forgejo.broken)) { | |
| 110 | if (forge.broken.has(file) !== forgejo.broken.has(file)) | |
| 111 | out.push(`broken ${file}: only ${forge.broken.has(file) ? "forge" : "forgejo"} found it`); | |
| 112 | } | |
| 113 | for (const file of union(forge.quarantined.keys(), forgejo.quarantined.keys())) { | |
| 114 | if (forge.quarantined.has(file) !== forgejo.quarantined.has(file)) | |
| 115 | out.push(`quarantined ${file}: only ${forge.quarantined.has(file) ? "forge" : "forgejo"} moved it`); | |
| 116 | } | |
| 117 | if ((forge.pull === null) !== (forgejo.pull === null)) | |
| 118 | out.push(`quarantine pull: only ${forge.pull ? "forge" : "forgejo"} proposed one`); | |
| 119 | return out; | |
| 120 | } | |
| 121 | ||
| 122 | /** One side, as lines a person reads. */ | |
| 123 | export function describeNight(name: string, night: Night): string[] { | |
| 124 | const reds = [...night.outside].filter(([, s]) => s === "red").map(([f]) => f); | |
| 125 | const lines = [`${name}: outside ${night.outside.size} files, ${reds.length} red`]; | |
| 126 | for (const [file, a] of night.red) lines.push(` Red: ${a.kind} #${a.issue} for ${file}`); | |
| 127 | for (const [file, n] of night.flaky) lines.push(` Flaky: ${file} (${issue(n)})`); | |
| 128 | for (const file of night.broken) lines.push(` broken: ${file}`); | |
| 129 | for (const [file, n] of night.quarantined) lines.push(` quarantined ${file} (#${n})`); | |
| 130 | lines.push(` quarantine pull: ${night.pull ?? "none"}`); | |
| 131 | return lines; | |
| 132 | } | |
| 133 | ||
| 134 | /** The whole report and whether the two sides agree. */ | |
| 135 | export function report(forge: Night, forgejo: Night): { text: string; agree: boolean } { | |
| 136 | const diffs = compareNights(forge, forgejo); | |
| 137 | const lines = [...describeNight("forge", forge), ...describeNight("forgejo", forgejo), ""]; | |
| 138 | if (diffs.length === 0) lines.push("no differences"); | |
| 139 | else lines.push(`${diffs.length} differences:`, ...diffs.map((d) => ` ${d}`)); | |
| 140 | return { text: lines.join("\n"), agree: diffs.length === 0 }; | |
| 141 | } | |
| 142 | ||
| 143 | if (import.meta.main) { | |
| 144 | const args = process.argv.slice(2); | |
| 145 | const paths: Record<"--forge" | "--forgejo", string[]> = { "--forge": [], "--forgejo": [] }; | |
| 146 | for (let i = 0; i < args.length; i += 2) { | |
| 147 | const flag = args[i]; | |
| 148 | const path = args[i + 1]; | |
| 149 | if ((flag !== "--forge" && flag !== "--forgejo") || !path) { | |
| 150 | console.error("usage: bun scripts/flake-compare.ts --forge <log>... --forgejo <log>..."); | |
| 151 | process.exit(2); | |
| 152 | } | |
| 153 | paths[flag].push(path); | |
| 154 | } | |
| 155 | if (paths["--forge"].length === 0 || paths["--forgejo"].length === 0) { | |
| 156 | console.error("give at least one log for each side: --forge <log> --forgejo <log>"); | |
| 157 | process.exit(2); | |
| 158 | } | |
| 159 | const read = (list: string[]) => readNight(list.map((p) => readFileSync(p, "utf8"))); | |
| 160 | const { text, agree } = report(read(paths["--forge"]), read(paths["--forgejo"])); | |
| 161 | console.log(text); | |
| 162 | process.exit(agree ? 0 : 1); | |
| 163 | } |
test/flake-compare.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * `scripts/flake-compare.ts` (#9045): one night of the forge's flake hunt | |
| 3 | * and outside gate beside the Forgejo job's, read from their logs. | |
| 4 | */ | |
| 5 | ||
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { compareNights, readNight, report } from "../scripts/flake-compare.ts"; | |
| 8 | ||
| 9 | const outside = `$ bun scripts/flake-hunt.ts outside --file-issues | |
| 10 | green: test/a.test.ts | |
| 11 | red : test/b.test.ts | |
| 12 | filed #12 for test/b.test.ts | |
| 13 | green: test/c.test.ts | |
| 14 | closed #7 for test/c.test.ts | |
| 15 | 3 files outside the gate, 1 red`; | |
| 16 | ||
| 17 | const hunt = `hunt run 1/5: seed 42, 3 files | |
| 18 | red: test/d.test.ts (seed 42) | |
| 19 | red in every run (broken, not flaky): test/e.test.ts | |
| 20 | FLAKY test/d.test.ts: red 1, green 4; first red seed 42 | |
| 21 | filed #30 | |
| 22 | quarantined test/d.test.ts (#30) | |
| 23 | pushed flake-hunt/2026-11-03`; | |
| 24 | ||
| 25 | /** A Forgejo step log: each line behind a timestamp. */ | |
| 26 | const stamped = (log: string) => | |
| 27 | log | |
| 28 | .split("\n") | |
| 29 | .map((line) => `2026-11-03T02:17:00.0000000Z ${line}`) | |
| 30 | .join("\n"); | |
| 31 | ||
| 32 | describe("flake-compare", () => { | |
| 33 | test("reads what a night filed", () => { | |
| 34 | const night = readNight([outside, hunt]); | |
| 35 | expect([...night.outside]).toEqual([ | |
| 36 | ["test/a.test.ts", "green"], | |
| 37 | ["test/b.test.ts", "red"], | |
| 38 | ["test/c.test.ts", "green"], | |
| 39 | ]); | |
| 40 | expect(night.red.get("test/b.test.ts")).toEqual({ kind: "filed", issue: 12 }); | |
| 41 | expect(night.red.get("test/c.test.ts")).toEqual({ kind: "closed", issue: 7 }); | |
| 42 | expect([...night.flaky]).toEqual([["test/d.test.ts", 30]]); | |
| 43 | expect([...night.broken]).toEqual(["test/e.test.ts"]); | |
| 44 | expect([...night.quarantined]).toEqual([["test/d.test.ts", 30]]); | |
| 45 | expect(night.pull).toBe("flake-hunt/2026-11-03"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("the same night on both sides has no differences, timestamps or not", () => { | |
| 49 | const { text, agree } = report(readNight([outside, hunt]), readNight([stamped(hunt), stamped(outside)])); | |
| 50 | expect(agree).toBe(true); | |
| 51 | expect(text).toContain("no differences"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("one side finding the other's Red: issue already open is agreement", () => { | |
| 55 | const silent = outside.replace(" filed #12 for test/b.test.ts\n", ""); | |
| 56 | expect(compareNights(readNight([outside]), readNight([silent]))).toEqual([]); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a test filed by only one side is named", () => { | |
| 60 | const forgeHunt = hunt.replace(" filed #30", " filed #30\nFLAKY test/f.test.ts: red 2, green 3; first red seed 9\n filed #31"); | |
| 61 | const diffs = compareNights(readNight([outside, forgeHunt]), readNight([outside, hunt])); | |
| 62 | expect(diffs).toEqual(["Flaky: test/f.test.ts: only forge found it (#31)"]); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a file red on one side and green on the other is named, as is a missing pull", () => { | |
| 66 | const forgejoOutside = outside.replace("red : test/b.test.ts\n filed #12 for test/b.test.ts", "green: test/b.test.ts"); | |
| 67 | const forgejoHunt = hunt.replace("quarantined test/d.test.ts (#30)\npushed flake-hunt/2026-11-03", "nothing to quarantine"); | |
| 68 | const { text, agree } = report(readNight([outside, hunt]), readNight([forgejoOutside, forgejoHunt])); | |
| 69 | expect(agree).toBe(false); | |
| 70 | expect(text).toContain("outside test/b.test.ts: red on forge, green on forgejo"); | |
| 71 | expect(text).toContain("quarantined test/d.test.ts: only forge moved it"); | |
| 72 | expect(text).toContain("quarantine pull: only forge proposed one"); | |
| 73 | }); | |
| 74 | }); |
new above
offline