Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1f39652
ci(repo): add flaky-test check workflow
avallete Oct 8, 2026
516d103
Merge branch 'develop' into avallete/flaky-test-detection-ci-e3bf03
avallete Oct 8, 2026
ac249fc
ci(repo): fix flaky-check coverage and classification gaps
avallete Oct 8, 2026
c3f1555
Merge remote-tracking branch 'origin/avallete/flaky-test-detection-ci…
avallete Oct 8, 2026
ad00610
ci(repo): tighten flaky-check report edge cases
avallete Oct 8, 2026
0fa6016
ci(repo): format flaky-report test
avallete Oct 8, 2026
7c5a107
ci(repo): flag individual flaky-check runs that ran no tests
avallete Oct 8, 2026
c5c1ba1
ci(repo): fix flaky-report test array literal
avallete Oct 8, 2026
c3e41a3
Merge branch 'develop' into avallete/flaky-test-detection-ci-e3bf03
avallete Oct 8, 2026
5affc96
Merge branch 'develop' into avallete/flaky-test-detection-ci-e3bf03
avallete Oct 8, 2026
4e1560a
ci(repo): move flaky-check logic into a typed CLI on Vitest JSON
avallete Oct 8, 2026
25820fe
ci(repo): run focused iterations as separate Vitest processes
avallete Oct 8, 2026
a7c3fcb
ci(repo): slim the flaky-check workflow and CLI
avallete Oct 9, 2026
2a63bf2
ci(repo): keep flaky-check comments within GitHub's size limit
avallete Oct 10, 2026
9e39d0e
Merge remote-tracking branch 'origin/develop' into avallete/flaky-tes…
avallete Oct 14, 2026
ac86d1c
ci(repo): match flaky-check e2e jobs to develop's sharding
avallete Oct 14, 2026
c8ed7b6
ci(repo): reuse CI's Docker and native caches in flaky-check runs
avallete Oct 14, 2026
8260cb8
ci(repo): keep flaky-check scenarios apart and cover stack namespace e2e
avallete Oct 14, 2026
9160b46
ci(repo): pin flaky-check retries off and flag empty or malformed exe…
avallete Oct 14, 2026
02ef811
Merge remote-tracking branch 'origin/develop' into avallete/flaky-tes…
avallete Oct 15, 2026
eb0d6cc
ci(repo): shard flaky-check cli-e2e jobs like develop and drop the Go…
avallete Oct 15, 2026
d5bb662
ci(repo): cache Docker images for native lifecycle and label shared rows
avallete Oct 15, 2026
058382e
ci(repo): skip unexercised file setups and limit Go cache to e2e in f…
avallete Oct 15, 2026
f99a2df
ci(repo): flag each flaky-check invocation that exits without a faili…
avallete Oct 15, 2026
9a2ed5a
ci(repo): truncate the flaky-check PR comment at a line end
avallete Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ the preview. This is independent of `run-ci` and `run-live-e2e-ci`.
The `run-preview-packages` label must exist as a repository label; create it
from **Issues → Labels** if it is missing.

## `run-flaky-check`: repeat the suites to find flaky tests

[`flaky-check.yml`](./workflows/flaky-check.yml) runs one commit on many fresh
runners with retries off. A test that passes in some runs and fails in others is
reported as flaky; one that fails in every run is reported separately.

| Trigger | Commit | Suites |
| ------------------------ | --------------- | --------------------------------------------- |
| Nightly, 03:17 UTC | `develop` | unit, integration ×10 |
| Weekly, Sunday 03:47 UTC | `develop` | CLI e2e, stack e2e ×3 |
| `run-flaky-check` label | PR merge commit | unit, integration ×10, plus `focused` |
| Manual dispatch | any ref | chosen suites, runs, repeats, and file filter |

`focused` runs the tests affected by the PR (`vitest --changed` against the merge
base) five times per job, each in a fresh Vitest process, to show a flaky-test fix
holds; a changed `package.json` or Vitest config selects every test. Dispatch with
`suites=focused` and a `filter` to target specific files.

The label is removed when the run starts, so re-add it to run again (by hand on
fork PRs, which also get no comment). The report goes to the job summary, the
`flaky-check-report` artifact, and one PR comment, and fails the run when any test
failed or any run produced no results.

The `run-flaky-check` label must exist as a repository label; create it from
**Issues → Labels** if it is missing.

## Live e2e coverage and stable releases

[`Live E2E`](./workflows/live-e2e.yml) exercises managed staging after every push
Expand Down
291 changes: 291 additions & 0 deletions .github/scripts/flaky-check/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import {
appendFileSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, join } from "node:path";
import type { JsonTestResults } from "vitest/node";
import {
CheckError,
collectedName,
commandsFor,
type Entry,
plan,
RESULTS_DIR,
type ReportSource,
} from "./plan.ts";
import {
aggregate,
COMMENT_MARKER,
commentBody,
isClean,
parseVitestJson,
renderMarkdown,
type RunMeta,
type RunResult,
} from "./report.ts";

function env(name: string): string {
return process.env[name] ?? "";
}

