Aster can record every test a CI run executes, retry only the tests that failed, tell flaky tests from broken ones across runs, and keep known flakes from failing the build. This page covers the whole loop:
CI runner aster-srv (Postgres)
--------- --------------------
aster --test-results --retry-failed ──▶ POST /v1/test-results/runs
capture each runner's report classifier, every 5 min:
retry failing tests flaky / broken / healthy
quarantine list at start ◀────────────── quarantine list + webhooks
step summary + annotations /ui dashboard (behind IAP)
Everything on the CI side works without a server. A server adds history,
labels (new, broken, known flake) and the automatic quarantine list.
--test-results[=PATH] (or ASTER_TEST_RESULTS=PATH, or =1 for a temp
path) reads each runner's own report and writes one NDJSON file per Aster
invocation: a run record, a record per target, and one per test attempt.
| Runner | How Aster reads it | Confidence |
|---|---|---|
mix test (ExUnit) |
Aster's formatter, loaded beside ExUnit.CLIFormatter, checked against ExUnit's summary |
complete |
go test |
-json, rendered back to plain go test output in the log |
complete |
pytest |
--junitxml |
complete |
Vitest, bun test |
JUnit reporter beside the default one | complete |
| Jest | --json |
complete |
node --test |
Aster's reporter | complete |
cargo nextest run |
JUnit profile | complete |
cargo test |
console test x ... ok/FAILED lines |
partial |
| Gradle, Maven | existing build/test-results and target/surefire-reports files |
complete |
partial means some tests may be missing. Partial results are still uploaded
and classified, but they never let a quarantined failure pass.
cargo nextest run writes JUnit only when the repository's nextest config
gives the profile a junit.path; without it nothing is recorded.
Aster recognises a runner in the target's command, directly or as the last
command of a sh -c one-liner. A command that runs a script (bash tools/cargo-test.sh, node scripts/related-tests.mjs) hides its runner, and
the target records no tests. Such a target declares its report instead:
[targets.test]
command = "bash tools/cargo-test.sh"
[targets.test.report]
format = "junit" # junit, go-json, jest-json or libtest
files = ["target/nextest/ci/junit.xml"]The command runs as written, with ASTER_TEST_REPORT_DIR set to an empty
directory outside the checkout. Without files, Aster reads every file the
command writes there (go test -json ./... > "$ASTER_TEST_REPORT_DIR/go.json").
With files, it reads those globs under the project directory. Either way only
files written while the command ran count, so an old report is never read as
this run's. Prefer ASTER_TEST_REPORT_DIR: a fixed path can be read by
another target that writes the same file, and when --only-affected-files
splits a target into several commands only the last command's file is read
(the results are then partial). files are relative to the project
directory, not a target's working_dir. A declared report wins over
recognising the command. libtest (saved cargo test console output) is
partial; the others are complete, and the capture is recorded as
declared-<format>.
A declared report never lets a quarantined failure pass: Aster does not see
the runner, so it cannot tell a test failure from a failed step of the script
by the exit code. Nor does it make the target retryable: --retry-failed
still needs a command Aster recognises.
--retry-failed[=N] (or ASTER_RETRY_FAILED=N) reruns only the failing
tests, with the runner's own selection flags, and records each attempt. See
Retrying failed tests for the runner
table and retry_setup.
| Variable | |
|---|---|
ASTER_SRV_URL |
The server. Set, Aster uploads after the run and fetches the quarantine list before it. |
ASTER_SRV_TOKEN |
A static token. Without it, GitHub Actions' OIDC token is used (the job needs permissions: id-token: write). |
ASTER_SRV_AUDIENCE |
The OIDC audience; default aster-srv. |
ASTER_SRV_PROXY_TOKEN |
Sent as Proxy-Authorization when set, for a deployment that still keeps an identity-aware proxy in front of /v1. The server ignores it. A laptop does not set it: see Reading the cache from a laptop. |
ASTER_GITHUB_SUMMARY |
0 stops the step summary and annotations in GitHub Actions. |
ASTER_QUARANTINE |
off stops quarantine; failures fail as usual. |
ASTER_BALANCE_PARTITIONS |
off keeps Mix's own split for mix test --partitions shards. |
An upload or lookup that fails never fails the build. aster results upload <file>, aster results summary <file> [--github] and aster results failures --github-run <id> do the same steps by hand.
A GitHub Actions job:
permissions:
id-token: write
contents: read
steps:
- run: aster affected test --retry-failed --test-results
env:
ASTER_SRV_URL: https://aster-srv.example.comThe step summary lists each failing test as new, broken (with the commit
it broke at), known flake, quarantined or, without a server, failed,
and each gets an annotation on its file.
CI authenticates every request with its GitHub OIDC token. A laptop or a devbox has none, so it signs in as a person, once per machine (a devbox does not share the laptop's sign-in):
export ASTER_SRV_URL=https://aster-srv.example.com
aster srv loginASTER_SRV_URL must be https://; plain http:// is accepted only for
127.0.0.1, ::1 and localhost, because the code, verifier and token would
cross the network in the clear.
aster srv loginmakes a PKCE verifier and a short user code (ABCD-EFGH), prints the code, and opens$ASTER_SRV_URL/cli-authin your browser.- You sign in there with your company Google account (the server's identity-aware proxy asks). The page shows the user code, your address and browser. Click Authorize only if the code matches your terminal.
- The server redirects the browser to
http://127.0.0.1:<port>/callbackwith a single-use code (valid 60 seconds). Aster trades the code, plus the PKCE verifier and user code, for a token. - The token is stored in
$XDG_CONFIG_HOME/aster/cache-tokens/<server host>.json(else~/.config/aster/...;%APPDATA%\aster\...on Windows), mode0600in a0700directory (on Windows an ACL naming only you; if Aster cannot set it, it does not store the token). Aster ignores a token file that group or others can read and tells you to log in again. The token is never printed, and it lives outside the repository. It lasts 12 hours by default (ASTER_SRV_CLI_TOKEN_TTLon the server); then runaster srv loginagain.
The next aster run with ASTER_SRV_URL set sends the token as a bearer
token and restores a cached target. Nothing else needs configuring.
A browser on your laptop cannot reach 127.0.0.1 on the remote machine, so
Aster uses a pasted code there. It picks that flow by itself when
SSH_CONNECTION, SSH_CLIENT or SSH_TTY is set, when a Linux shell has
neither DISPLAY nor WAYLAND_DISPLAY, or when the system browser fails to
start. aster srv login --no-browser asks for it explicitly:
$ aster srv login
This looks like a remote shell, so Aster will ask for a code instead of a redirect.
Open this URL in a browser on any computer and sign in:
https://aster-srv.example.com/cli-auth?mode=copy&challenge=...&state=...&user_code=K7QM2XWD
Confirm only if the page shows this code: K7QM-2XWD
Request id: Xb3kP9aQ
Paste the code the page shows, then press Enter: <paste here>
Signed in as ada@example.com. Token stored at ...- Open the printed URL in a browser on any computer. Confirm the page shows the same user code and your own address.
- Click Authorize. The page shows a one-time code (never a token) and the request id.
- Paste the code into the terminal. It works once and expires after 60 seconds, like the loopback code; the PKCE verifier stays on the remote machine, so the code alone signs nobody in.
--loopback forces the browser redirect, for example with
ssh -R PORT:127.0.0.1:PORT forwarding (add --no-browser to print the URL
instead of opening one).
aster srv logout deletes the stored token and asks the server to refuse
every token issued to you before now, on every machine. If the server cannot
be reached the local file is still deleted and the command fails, so run it
again later. Operators can turn person sign-in off for everyone with
ASTER_SRV_PERSON_TOKENS=off on the server: /cli-auth answers 404 and every
person token is refused.
Signed out, or with an expired or refused token, aster run prints one line
(not signed in; run aster srv login) and builds from the local cache. It
does not fail. If the server has not enabled sign-in, aster srv login
says the server refused it.
A person's token reads and never writes: it cannot upload or commit (the
server answers 403), cannot upload test results, and Aster never asks to
write with it. Entries CI writes carry the CI runner's platform in their key,
so a macOS laptop mostly misses entries written on Linux CI.
The same token reads every dataset the /ui pages show, as JSON, for any
repository the server serves. aster results and aster cache stats print
it; the repository defaults to
GITHUB_REPOSITORY, else the checkout's origin (--repo owner/name
overrides).
/ui page |
Command | Route |
|---|---|---|
| Cache | aster cache stats |
GET /v1/cache/summary, /v1/cache/entries?target= |
| Flaky tests | aster results flaky |
GET /v1/test-results/tests?state=flaky |
| Flaky tests (broken list) | aster results broken |
GET /v1/test-results/tests?state=broken |
| Failure clusters | aster results clusters |
GET /v1/test-results/clusters |
| Quarantine | aster results quarantine |
GET /v1/test-results/quarantine |
| A test | aster results test <id> |
GET /v1/test-results/tests/<id> |
| Anything else | aster srv get '<path>' |
any GET /v1/... route, such as /v1/test-results/durations?... or /v1/test-results/runs/<id>/failures |
The pre-0.25 spellings (aster cache login/logout, aster srv flaky,
broken, quarantine, clusters, test, cache) still work, are hidden
from --help, and print the new name on stderr.
With CI credentials in the environment (ASTER_SRV_TOKEN, GitHub OIDC) the CI
token is used and reads its own repository. Not signed in, or a refused token,
exits non-zero with the reason.
The server classifies every run. A test is a definite flake when it failed
and then passed on a retry in one run, or when two runs with the same target
input_hash (the same inputs) disagree. Each failing run counts once. When
many of a target run's tests fail together (a crashed shard or a dead
database), the server records one incident for the target run instead of
a flake per test. A test is broken when its last 3 default-branch runs
failed the same way; broken tests are never quarantined.
The details are in the server README.
A quarantined test still runs and still uploads. When it fails, its target
may report passed (quarantined: N) instead of failing, and its dependents
run.
All of these, after retries:
- The target's results are
complete. - Every test whose last attempt failed is quarantined, and there is at least one.
- The command exited with its runner's "tests failed" code: ExUnit 2,
nextest 100, pytest,
go test, Jest, Vitest,bun testandnode --test- A crash, compile error, timeout or signal exits otherwise and still
fails. Gradle, Maven and
cargo testuse one code for test failures and compile errors, so their targets never pass this way.
- A crash, compile error, timeout or signal exits otherwise and still
fails. Gradle, Maven and
ASTER_QUARANTINEis notoff.
A target that runs several commands, or whose results are not captured (no
--test-results and no --retry-failed), is never judged. When some failures
are quarantined but the target still fails, its log says why.
Each failure let through gets a GitHub ::warning annotation and a row in the
step summary; --json lists them under tests.quarantined; the results file
marks those attempts "quarantined": true. A pass that needed the quarantine
is not cached.
At start Aster prints the list it will use, for example:
[aster] Quarantine: 4 from aster-srv (version 312), 1 from aster-quarantine.toml
The server quarantines a test that is not broken once definite flakes
from 2 different failing runs fall within 7 days, counting only flakes whose
runs are both default-branch runs by their credentials (a push, dispatch or
schedule of the default branch): a pull request job's results score flakes
but never quarantine. A run that flaked more than 10 tests counts toward none
of them, and an incident's failures never count. It releases the test after 20
consecutive passes, when it breaks, or after 30 days; a released test starts
afresh. The list is fetched at start and cached in .aster/quarantine.json.
If the server cannot be reached, a cache younger than 24 hours stands in;
otherwise only the repository file applies, so nothing extra is let through.
The repository can quarantine tests itself in aster-quarantine.toml at
the workspace root, reviewed like any change. Every entry needs a reason and
an expires date, and stops applying after that date (with a warning). An
entry names a test by id (shown on the dashboard), or by name with
optional file, suite and target (a project or one of its targets) to
narrow it:
# aster-quarantine.toml
[[test]]
name = "test creates an agent"
file = "test/platform/agents_test.exs"
target = "//services/platform"
reason = "DBConnection.OwnershipError under load; tracked in #4812"
expires = 2026-11-30
[[test]]
id = "c81e728d9d4c2f636f067f89cc14862c0f5b6a1e9e9e0f4c6b1a2d3e4f5a6b7c"
reason = "Times out on the 4-core runners"
expires = 2026-11-15A malformed file stops the run with an error naming the entry.
A target that runs one partition of a Mix suite
(MIX_TEST_PARTITION=K mix test --partitions N) runs an explicit file list
when aster-durations.json at the workspace root times its suite: the
suite's files split longest-first by median duration. The file comes from
the checkout, so every shard of a commit splits the same way; nothing is
fetched while the suite runs. aster results durations writes it from the
server; commit it and refresh it on a schedule (see
Durations). Without the file, the
suite in it, or with ASTER_BALANCE_PARTITIONS=off, every shard keeps Mix's
own split. A shard's tests are recorded under the project whose suite it
runs (the directory the command cds to), so a test has one id, and one
quarantine entry, whichever shard ran it.
The server can POST to webhooks when a test is quarantined, released, expires from quarantine, or breaks. See Webhooks.
aster-srv is one binary (and the image
ghcr.io/archastro/aster-srv:<aster version>) that needs Postgres. The
server README covers configuration,
authentication, the dashboard and operating it.