Public: anyone can read this; the crew can change it.

The tests outside the gate run on a schedule, so a suite that breaks is seen the day it breaks #2559

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/812-the-tests-outside-the-gate-run-on-a-sche → main 2 commits fork

  • Merged. scout/812-the-tests-outside-the-gate-run-on-a-sche was merged into main as 188743f.

Review

Sign in to review this change.
6 files changed +310 −7
.forgejo/workflows/outside-gate.yml added +38
@@ -0,0 +1,38 @@
1 name: outside-gate
2 # Nightly: every file in EXCLUDED (scripts/gate.ts), one at a time with the
3 # gate's 60 s timeout, so a suite outside the gate that breaks is seen the
4 # day it breaks. A newly red file opens one `Red: test/….test.ts` issue
5 # naming its first failing test; a file green again closes it
6 # (scripts/flake-hunt.ts outside). The list is EXCLUDED itself: a file moved
7 # into the gate stops being run here.
8 #
9 # Needs secret FORGE_TOKEN and variable FORGE_URL, as flake-hunt does.
10 on:
11 schedule:
12 - cron: "47 3 * * *"
13 workflow_dispatch: {}
14
15 jobs:
16 outside:
17 runs-on: isolated
18 timeout-minutes: 30
19 env:
20 REGISTRY: forgejo-http.forgejo.svc.cluster.local:3000
21 steps:
22 - name: clone main
23 env:
24 TOKEN: ${{ github.token }}
25 run: |
26 set -eu
27 git clone -q "http://x-access-token:$TOKEN@$REGISTRY/$GITHUB_REPOSITORY.git" src
28 cd src
29 bun install --frozen-lockfile
30
31 - name: run the excluded files
32 working-directory: src
33 env:
34 FORGE_API: ${{ vars.FORGE_URL }}/api/v1
35 FORGE_REPO: ${{ github.repository }}
36 FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
37 FORGE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
38 run: bun scripts/flake-hunt.ts outside --file-issues
README.md +10
@@ -1282,6 +1282,16 @@ takes tens of ms): assert relative to a baseline from the same run, or bound
12821282 only the failure the test names. The workflow needs a
12831283 `FORGE_TOKEN` secret and a `FORGE_URL` variable on the mirror.
12841284
1285 **No unseen red outside the gate.** Nightly, `.forgejo/workflows/outside-gate.yml`
1286 runs `bun scripts/flake-hunt.ts outside --file-issues`: every file in
1287 `EXCLUDED` (the complement of the gate, from the same list, so a file moved
1288 into the gate is no longer run there), once, one at a time, with the gate's
1289 60 s timeout. A file that newly fails opens one `Red: test/x.test.ts` issue
1290 naming its first failing test; still red on another test, it updates that
1291 issue; still red on the same test, it says nothing, so the owner's asks show
1292 it once; passing again, it closes the issue. Same `FORGE_TOKEN` and
1293 `FORGE_URL` as the hunt.
1294
12851295 Bun's own `--randomize --seed=N` drives every hunt and admission run. Not
12861296 used: `--rerun-each` (one seed for every rerun, so ten reruns explore one
12871297 order) and `--parallel` (it changes the load the gate is judged under).
scripts/flake-hunt.ts +112 −6
@@ -9,6 +9,12 @@
99 * `--file-issues` each gets an issue carrying the failure text, the
1010 * seed and the run (FORGE_API, FORGE_REPO, FORGE_TOKEN; FORGE_RUN_URL
1111 * names the run). Exits 1 when it found one.
12 * bun scripts/flake-hunt.ts outside [--file-issues]
13 * Every file in EXCLUDED once, one at a time, with the gate's timeout:
14 * the schedule for what the gate does not run (#812). With
15 * `--file-issues` a newly red file opens one `Red: test/….test.ts`
16 * issue naming its first failing test, a file red on another test
17 * updates it, and a file green again closes it.
1218 * bun scripts/flake-hunt.ts quarantine
1319 * Puts every file an open `Flaky: test/….test.ts` issue the hunt filed
1420 * (author is the token's user, not untrusted) names into
@@ -30,12 +36,16 @@ import {
3036 huntedFile,
3137 HUNT_RUNS,
3238 issueFor,
39 redActions,
40 redFile,
41 redIssueFor,
42 type OpenRed,
3343 quarantineFlaky,
3444 readRun,
3545 shuffled,
3646 type FileRun,
3747 } from "./flake.ts";
38 import { gateFiles, presentTests } from "./gate.ts";
48 import { gateFiles, outsideFiles, presentTests } from "./gate.ts";
3949 import { parseShardSpec, sliceFiles } from "./shards.ts";
4050
4151 const GATE_FILE = new URL("./gate.ts", import.meta.url).pathname;
@@ -66,7 +76,7 @@ function runsOf(fallback: number): number {
6676
6777 const freshSeed = (): number => crypto.getRandomValues(new Uint32Array(1))[0]!;
6878
69 /** One file, one seed, one `bun test`: the gate's own unit. */
79 /** One file, one `bun test`: the gate's own unit (random order when seeded). */
7080 async function runFile(file: string, seed: number): Promise<FileRun> {
7181 const env = { ...process.env };
7282 // The gate hands its children none of these (scripts/test-shards.ts).
@@ -84,8 +94,7 @@ async function runFile(file: string, seed: number): Promise<FileRun> {
8494 "test",
8595 "--timeout",
8696 "60000",
87 "--randomize",
88 `--seed=${seed}`,
97 ...(seed === 0 ? [] : ["--randomize", `--seed=${seed}`]),
8998 `./${file}`,
9099 ],
91100 { stdin: "ignore", stdout: "pipe", stderr: "pipe", env },
@@ -171,6 +180,102 @@ async function openFlakyIssues(): Promise<Map<string, number>> {
171180 return found;
172181 }
173182
183 async function forgeJson(
184 method: string,
185 path: string,
186 body: unknown,
187 ): Promise<Response> {
188 const { api, repo, token } = forgeApi();
189 return fetch(`${api}/repos/${repo}${path}`, {
190 method,
191 headers: {
192 authorization: `Bearer ${token}`,
193 "content-type": "application/json",
194 },
195 body: JSON.stringify(body),
196 });
197 }
198
199 /** Open `Red:` issues the scheduled run filed: file → issue. */
200 async function openRedIssues(): Promise<Map<string, OpenRed>> {
201 const { api, repo, token } = forgeApi();
202 const self = await huntUserId(api, token);
203 const found = new Map<string, OpenRed>();
204 let cursor: string | number | null = "";
205 for (let page = 0; page < 100 && cursor !== null; page++) {
206 const query = cursor === "" ? "" : `&cursor=${encodeURIComponent(cursor)}`;
207 const res = await fetch(
208 `${api}/repos/${repo}/issues?state=open&limit=100${query}`,
209 { headers: { authorization: `Bearer ${token}` } },
210 );
211 if (!res.ok) fail(`listing issues: ${res.status}`);
212 const body = (await res.json()) as {
213 items?: {
214 number?: unknown;
215 title?: unknown;
216 body?: unknown;
217 authorId?: unknown;
218 untrusted?: unknown;
219 }[];
220 next_cursor?: string | number | null;
221 };
222 for (const issue of body.items ?? []) {
223 if (issue.authorId !== self || (issue.untrusted ?? false) !== false)
224 continue;
225 const file =
226 typeof issue.title === "string" ? redFile(issue.title) : undefined;
227 if (file !== undefined && Number.isInteger(issue.number))
228 found.set(file, {
229 number: issue.number as number,
230 ...(typeof issue.body === "string" ? { body: issue.body } : {}),
231 });
232 }
233 cursor = body.next_cursor ?? null;
234 }
235 return found;
236 }
237
238 /** The files outside the gate, one at a time; red ones reported to the forge. */
239 async function outside(): Promise<number> {
240 const files = outsideFiles(presentTests());
241 const fileIssues = rest.includes("--file-issues");
242 const open = fileIssues ? await openRedIssues() : new Map<string, OpenRed>();
243 const runs: FileRun[] = [];
244 for (const file of files) {
245 const run = await runFile(file, 0);
246 console.log(`${run.green ? "green" : "red "}: ${file}`);
247 runs.push(run);
248 }
249 const where = process.env["FORGE_RUN_URL"] ?? "";
250 for (const action of redActions(runs, open)) {
251 if (!fileIssues) continue;
252 if (action.kind === "open") {
253 const res = await forgeJson("POST", "/issues", redIssueFor(action.run, where));
254 const made = (await res.json().catch(() => ({}))) as { number?: unknown };
255 if (!res.ok) fail(`filing the issue for ${action.run.file}: ${res.status}`);
256 console.log(` filed #${made.number} for ${action.run.file}`);
257 } else if (action.kind === "update") {
258 const { body } = redIssueFor(action.run, where);
259 const res = await forgeJson("PATCH", `/issues/${action.number}`, { body });
260 if (!res.ok) {
261 const note = await forgeJson("POST", `/issues/${action.number}/comments`, { body });
262 if (!note.ok) fail(`updating #${action.number}: ${note.status}`);
263 }
264 console.log(` updated #${action.number} for ${action.run.file}`);
265 } else {
266 await forgeJson("POST", `/issues/${action.number}/comments`, {
267 body: `\`${action.file}\` passed in the scheduled run${where ? ` (${where})` : ""}.`,
268 });
269 const res = await forgeJson("PATCH", `/issues/${action.number}`, { state: "closed" });
270 if (!res.ok) fail(`closing #${action.number}: ${res.status}`);
271 console.log(` closed #${action.number} for ${action.file}`);
272 }
273 }
274 const red = runs.filter((run) => !run.green).length;
275 console.log(`${files.length} files outside the gate, ${red} red`);
276 return 0;
277 }
278
174279 async function hunt(): Promise<number> {
175280 const runs = runsOf(HUNT_RUNS);
176281 const spec = parseShardSpec(valueOf("--shard"));
@@ -263,10 +368,11 @@ async function admit(): Promise<number> {
263368 return admitted(results) ? 0 : 1;
264369 }
265370
266 if (mode === "hunt") process.exit(await hunt());
371 if (mode === "outside") process.exit(await outside());
372 else if (mode === "hunt") process.exit(await hunt());
267373 else if (mode === "quarantine") process.exit(await quarantineAll());
268374 else if (mode === "admit") process.exit(await admit());
269375 else
270376 fail(
271 "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | quarantine | admit test/x.test.ts [--runs N]",
377 "usage: flake-hunt.ts hunt [--runs N] [--shard N/M] [--file-issues] | outside [--file-issues] | quarantine | admit test/x.test.ts [--runs N]",
272378 );
scripts/flake.ts +72
@@ -21,6 +21,9 @@ export const HUNT_RUNS = 5;
2121 /** The title prefix of the issue the hunt files; quarantine reads it back. */
2222 export const FLAKY_TITLE = "Flaky: ";
2323
24 /** The title prefix of the issue the scheduled run files for a red file. */
25 export const RED_TITLE = "Red: ";
26
2427 /**
2528 * The least a test may bound a request or a spawn by, in wall-clock ms. A
2629 * loopback fetch on a loaded shared runner takes tens of milliseconds; a
@@ -321,3 +324,72 @@ export function shuffled(files: readonly string[], seed: number): string[] {
321324 }
322325 return out;
323326 }
327
328 /** The first failing test's name as the issue words it. */
329 export function firstFailing(run: FileRun): string {
330 return run.failing[0] !== undefined
331 ? `\`${run.failing[0]}\``
332 : "none named; the file failed outside a test";
333 }
334
335 /** The issue for a file outside the gate that is red. */
336 export function redIssueFor(
337 run: FileRun,
338 where: string,
339 ): { title: string; body: string } {
340 const fence = "```";
341 return {
342 title: `${RED_TITLE}${run.file}`,
343 body: [
344 `\`${run.file}\` is outside the gate and was red in the scheduled run. This issue closes itself when the file passes again.`,
345 "",
346 `- first failing test: ${firstFailing(run)}`,
347 `- run: ${where || "(a local run)"}`,
348 `- reproduce: \`bun test --timeout 60000 ./${run.file}\``,
349 "",
350 fence,
351 run.text || "(no failure text captured)",
352 fence,
353 ].join("\n"),
354 };
355 }
356
357 /** The file an issue titled `Red: …` names, or undefined. */
358 export function redFile(title: string): string | undefined {
359 if (!title.startsWith(RED_TITLE)) return undefined;
360 return flakyFile(`${FLAKY_TITLE}${title.slice(RED_TITLE.length)}`);
361 }
362
363 /** An open `Red:` issue as the scheduled run sees it. */
364 export type OpenRed = { number: number; body?: string };
365
366 export type RedAction =
367 | { kind: "open"; run: FileRun }
368 | { kind: "update"; run: FileRun; number: number }
369 | { kind: "close"; file: string; number: number };
370
371 /**
372 * What the forge must be told after a scheduled run: a newly red file opens
373 * one issue; a file still red whose first failing test is not in its issue
374 * updates that issue; a file green again closes it. A file that stays red
375 * on the same test says nothing, so the asks show it once.
376 */
377 export function redActions(
378 runs: readonly FileRun[],
379 open: ReadonlyMap<string, OpenRed>,
380 ): RedAction[] {
381 const actions: RedAction[] = [];
382 for (const run of runs) {
383 const issue = open.get(run.file);
384 if (run.green) {
385 if (issue)
386 actions.push({ kind: "close", file: run.file, number: issue.number });
387 } else if (!issue) actions.push({ kind: "open", run });
388 else if (
389 typeof issue.body === "string" &&
390 !issue.body.includes(firstFailing(run))
391 )
392 actions.push({ kind: "update", run, number: issue.number });
393 }
394 return actions;
395 }
scripts/gate.ts +17
@@ -15,6 +15,9 @@
1515 * order under load (`bun scripts/flake-hunt.ts admit <file>`). A flaky file
1616 * leaves it only through `bun scripts/flake-hunt.ts quarantine`, under the
1717 * issue the nightly hunt filed (`.forgejo/workflows/flake-hunt.yml`).
18 *
19 * The excluded files are not unseen: `.forgejo/workflows/outside-gate.yml`
20 * runs `outsideFiles` nightly and files a `Red:` issue for each that fails (#812).
1821 */
1922
2023 import { readdirSync } from "node:fs";
@@ -135,6 +138,20 @@ export function gateFiles(
135138 return present.filter((file) => !out.has(file));
136139 }
137140
141 /**
142 * What the scheduled run runs (`bun scripts/flake-hunt.ts outside`): the
143 * present files `EXCLUDED` names, in name order. Derived from the same list
144 * as `gateFiles`, so a file is in exactly one of the two: moving it into
145 * the gate stops the schedule running it.
146 */
147 export function outsideFiles(
148 present: readonly string[] = presentTests(),
149 excluded: readonly Exclusion[] = EXCLUDED,
150 ): string[] {
151 const gated = new Set(gateFiles(present, excluded));
152 return present.filter((file) => !gated.has(file));
153 }
154
138155 /**
139156 * The gate a checked-out tree's own `scripts/gate.ts` names, whichever shape
140157 * it has: a tree from before #2071 lists it in `GATE`; a later one derives
test/flake.test.ts +61 −1
@@ -9,7 +9,7 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
99 import { readdirSync, readFileSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
12 import { auditGate, gateFiles, type Exclusion } from "../scripts/gate.ts";
12 import { auditGate, EXCLUDED, gateFiles, outsideFiles, presentTests, type Exclusion } from "../scripts/gate.ts";
1313 import {
1414 admissionRecord,
1515 admitted,
@@ -22,6 +22,9 @@ import {
2222 issueFor,
2323 quarantine,
2424 quarantineFlaky,
25 redActions,
26 redFile,
27 redIssueFor,
2528 readRun,
2629 retries,
2730 ROOT_CAUSES,
@@ -346,3 +349,60 @@ describe("no tight wall-clock bound in a test", () => {
346349 expect(files.flatMap((path) => tightBounds(path, read(path)))).toEqual([]);
347350 });
348351 });
352
353 describe("the scheduled run of the files outside the gate", () => {
354 const present = ["test/a.test.ts", "test/b.test.ts", "test/c.test.ts"];
355 const excluded: Exclusion[] = [
356 { file: "test/b.test.ts", reason: "r", issue: 1 },
357 ];
358 const run = (file: string, failing: string[] = []) =>
359 readRun(file, 0, failing.length ? 1 : 0, failing.map((f) => `(fail) ${f}`).join("\n"));
360
361 test("it runs exactly the files the gate does not", () => {
362 expect(outsideFiles(present, excluded)).toEqual(["test/b.test.ts"]);
363 const both = [...gateFiles(present, excluded), ...outsideFiles(present, excluded)];
364 expect(both.sort()).toEqual(present);
365 // A file moved into the gate is no longer run by the schedule.
366 expect(outsideFiles(present, [])).toEqual([]);
367 });
368
369 test("the real lists partition the test files", () => {
370 const all = presentTests();
371 expect(outsideFiles(all)).toEqual(EXCLUDED.map((e) => e.file).sort());
372 expect(new Set([...gateFiles(all), ...outsideFiles(all)]).size).toBe(all.length);
373 });
374
375 test("a newly red file opens one issue naming it and its first failing test", () => {
376 const red = run("test/b.test.ts", ["first one", "second one"]);
377 const actions = redActions([red], new Map());
378 expect(actions).toEqual([{ kind: "open", run: red }]);
379 const issue = redIssueFor(red, "http://run/1");
380 expect(issue.title).toBe("Red: test/b.test.ts");
381 expect(redFile(issue.title)).toBe("test/b.test.ts");
382 expect(issue.body).toContain("`first one`");
383 expect(issue.body).not.toContain("second one` ");
384 });
385
386 test("a file still red on the same test says nothing; on another it updates", () => {
387 const red = run("test/b.test.ts", ["first one"]);
388 const body = redIssueFor(red, "").body;
389 const open = new Map([["test/b.test.ts", { number: 7, body }]]);
390 expect(redActions([red], open)).toEqual([]);
391 const other = run("test/b.test.ts", ["another"]);
392 expect(redActions([other], open)).toEqual([{ kind: "update", run: other, number: 7 }]);
393 });
394
395 test("a file green again closes its issue; green with no issue does nothing", () => {
396 const green = run("test/b.test.ts");
397 expect(redActions([green], new Map([["test/b.test.ts", { number: 7 }]]))).toEqual([
398 { kind: "close", file: "test/b.test.ts", number: 7 },
399 ]);
400 expect(redActions([green], new Map())).toEqual([]);
401 });
402
403 test("only Red: titles naming a test file count", () => {
404 expect(redFile("Red: ../x.test.ts")).toBeUndefined();
405 expect(redFile("Flaky: test/a.test.ts")).toBeUndefined();
406 expect(redFile("Red: test/a.test.ts")).toBe("test/a.test.ts");
407 });
408 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs