From f7cf721f290d46ba8805ad13303c04631f3672bb Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 09:34:47 +0100 Subject: [PATCH 1/2] CI: split the test suite across six parallel jobs The suite ran as one job for about 75 minutes, and a newer push to main cancels the run in progress, so runs on main were cancelled about as often as they finished. Each slice still runs its files one at a time. A final job keeps the check name `test`. --- .github/scripts/test_shard.sh | 34 +++++++++++++++++++ .github/workflows/test.yml | 61 ++++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 11 deletions(-) create mode 100755 .github/scripts/test_shard.sh diff --git a/.github/scripts/test_shard.sh b/.github/scripts/test_shard.sh new file mode 100755 index 000000000..c80ea6509 --- /dev/null +++ b/.github/scripts/test_shard.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run one slice of the test suite: test_shard.sh . +# +# Every *_test.dart under test/ belongs to exactly one slice (its position in +# the sorted list, modulo ), so the slices together are the whole suite. +# Inside a slice the files still run one at a time (--concurrency=1): the suite +# uses sqflite_common_ffi against real database files and parallel workers on +# ONE machine race on them. Separate CI jobs are separate machines. +# +# ponytail: slices are balanced by file COUNT, not by duration. If one slice +# becomes the slow one, split by recorded timings instead. +set -euo pipefail + +index="$1" +total="$2" + +files=() +i=0 +while IFS= read -r f; do + if [ $((i % total)) -eq "$index" ]; then files+=("$f"); fi + i=$((i + 1)) +done < <(find test -name '*_test.dart' | LC_ALL=C sort) + +if [ "${#files[@]}" -eq 0 ]; then + echo "slice $index of $total holds no test files" >&2 + exit 1 +fi + +echo "slice $index of $total: ${#files[@]} of $i test files" +if [ "${TEST_SHARD_LIST_ONLY:-}" = 1 ]; then + printf '%s\n' "${files[@]}" + exit 0 +fi +flutter test --concurrency=1 --reporter=expanded "${files[@]}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64385d7c9..e48ae940e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,9 @@ permissions: contents: read jobs: - test: + # Analyze, the lock guard and the zone-dependent tests: everything that is + # not the main suite. + checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,17 +70,8 @@ jobs: - name: Analyze run: flutter analyze - # --concurrency=1: the suite uses sqflite_common_ffi against real database - # files, and parallel workers race on them. - # - # The golden capture (whoop_hist.jsonl) is a real band recording kept - # beside the repo rather than committed to it, so the two derivation-replay - # tests SKIP here and run locally. - - name: Test - run: flutter test --concurrency=1 --reporter=expanded - # The WHOOP freeze goldens are per timezone (day labels are local), and - # the run above is UTC, so it only enforces the *_utc files and the other + # the main suite runs in UTC, so it only enforces the *_utc files and the other # two skip. Re-run that one file under each committed offset. The Mi # scale clock test reads the process zone and is a no-op under UTC, so it # rides along. @@ -86,3 +79,49 @@ jobs: run: | TZ=Asia/Kolkata flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart TZ=America/New_York flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart + + # The main suite, split across machines. It ran as ONE job for about 75 + # minutes, and a newer push to main cancels the run in progress + # (`cancel-in-progress` above), so runs on main were cancelled about as often + # as they finished. Each slice still runs its files one at a time; see + # .github/scripts/test_shard.sh. + # + # The golden capture (whoop_hist.jsonl) is a real band recording kept beside + # the repo rather than committed to it, so the two derivation-replay tests + # SKIP here and run locally. + suite: + runs-on: ubuntu-latest + strategy: + # One failing slice must not hide failures in the others. + fail-fast: false + matrix: + shard: [0, 1, 2, 3, 4, 5] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Same pin as `checks`; see the note there. + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.41.6 + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Test (slice ${{ matrix.shard }} of 6) + run: bash .github/scripts/test_shard.sh ${{ matrix.shard }} 6 + + # One check with the old job's name, so anything that waits for `test` + # (branch protection, a badge) keeps working. Fails unless every job above + # passed. + test: + if: always() + needs: [checks, suite] + runs-on: ubuntu-latest + steps: + - name: All checks and slices passed + run: | + [ "${{ needs.checks.result }}" = success ] && [ "${{ needs.suite.result }}" = success ] From 42c00a44201dd3bfda1373bc4a11cf6449a4b8cb Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 10:27:01 +0100 Subject: [PATCH 2/2] CI: fail the slice when listing the test files fails --- .github/scripts/test_shard.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test_shard.sh b/.github/scripts/test_shard.sh index c80ea6509..c059988a1 100755 --- a/.github/scripts/test_shard.sh +++ b/.github/scripts/test_shard.sh @@ -14,12 +14,17 @@ set -euo pipefail index="$1" total="$2" +# Listed into a variable first, so a failed `find` or `sort` fails the slice +# (set -e, pipefail). Feeding the loop straight from the pipeline would let a +# half-read test tree pass with some of its files never run. +all="$(find test -name '*_test.dart' | LC_ALL=C sort)" + files=() i=0 while IFS= read -r f; do if [ $((i % total)) -eq "$index" ]; then files+=("$f"); fi i=$((i + 1)) -done < <(find test -name '*_test.dart' | LC_ALL=C sort) +done <<< "$all" if [ "${#files[@]}" -eq 0 ]; then echo "slice $index of $total holds no test files" >&2