* feat(flaky): gstack-flaky run reruns a failed JUnit test once and ledgers the flake (GSTA-158) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(flaky): stream output, keep unreported runner failures red, fail-closed duplicates and nonzero reruns Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes gstack-flaky never greens an incomplete run: a targeted rerun needs the runner's summary proving the first run complete, otherwise (and always for generic runners) the rerun repeats the whole command. A rerun clears a test only when it exits 0 and the test passes once on its first attempt. Killed runners exit 3 (FLAKY_COMMAND_KILLED) and signals are forwarded. The first report is restored at --junit with reruns kept beside it; ids are redacted at every output; the ledger gets its own GSTACK_FLAKE_LEDGER_JUNIT; fixtures are registered in FREE_FIXTURES. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: latch cancellation and check whole-command rerun reports A cancel forwarded to a runner that traps the signal now counts as killed for every later attempt, so a cancelled job is never rerun or greened. A whole-command rerun clears nothing when its report has a failed test, misses a first-run test, or its runner counted failures outside the report. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: whole-command rerun when coverage or runner-flaky tests can fail the run A coverage threshold or a fail-on-flaky policy fails a run for a reason a narrowed rerun cannot reproduce, so either forces a whole-command rerun. --junit refuses an existing file that is not a JUnit report instead of deleting it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(flaky): rerun the whole command by default; targeted rerun is opt-in A rerun counts only on the runner's own full green verdict: exit 0, no failed or missing test, nothing counted outside the report. That covers every way a run can fail, which a narrowed rerun cannot (a coverage threshold with an lcov reporter, a fail-on-flaky policy, a test never reached). --rerun-scope test reruns the failed test alone, with its preconditions documented and the complete-run checks kept as a safety net. An unreadable existing --junit file is a usage error, not a crash. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: gstack-flaky CHANGELOG entry and standalone-binaries row (GSTA-158) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
8.2 KiB
Flaky tests: rerun once and record it (gstack-flaky)
gstack-flaky run runs your test command, reads the JUnit report it writes,
and when tests failed, runs the command once more. A test that passes on its
rerun is a flaky pass: the job stays green and the flake is recorded on a ledger, so
the daily flaky pass can fix it at the source. A test that fails twice is red.
This is CREW rule 16 ("run the failed test again and note that it is flaky")
for any runner that writes JUnit: Playwright, bun test, jest, vitest and
pytest. gstack's own free suite keeps its file-level retry
(GSTACK_FREE_RETRY_FLAKY, Flake ledger).
Get started in GitHub Actions
- uses: oven-sh/setup-bun@v2
with: { bun-version: 1.4.4 }
- name: Install gstack-flaky (pinned)
env: { GSTACK_REF: <a gstack commit sha you have tested> }
run: |
git clone --quiet --filter=blob:none https://github.com/garrytan/gstack.git "$RUNNER_TEMP/gstack"
git -C "$RUNNER_TEMP/gstack" checkout --quiet "$GSTACK_REF"
- name: E2E
env:
PLAYWRIGHT_JUNIT_OUTPUT_NAME: results/junit.xml
GSTACK_FLAKE_LEDGER_JUNIT: ${{ runner.temp }}/flaky-junit-ledger.jsonl
run: |
"$RUNNER_TEMP/gstack/bin/gstack-flaky" run --junit results/junit.xml -- npx playwright test --reporter=line,junit
- if: always()
uses: actions/upload-artifact@v4
with: { name: flaky-junit-ledger-e2e, path: "${{ runner.temp }}/flaky-junit-ledger.jsonl", if-no-files-found: ignore, retention-days: 90 }
Replace GSTACK_REF with a gstack commit you have tested; the bin needs only
Bun and git. In Actions it also writes a step-summary table of the flaky
passes and one ::warning annotation per flaky test, so they are visible on
the run before anyone reads the ledger.
Runners
| Runner | Command after -- |
Notes |
|---|---|---|
| Playwright | npx playwright test --reporter=line,junit with PLAYWRIGHT_JUNIT_OUTPUT_NAME=<file> |
gstack-flaky sets PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1 so Playwright's own retries are reported; with retries configured, those attempts stand and nothing is rerun again. |
| bun | bun test --reporter=junit --reporter-outfile=<file> |
|
jest, vitest, pytest (--runner generic) |
your command, writing JUnit | Always reruns the whole command, even with --rerun-scope test. |
The command after -- runs without a shell. When it needs one, wrap it as
bash -c '<script> "$@"' _ so a targeted rerun's selection arguments reach
the runner.
Rerun scope
Default: the whole command. The rerun repeats your command exactly, and
counts only on the runner's own full green verdict: it exits 0, no test fails,
every test of the first run is in its report, and the runner counts no failure
outside the report. That verdict covers every reason a run can fail, including
the ones a narrowed rerun cannot see: a test the first run never reached
(--bail, --max-failures, -x), a coverage threshold (however coverage is
configured, bunfig.toml and lcov included), a fail-on-flaky policy, a
global setup error. A test that failed in the first run and passes once in this
rerun is a flaky pass. It costs one more run of the suite, which is still far
cheaper than rerunning the CI job by hand.
Opt-in: --rerun-scope test. Reruns the failed test alone (Playwright:
<file> --grep <title> --project <project>; bun: <file> --test-name-pattern <title>)
and clears it when that rerun exits 0. Use it only when the first run's exit
status can come from failed tests alone: no coverage threshold in any form, no
--fail-on-flaky-tests, no other step inside the command that can fail. As a
safety net, gstack-flaky still reruns the whole command
(FLAKY_RERUN: scope=command (<why>)) when the runner exited anything but
1, its summary is missing, it stopped early (bun Bailed out, Playwright
did not run, interrupted, Testing stopped early, a global timeout), it
printed a coverage table or got --coverage, or it retried flaky tests itself.
It cannot see a coverage threshold configured without console output (for
example coverageReporter = ["lcov"] in bunfig.toml), which is why this
scope is opt-in.
Rules
- One rerun, small failures only. A failed test gets one rerun when the
runner made no retries and at most
--max-reruns(3) tests failed. More failures than that is a real red, and nothing is rerun. When the run is already red for a test that cannot be rerun, a whole-command rerun is skipped. - A rerun clears a test only cleanly. The rerun's command must exit 0 and the test must run once and pass on its first attempt; a rerun the runner retried again does not count.
- Never green by accident. The report is deleted before every attempt, and
a report that attempt did not write counts as missing. A missing, empty,
zero-test or malformed report fails, as does a non-zero exit with no failed
test (a crash or a non-test step) and a runner killed by a signal. Cancelling
gstack-flaky(SIGINT, SIGTERM) is passed to the runner and latched: nothing is rerun after it, whatever exit the runner chooses. When the runner's summary counts failures its JUnit omits (a bun file that fails to load, a Playwright error outside any test), the run is red and nothing is rerun. - Reports. After the run,
--junitholds the first run's report, so a later upload or test reporter sees what failed. Each rerun's report is kept beside it as<name>.rerun-<n>.xml. Other reporters' outputs (Playwright'stest-results/and HTML report) are rewritten by a rerun; upload the JUnit report as the record. An existing--junitfile that is not a JUnit report, or cannot be read, is refused (usage, exit 2), never deleted. - Ids. A test is
<file>::<project>::<describe > title>, with the project empty outside Playwright, so a test in two browser projects is two tests.gstack-flaky ids --junit <file>prints them. - Ledger. One JSON line per flaky pass (
kind: flaky-pass) and per test that failed its rerun (rerun-fail), with the tree, commit, branch, run URL, attempts, the rerun's scope (testorcommand) and the first lines of the error. Ids, paths and failure text go through gstack's redaction engine first; when the scan cannot run, they are withheld. The path is--ledger, elseGSTACK_FLAKE_LEDGER_JUNIT, else<state root>/flake-ledger-junit.jsonl, andFLAKY_RESULTnames it. It is kept apart from the free suite's file-level ledger (GSTACK_FLAKE_LEDGER). A ledger write error is a warning and never changes the verdict.
Output and exit codes
One FLAKY: <flaky-pass|fail|rerun-fail> <id> attempts=<…> (<reason>) line per
test that failed at least once, FLAKY_RERUN: scope=command (<why>) when the
rerun repeated the whole command, then
FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>.
| Exit | Meaning |
|---|---|
| 0 | Every test passed, flaky passes included |
| 1 | A test failed (after its rerun, or with no rerun), including FLAKY_UNREPORTED_FAILURES |
| 2 | Usage error, including a --junit path that is not a .xml file, or an existing file there that is not a readable JUnit report |
| 3 | No usable report, a crash, or a killed runner (a code below on stderr) |
| Code | Exit | Cause | Fix |
|---|---|---|---|
FLAKY_NO_RESULTS |
3 | No report at --junit, an empty one, or one with no testcase |
Make the command write JUnit to that path |
FLAKY_JUNIT_INVALID |
3 | The report is not well-formed XML | Make sure one process writes it and nothing appends to it |
FLAKY_RETRIES_HIDDEN |
3 | Playwright retried but its report omits the retries | Set PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1 where the report is written |
FLAKY_COMMAND_FAILED |
3 | Non-zero exit with no failed test | Read the command output: a real red |
FLAKY_COMMAND_KILLED |
3 | The runner was killed by a signal (a cancelled job, an OOM kill) | Nothing was rerun; rerun the job |
FLAKY_COMMAND_NOT_STARTED |
3 | The command could not be started | Check the argv after -- |
FLAKY_UNREPORTED_FAILURES |
1 | The runner counted failures or errors its JUnit omits | Fix the load error or the error outside any test: a real red |
FLAKY_LEDGER_WRITE_FAILED |
— | The ledger could not be appended (warning) | Check the path; the verdict stands |