function setOutputs(values: Record<string, string>): void {
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`);
appendFileSync(env("GITHUB_OUTPUT") || "/dev/stdout", lines.join(""));
Comment thread
avallete marked this conversation as resolved.
}

function capture(argv: string[]): string {
const result = Bun.spawnSync(argv, { stderr: "inherit" });
if (result.exitCode !== 0) {
throw new CheckError(`${argv.join(" ")} exited ${result.exitCode}`);
}
return result.stdout.toString().trim();
}

function words(value: string): string[] {
return value.split(/\s+/).filter((word) => word !== "");
}

function planCommand(): void {
const { entries, base, filter } = plan({
event: env("EVENT_NAME"),
schedule: env("SCHEDULE"),
baseRef: env("PR_BASE_REF"),
suites: env("INPUT_SUITES"),
runs: env("INPUT_RUNS"),
repeats: env("INPUT_REPEATS"),
filter: env("INPUT_FILTER"),
});
// github.sha is already the commit to test (a PR's merge commit, or the branch head), unless a
// dispatch names another ref.
const ref = env("INPUT_REF");
const sha =
ref === ""
? env("EVENT_SHA")
: capture([
"gh",
"api",
`repos/${env("GITHUB_REPOSITORY")}/commits/${encodeURIComponent(ref)}`,
"--jq",
".sha",
]);
setOutputs({ sha, base, filter, matrix: JSON.stringify({ include: entries }) });
}

type TurboTask = { task: string; package: string; directory: string; command: string };

/** Collected report names an invocation should produce, so a report that never appears is flagged. */
function expectedReports(source: ReportSource, turboCache: Map<string, TurboTask[]>): string[] {
if ("dir" in source) {
return [collectedName(source.dir, source.report)];
}
const key = source.turbo.join(" ");
let tasks = turboCache.get(key);
if (tasks === undefined) {
const dryRun: { tasks: TurboTask[] } = JSON.parse(
capture(["pnpm", "exec", "turbo", "run", ...source.turbo, "--dry=json"]),
);
tasks = dryRun.tasks.filter(
(task) => task.task === source.turbo[0] && task.command !== "<NONEXISTENT>",
);
turboCache.set(key, tasks);
}
return tasks.map((task) => {
// Turbo forwards arguments to the last command of a chained script only.
if (task.command.includes("&&")) {
throw new CheckError(
`${task.package} chains commands in ${task.task}, so only its last one would write a report`,
);
}
return collectedName(task.directory, source.report);
});
}

async function runCommand(): Promise<number> {
const entry: Entry = JSON.parse(env("MATRIX"));
const filters = words(env("FILTER"));
const extra =
entry.suite === "focused" && filters.length === 0
? ["--changed", capture(["git", "merge-base", "HEAD", `origin/${env("BASE_REF")}`])]
: filters;
if (entry.suite === "e2e") {
process.env.SUPABASE_GO_BINARY = join(process.cwd(), "apps/cli-go/supabase-go");
}
if (entry.suite === "e2e" && entry.target === "cli") {
// Matches test.yml, so concurrency-sensitive flakes show up here too.
process.env.SUPABASE_E2E_WORKERS = "4";
}
Comment thread
avallete marked this conversation as resolved.
let exitCode = 0;
const expected: string[] = [];
const failedInvocations: RunMeta["failedInvocations"] = [];
const turboCache = new Map<string, TurboTask[]>();
for (let iteration = 1; iteration <= entry.executions; iteration += 1) {
for (const { argv, reports } of commandsFor(entry, iteration, extra)) {
const names = expectedReports(reports, turboCache);
expected.push(...names);
console.log(`$ ${argv.join(" ")}`);
const code = await Bun.spawn(argv, { stdout: "inherit", stderr: "inherit" }).exited;
if (code !== 0) {
exitCode = code;
failedInvocations.push({ reports: names, exitCode: code });
}
}
}
setOutputs({
"exit-code": String(exitCode),
"expected-reports": JSON.stringify(expected),
"failed-invocations": JSON.stringify(failedInvocations),
});
return exitCode;
}

function collectCommand(): void {
const entry: Entry = JSON.parse(env("MATRIX"));
const out = join(env("RUNNER_TEMP"), "flaky-results", entry.name);
mkdirSync(out, { recursive: true });
for (const report of new Bun.Glob(`{apps,packages}/*/${RESULTS_DIR}/*.json`).scanSync({
dot: true,
})) {
copyFileSync(
report,
join(out, collectedName(dirname(dirname(report)), basename(report, ".json"))),
);
}
const meta: RunMeta = {
name: entry.name,
suite: entry.suite,
run: entry.run,
executions: entry.executions,
exitCode: env("EXIT_CODE") === "" ? null : Number(env("EXIT_CODE")),
root: process.cwd(),
expectedReports: JSON.parse(env("EXPECTED_REPORTS") || "[]"),
failedInvocations: JSON.parse(env("FAILED_INVOCATIONS") || "[]"),
};
writeFileSync(join(out, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`);
setOutputs({ dir: out });
}

/** A missing, truncated, or foreign report reads as absent, which the aggregation flags. */
function readCases(path: string, root: string): ReturnType<typeof parseVitestJson> | undefined {
try {
const results: JsonTestResults = JSON.parse(readFileSync(path, "utf8"));
return parseVitestJson(results, root);
} catch {
return undefined;
}
}

function readResults(dir: string): RunResult[] {
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter((name) => existsSync(join(dir, name, "meta.json")))
.map((name) => {
const runDir = join(dir, name);
const meta: RunMeta = JSON.parse(readFileSync(join(runDir, "meta.json"), "utf8"));
const reports = readdirSync(runDir)
.filter((file) => file.endsWith(".json") && file !== "meta.json")
.flatMap((file) => {
const cases = readCases(join(runDir, file), meta.root);
const iteration = Number(/\.(\d+)\.json$/.exec(file)?.[1] ?? "1");
return cases === undefined ? [] : [{ name: file, iteration, cases }];
});
return { meta, reports };
});
}

function reportCommand(): void {
const [resultsDir = "results", outDir = "flaky-report"] = process.argv.slice(3);
const matrix: { include: Entry[] } = JSON.parse(env("MATRIX") || '{"include":[]}');
const report = aggregate(
readResults(resultsDir),
matrix.include.map((entry) => entry.name),
env("FILTER") === "" ? ["focused"] : [],
);
const markdown = renderMarkdown(report, {
sha: env("SHA"),
runUrl: `${env("GITHUB_SERVER_URL")}/${env("GITHUB_REPOSITORY")}/actions/runs/${env("GITHUB_RUN_ID")}`,
});
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
writeFileSync(join(outDir, "report.md"), markdown);
appendFileSync(env("GITHUB_STEP_SUMMARY") || "/dev/stdout", markdown);
if (!isClean(report)) {
console.log(
"::error ::Flaky, failing, or missing runs found; see the job summary or the flaky-check-report artifact.",
);
process.exitCode = 1;
}
}

/** Creates or updates the one PR comment that starts with the report marker. */
function commentCommand(): void {
const [reportPath = "flaky-report/report.md"] = process.argv.slice(3);
const issue = `repos/${env("GITHUB_REPOSITORY")}/issues`;
try {
const pages: { id: number; body: string; user: { login: string } }[][] = JSON.parse(
capture(["gh", "api", "--paginate", "--slurp", `${issue}/${env("PR_NUMBER")}/comments`]),
);
const existing = pages
.flat()
.find(
(comment) =>
comment.user.login === "github-actions[bot]" && comment.body.startsWith(COMMENT_MARKER),
);
const target =
existing === undefined
? ["POST", `${issue}/${env("PR_NUMBER")}/comments`]
: ["PATCH", `${issue}/comments/${existing.id}`];
capture([
"gh",
"api",
"--method",
...target,
"--raw-field",
`body=${commentBody(readFileSync(reportPath, "utf8"))}`,
]);
} catch (error) {
// The report stays in the job summary and artifact, so a comment failure only warns.
console.log(
`::warning::Unable to post the flaky-check PR comment: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

if (import.meta.main) {
const command = process.argv[2];
try {
switch (command) {
case "plan":
planCommand();
break;
case "run":
process.exitCode = await runCommand();
break;
case "collect":
collectCommand();
break;
case "report":
reportCommand();
break;
case "comment":
commentCommand();
break;
default:
throw new CheckError(
`usage: cli.ts plan|run|collect|report|comment, got '${command ?? ""}'`,
);
}
} catch (error) {
if (!(error instanceof CheckError)) {
throw error;
}
console.log(`::error ::${error.message}`);
process.exitCode = 1;
}
}
60 changes: 60 additions & 0 deletions .github/scripts/flaky-check/fixtures/iteration-1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"testResults": [
{
"name": "/repo/packages/api/src/hook.unit.test.ts",
"status": "passed",
"message": "",
"assertionResults": [
{
"ancestorTitles": ["hook"],
"title": "runs after the hook",
"status": "passed",
"failureMessages": []
}
]
},
{
"name": "/repo/packages/api/src/import-error.unit.test.ts",
"status": "failed",
"message": "import failure",
"assertionResults": []
},
{
"name": "/repo/packages/api/src/probe.unit.test.ts",
"status": "failed",
"message": "",
"assertionResults": [
{
"ancestorTitles": ["sometimes"],
"title": "fails on the second iteration",
"status": "passed",
"failureMessages": []
},
{
"ancestorTitles": ["two errors"],
"title": "test and afterEach both fail on the second iteration",
"status": "passed",
"failureMessages": []
},
{
"ancestorTitles": ["@supabase/api"],
"title": "twin",
"status": "passed",
"failureMessages": []
},
{
"ancestorTitles": ["@supabase/api"],
"title": "twin",
"status": "failed",
"failureMessages": ["Error: second twin"]
},
{
"ancestorTitles": ["@supabase/api"],
"title": "skipped",
"status": "skipped",
"failureMessages": []
}
]
}
]
}
Loading
Loading