Firestore flake probe #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Measures the `test/firestore.test.tsx` flake rate (#776) in CI rather than locally. | |
| # | |
| # Every measurement of this flake so far has been on a laptop, where the failure looks | |
| # like a plain `waitFor` timeout. In CI it comes with a gRPC framing desync | |
| # (`RESOURCE_EXHAUSTED: Received message larger than max`), which may mean the two are | |
| # not the same bug. This runs both `@grpc/grpc-js` arms on both Node versions under CI | |
| # conditions so the comparison is made where the failure actually happens. | |
| # | |
| # Both arms run inside a single job, sequentially on the same runner. They are | |
| # deliberately NOT a matrix dimension: the whole premise is that the machine matters, | |
| # so splitting the arms across two runners would reintroduce the variable being tested. | |
| # | |
| # WORKLOAD. The first run of this probe (2026-08-07) came back 120/120 clean in | |
| # `firestore-only` mode, with zero `RESOURCE_EXHAUSTED`. That configuration runs one | |
| # emulator and one test file, while the failures in #776 come from `npm run test`: five | |
| # emulators and the whole suite. So the isolated suite does not reproduce either failure | |
| # and cannot serve as a control. `full-suite` runs the actual CI workload instead. | |
| # | |
| # WHAT IT MEASURES NOW (2026-08-10). The grpc-js override was tested and does not fix the | |
| # flake, so the open question is timing: whether the iterations that fail are the ones | |
| # where the emulators were slow. Each iteration records its health-check durations. | |
| # | |
| # Manual only. It never runs on a push, a PR or a schedule, so it costs nothing until | |
| # someone asks for it. | |
| name: Firestore flake probe | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| workload: | |
| description: "Which workload to run each iteration" | |
| required: false | |
| type: choice | |
| default: "full-suite" | |
| options: | |
| - "full-suite" | |
| - "firestore-only" | |
| iterations: | |
| description: "Test runs per arm (full-suite ~23s each, firestore-only ~8s each)" | |
| required: false | |
| default: "30" | |
| node_versions: | |
| description: "JSON array of Node majors to probe" | |
| required: false | |
| default: '["22", "24"]' | |
| arms: | |
| description: "JSON array of grpc-js arms: baseline, override, or both" | |
| required: false | |
| # Baseline alone by default since 2026-08-10: the override was measured and does | |
| # not fix the flake (#776). Kept as an option rather than deleted. | |
| default: '["baseline"]' | |
| # Least privilege. This workflow reads the repo and writes nothing back. | |
| permissions: | |
| contents: read | |
| jobs: | |
| probe: | |
| runs-on: ubuntu-latest | |
| # Sized 2026-08-07 on the `Run tests` step of the real CI job (not the whole job, | |
| # which is ~57s including install and setup). | |
| # | |
| # ⚠️ Do NOT size this on a single figure. Across recent CI runs that step lands | |
| # anywhere from 20 to 31 seconds, and a timeout cares about the slow tail, not the | |
| # median. At 31s the 200 cap wants ~207 minutes, which a flat-23s estimate (~153) | |
| # would have put comfortably inside 180. And if #776's reported ~120s hang ever | |
| # reproduces, those iterations cost far more than any of this. | |
| # | |
| # The default 30 per arm is ~28-38 minutes full-suite and is safe under any reading. | |
| # This ceiling exists for the 200 cap, so it is set past the pessimistic figure | |
| # rather than the average one. The `always()` summary step means a run that does hit | |
| # the wall still reports the arms that finished. | |
| timeout-minutes: 240 | |
| strategy: | |
| matrix: | |
| node: ${{ fromJSON(inputs.node_versions) }} | |
| fail-fast: false | |
| name: Probe Node ${{ matrix.node }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| persist-credentials: false | |
| - name: Setup node | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| check-latest: true | |
| cache: 'npm' | |
| - name: Setup Java | |
| uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 | |
| with: | |
| distribution: 'temurin' | |
| java-version: '21' | |
| - name: Firebase emulator cache | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ~/.cache/firebase/emulators | |
| key: firebase_emulators | |
| - name: Install deps | |
| run: npm ci | |
| # The full suite starts the functions emulator, which will not come up without | |
| # these. `test.yaml` does the same thing before `npm run test`. Skipped in | |
| # firestore-only mode, where no functions emulator is started. | |
| - name: Install deps for functions | |
| if: ${{ inputs.workload == 'full-suite' }} | |
| run: npm install --no-audit --no-fund | |
| working-directory: ./functions | |
| # Inputs and matrix values are passed through `env` rather than interpolated into | |
| # the script body, so nothing from the dispatch form can be executed as shell. | |
| # | |
| # Counts are appended to `probe-counts.tsv` as each arm finishes, and the summary | |
| # is rendered by a separate `always()` step. If the job hits its timeout partway | |
| # through, whatever was measured before the wall still gets reported. | |
| - name: Run the probe | |
| env: | |
| ITERATIONS: ${{ inputs.iterations }} | |
| ARMS: ${{ inputs.arms }} | |
| WORKLOAD: ${{ inputs.workload }} | |
| NODE_MAJOR: ${{ matrix.node }} | |
| run: | | |
| # ⚠️ errexit is ON here even though nothing below turns it on: GitHub runs an | |
| # undeclared `run:` step as `bash -e {0}`, and `set -uo pipefail` does not | |
| # disable it. Every command that is ALLOWED to fail therefore has to say so. | |
| # Getting this wrong means the step dies on the first failing iteration and | |
| # the probe can only ever report a clean table, which is the one failure mode | |
| # that makes the whole workflow useless. See the loop below. | |
| set -uo pipefail | |
| # Guard against a non-numeric or absurd `iterations` before it reaches the loop. | |
| case "$ITERATIONS" in | |
| ''|*[!0-9]*) echo "iterations must be a positive integer, got '$ITERATIONS'"; exit 1 ;; | |
| esac | |
| if [ "$ITERATIONS" -lt 1 ] || [ "$ITERATIONS" -gt 200 ]; then | |
| echo "iterations must be between 1 and 200, got '$ITERATIONS'" | |
| exit 1 | |
| fi | |
| # `full-suite` reproduces what `npm run test` does in CI: every emulator in | |
| # firebase.json, every test file. `firestore-only` is the narrower original, | |
| # kept because it isolates the Firestore client and is ~3x faster per run. | |
| case "$WORKLOAD" in | |
| full-suite) | |
| EMULATOR_ARGS="" | |
| VITEST_ARGS="" | |
| ;; | |
| firestore-only) | |
| EMULATOR_ARGS="--only firestore" | |
| VITEST_ARGS="firestore" | |
| ;; | |
| *) | |
| echo "workload must be full-suite or firestore-only, got '$WORKLOAD'" | |
| exit 1 | |
| ;; | |
| esac | |
| echo "Workload: $WORKLOAD" | |
| # Validate the arm list rather than trusting the dispatch form, and normalize | |
| # it to a space-separated list. Order is forced baseline-then-override because | |
| # applying the override mutates node_modules for everything after it. | |
| ARM_LIST="$(node -e ' | |
| const arms = JSON.parse(process.env.ARMS); | |
| if (!Array.isArray(arms) || arms.length === 0) throw new Error("arms must be a non-empty JSON array"); | |
| const allowed = ["baseline", "override"]; | |
| for (const a of arms) if (!allowed.includes(a)) throw new Error("unknown arm: " + a); | |
| process.stdout.write(allowed.filter((a) => arms.includes(a)).join(" ")); | |
| ')" || exit 1 | |
| mkdir -p probe-logs | |
| : > probe-counts.tsv | |
| : > probe-unmatched.txt | |
| : > probe-iterations.tsv | |
| for arm in $ARM_LIST; do | |
| echo "::group::Arm: $arm" | |
| # `npm pkg set` mangles keys containing a slash, so edit package.json directly. | |
| # `npm install` (not `npm ci`) is required here because applying an override | |
| # necessarily changes the lockfile. | |
| if [ "$arm" = "override" ]; then | |
| node -e ' | |
| const fs = require("fs"); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| pkg.overrides = { ...pkg.overrides, "@grpc/grpc-js": "^1.14.0" }; | |
| fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n"); | |
| ' | |
| npm install --no-audit --no-fund | |
| fi | |
| resolved="$(node -p "require('@grpc/grpc-js/package.json').version")" | |
| echo "Arm $arm resolved @grpc/grpc-js: $resolved" | |
| pass=0 | |
| flake=0 | |
| infra=0 | |
| hang=0 | |
| grpc_err=0 | |
| flake_with_grpc=0 | |
| for i in $(seq 1 "$ITERATIONS"); do | |
| log="probe-logs/$arm-run-$i.log" | |
| json="probe-logs/$arm-run-$i.json" | |
| # A fresh emulator start per iteration, matching how `npm test` runs in CI. | |
| # Reusing one emulator across iterations would measure a different thing. | |
| # Unquoted on purpose: both are either empty or a fixed literal set above, | |
| # never user input. | |
| # | |
| # ⚠️ `set +e` is LOAD-BEARING, do not remove it. This command failing is the | |
| # entire point of the probe, but the step runs under `bash -e`, so without | |
| # this the first flake kills the step before `rc` is even read: no | |
| # classification, no tally for the arm, and an empty or half-written | |
| # probe-counts.tsv. It was removed once on the reasoning that the script | |
| # never sets `-e` itself, which is true and irrelevant. | |
| # ⚠️ The json reporter is what makes the timing usable; do not read durations | |
| # out of the human log instead. The default reporter prints a per-test line | |
| # only above its 300ms slow threshold, pass or fail, so the log drops auth | |
| # entirely and censors the fast end of firestore and database. A comparison | |
| # across outcomes needs every iteration, not the slow ones. | |
| set +e | |
| npx firebase emulators:exec $EMULATOR_ARGS --project=rxfire-525a3 \ | |
| "npx vitest run $VITEST_ARGS --reporter=default --reporter=json --outputFile.json=$json" > "$log" 2>&1 | |
| rc=$? | |
| set -e | |
| # Health-check duration per emulator, in ms: firestore, auth, database. All | |
| # three so a slow firestore round trip can be told apart from a slow runner. | |
| # | |
| # ⚠️ Scoped by FILE, not by test title. All three files open with a test named | |
| # `double check - emulator is running`, so matching the title alone records | |
| # whichever one vitest emitted first. | |
| # | |
| # `na` when the json is missing or unparseable, and for auth and database in | |
| # firestore-only mode. Never read it as a fast run. | |
| health_line="$(node -e ' | |
| const fs = require("fs"); | |
| const p = process.argv[1]; | |
| const files = ["test/firestore.test.tsx", "test/auth.test.tsx", "test/database.test.tsx"]; | |
| const out = files.map(() => "na"); | |
| if (!fs.existsSync(p)) { process.stdout.write(out.join("\t")); process.exit(0); } | |
| let report; | |
| try { report = JSON.parse(fs.readFileSync(p, "utf8")); } | |
| catch { process.stdout.write(out.join("\t")); process.exit(0); } | |
| for (const file of report.testResults || []) { | |
| const idx = files.findIndex((f) => String(file.name || "").includes(f)); | |
| if (idx === -1) continue; | |
| for (const a of file.assertionResults || []) { | |
| if (a.title === "double check - emulator is running" && typeof a.duration === "number") { | |
| out[idx] = String(Math.round(a.duration)); | |
| break; | |
| } | |
| } | |
| } | |
| process.stdout.write(out.join("\t")); | |
| ' "$json" 2>/dev/null || true)" | |
| [ -n "$health_line" ] || health_line="$(printf 'na\tna\tna')" | |
| IFS=$'\t' read -r health_ms health_auth health_db <<< "$health_line" | |
| saw_grpc=0 | |
| if grep -q "RESOURCE_EXHAUSTED: Received message larger than max" "$log"; then | |
| grpc_err=$((grpc_err + 1)) | |
| saw_grpc=1 | |
| fi | |
| # ⚠️ EVERY QUESTION BELOW IS SCOPED TO FIRESTORE'S OWN OUTPUT, and it has to | |
| # be. In firestore-only mode anything in the log was necessarily about #776. | |
| # In full-suite mode that is false: `expected 'loading' to deeply equal | |
| # 'success'` is just what vitest prints when a data hook's status assertion | |
| # fails, and it appears in 6 of the 9 test files, 40 times over. An unscoped | |
| # search counts a slow storage upload or a functions failure as a #776 flake. | |
| # | |
| # In a vitest log the FAIL line names the file and the assertion or timeout | |
| # sits on the NEXT line, verified against #781's real overnight failure, so | |
| # -A1 is the correct window. Captured into a variable rather than piped into | |
| # `grep -q`, because an early-exiting `grep -q` can SIGPIPE its producer and | |
| # `pipefail` would turn that 141 into a silent "no match". | |
| fs_fails="$(grep -A1 -E "FAIL.*test/firestore\.test\.tsx" "$log" || true)" | |
| if [ "$rc" -eq 0 ]; then | |
| pass=$((pass + 1)) | |
| outcome=pass | |
| echo "run $i: PASS (firestore health check ${health_ms}ms)" | |
| elif grep -qE "FAIL.*test/firestore\.test\.tsx.*double check - emulator is running" "$log"; then | |
| # `test/{auth,firestore,database}.test.tsx` each open with an emulator | |
| # health check. If FIRESTORE's fails, its emulator did not come up and no | |
| # firestore result this iteration means anything, so the run is void. | |
| # | |
| # ⚠️ Scoped to firestore deliberately. An unscoped check let ANY emulator's | |
| # health failure outrank a real firestore flake in the same run, filing it | |
| # as infra and dropping it from the rate. A non-firestore health failure | |
| # now falls through to the final `else`, where it is still excluded but is | |
| # recorded in probe-unmatched.txt instead of being silently miscounted. | |
| # | |
| # This must precede the hang check either way: a health check fails BY | |
| # timing out, so it would otherwise read as the #776 120s hang. | |
| infra=$((infra + 1)) | |
| outcome=infra | |
| echo "run $i: INFRA FAILURE (rc=$rc), firestore emulator health check failed, excluded from the rate" | |
| tail -20 "$log" | |
| elif grep -q "expected 'loading' to deeply equal 'success'" <<< "$fs_fails"; then | |
| # The #776 signature, in firestore's output specifically. | |
| flake=$((flake + 1)) | |
| # Whether the gRPC desync and the #776 assertion co-occur is the whole | |
| # question, so count the overlap rather than two independent totals. | |
| if [ "$saw_grpc" -eq 1 ]; then | |
| flake_with_grpc=$((flake_with_grpc + 1)) | |
| fi | |
| outcome=flake | |
| echo "run $i: FLAKE (rc=$rc, firestore health check ${health_ms}ms)" | |
| elif grep -qE "Test timed out in [0-9]+ms|Hook timed out in [0-9]+ms" <<< "$fs_fails"; then | |
| # #776 also reports a ~120s hang. A hang produces no assertion line, so | |
| # without this bucket it would land in `infra` and vanish from the rate. | |
| # Scoped like the flake check: a timeout in any other test file is not | |
| # the #776 hang and must not be presented as one. | |
| hang=$((hang + 1)) | |
| outcome=hang | |
| echo "run $i: HANG (rc=$rc, firestore health check ${health_ms}ms)" | |
| else | |
| # Everything else: emulator start failures, a non-firestore health check, | |
| # a failure in another test file. Counted separately because folding them | |
| # in previously inflated a local flake-rate estimate by ~50%. | |
| infra=$((infra + 1)) | |
| outcome=infra | |
| echo "run $i: INFRA FAILURE (rc=$rc), excluded from the rate" | |
| # Two ways to land here that must not be silent: vitest rewording the #776 | |
| # assertion (which would turn every real flake into an infra failure), and | |
| # a genuine failure in another test file. Record the first FAIL line from | |
| # anywhere in the log, not just firestore's, so both are visible. | |
| if line="$(grep -m1 -E "FAIL |AssertionError|expected .* to " "$log")"; then | |
| printf '%s run %s: %s\n' "$arm" "$i" "$line" >> probe-unmatched.txt | |
| fi | |
| tail -20 "$log" | |
| fi | |
| # One row per iteration, so the health-check duration can be compared across | |
| # outcomes rather than only totalled. The per-arm counts below stay as they | |
| # were; this is additive. | |
| printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ | |
| "$NODE_MAJOR" "$arm" "$i" "$outcome" \ | |
| "$health_ms" "$health_auth" "$health_db" "$saw_grpc" \ | |
| >> probe-iterations.tsv | |
| done | |
| printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ | |
| "$NODE_MAJOR" "$arm" "$resolved" \ | |
| "$pass" "$flake" "$infra" "$hang" "$grpc_err" "$flake_with_grpc" \ | |
| >> probe-counts.tsv | |
| echo "arm=$arm node=$NODE_MAJOR pass=$pass flake=$flake infra=$infra hang=$hang grpc_err=$grpc_err overlap=$flake_with_grpc" | |
| echo "::endgroup::" | |
| done | |
| # Rendered separately, and on `always()`, so a job killed by `timeout-minutes` | |
| # still reports every arm that finished before the wall. | |
| - name: Summarize | |
| if: ${{ always() }} | |
| env: | |
| NODE_MAJOR: ${{ matrix.node }} | |
| WORKLOAD: ${{ inputs.workload }} | |
| run: | | |
| set -uo pipefail | |
| if [ ! -s probe-counts.tsv ]; then | |
| echo "No arm completed; nothing to summarize." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| while IFS=$'\t' read -r node arm resolved pass flake infra hang grpc_err overlap; do | |
| counted=$((pass + flake)) | |
| if [ "$counted" -gt 0 ]; then | |
| # `< /dev/null` so the subshell cannot consume the loop's stdin. | |
| rate="$(node -e "process.stdout.write(((${flake}/${counted})*100).toFixed(1))" < /dev/null)" | |
| else | |
| rate="n/a" | |
| fi | |
| { | |
| echo "### Node ${node} / ${arm} / ${WORKLOAD} (@grpc/grpc-js ${resolved})" | |
| echo "" | |
| echo "| Outcome | Count |" | |
| echo "| --- | --- |" | |
| echo "| Pass | ${pass} |" | |
| echo "| Flake (#776 signature) | ${flake} |" | |
| echo "| ...of which also showed RESOURCE_EXHAUSTED | ${overlap} |" | |
| echo "| Hang (test timeout, no assertion) | ${hang} |" | |
| echo "| Infra failure (excluded) | ${infra} |" | |
| echo "| Runs showing RESOURCE_EXHAUSTED | ${grpc_err} |" | |
| echo "" | |
| echo "**Flake rate: ${rate}% of ${counted} counted runs.**" | |
| echo "" | |
| if [ "$hang" -gt 0 ] || [ "$infra" -gt 0 ]; then | |
| echo "> ${hang} hang(s) and ${infra} infra failure(s) are excluded from the rate." | |
| echo "" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| done < probe-counts.tsv | |
| { | |
| echo "---" | |
| echo "" | |
| if [ "$WORKLOAD" = "firestore-only" ]; then | |
| echo "⚠️ **A clean table here is not a verdict on CI.** This ran one emulator and" | |
| echo "one test file; \`npm run test\` in CI starts five emulators and runs the whole" | |
| echo "suite with parallel workers. The 2026-08-07 run of this mode came back 120/120" | |
| echo "clean with zero \`RESOURCE_EXHAUSTED\`, so this configuration is not known to" | |
| echo "reproduce either the local or the CI failure. Prefer \`full-suite\`." | |
| else | |
| echo "This ran the same workload as CI: every emulator in \`firebase.json\` and the" | |
| echo "whole test suite, one fresh emulator start per iteration. A failure in any" | |
| echo "test file counts, but only the #776 assertion signature counts toward the" | |
| echo "flake rate; anything else is reported separately and listed below." | |
| fi | |
| echo "" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Firestore's health check is a bare `addDoc` round trip and every `waitFor` in | |
| # test/firestore.test.tsx uses the 1000ms default, so a slow round trip would | |
| # explain the failures. Compares distributions, not flake events: a run yields | |
| # few flakes but records a duration every iteration. It describes; overlapping | |
| # ranges are a real answer, not a failed one. | |
| if [ -s probe-iterations.tsv ]; then | |
| { | |
| echo "### Firestore emulator health check, by outcome" | |
| echo "" | |
| node -e ' | |
| const fs = require("fs"); | |
| const rows = fs.readFileSync("probe-iterations.tsv", "utf8").trim().split("\n").filter(Boolean) | |
| .map((l) => l.split("\t")) | |
| .map(([node, arm, i, outcome, fsMs, authMs, dbMs, grpc]) => ({ arm, outcome, fsMs, authMs, dbMs })); | |
| // Even n takes the mean of the two middle values. Taking the upper made | |
| // the median and max cells print the same number at n = 2, and the flake | |
| // row is where n is smallest. | |
| const median = (a) => { | |
| const m = a.length >> 1; | |
| return a.length % 2 ? a[m] : Math.round((a[m - 1] + a[m]) / 2); | |
| }; | |
| // Each cell carries its own n: the row count includes iterations that | |
| // recorded no duration, so a row of 5 can rest on 2 measurements. | |
| const series = (rs, key) => { | |
| const a = rs.map((r) => Number(r[key])).filter((n) => Number.isFinite(n)).sort((x, y) => x - y); | |
| return a.length ? `${median(a)} / ${a[a.length - 1]} (n=${a.length})` : "-"; | |
| }; | |
| // One table per arm. Pooling them would put baseline and override into one | |
| // distribution while the counts tables above stay per-arm. | |
| const arms = [...new Set(rows.map((r) => r.arm))]; | |
| for (const arm of arms) { | |
| const armRows = rows.filter((r) => r.arm === arm); | |
| if (arms.length > 1) { console.log(`Arm: ${arm}`); console.log(""); } | |
| console.log("Median / max, in ms, with the count of iterations that recorded one."); | |
| console.log(""); | |
| console.log("| Outcome | runs | firestore | auth | database |"); | |
| console.log("| --- | --- | --- | --- | --- |"); | |
| for (const name of ["pass", "flake", "hang", "infra"]) { | |
| const rs = armRows.filter((r) => r.outcome === name); | |
| if (!rs.length) continue; | |
| console.log(`| ${name} | ${rs.length} | ${series(rs, "fsMs")} | ${series(rs, "authMs")} | ${series(rs, "dbMs")} |`); | |
| } | |
| console.log(""); | |
| const missing = armRows.filter((r) => !Number.isFinite(Number(r.fsMs))) | |
| .reduce((m, r) => m.set(r.outcome, (m.get(r.outcome) || 0) + 1), new Map()); | |
| if (missing.size) { | |
| const parts = [...missing].map(([outcome, n]) => `${n} ${outcome}`).join(", "); | |
| console.log(`> No firestore duration recorded for ${parts}, usually because the emulator never came up.`); | |
| console.log(""); | |
| } | |
| } | |
| console.log("> Read the columns against each other. Firestore slow while auth and database"); | |
| console.log("> stay flat points at the Firestore client or its stream; all three rising"); | |
| console.log("> together points at runner-wide contention instead, which is a different bug."); | |
| ' || echo "(could not summarize durations)" | |
| echo "" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| if [ -s probe-unmatched.txt ]; then | |
| { | |
| echo "### ⚠️ Unrecognized failures" | |
| echo "" | |
| echo "These runs failed with an assertion the classifier does not know, so they" | |
| echo "were counted as infra. If vitest reworded the #776 message, the flake counts" | |
| echo "above are wrong and the pattern needs updating." | |
| echo "" | |
| echo '```' | |
| cat probe-unmatched.txt | |
| echo '```' | |
| echo "" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| # The probe reports; it does not fail. A red job here would mean the probe | |
| # broke, not that the flake reproduced. | |
| total_counted="$(awk -F'\t' '{ s += $4 + $5 } END { print s + 0 }' probe-counts.tsv)" | |
| if [ "$total_counted" -eq 0 ]; then | |
| echo "Every run failed for infrastructure reasons; the probe measured nothing." | |
| exit 1 | |
| fi | |
| - name: Upload probe logs | |
| if: ${{ always() }} | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: probe-logs-node${{ matrix.node }} | |
| path: | | |
| probe-logs/ | |
| probe-counts.tsv | |
| probe-unmatched.txt | |
| probe-iterations.tsv | |
| retention-days: 7 |