From 1e3f19332893a3461433d4582fd7550aacb2467d Mon Sep 17 00:00:00 2001 From: ryanbr Date: Tue, 28 Jul 2026 23:16:30 -0700 Subject: [PATCH 1/4] Run the tool tests that nothing was running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places in this repo had tests, or code the app's conclusions depend on, that no CI job ever executed. tools/linux-capture carries 212 tests across 8 files — the frame decoder, the HCI extractor, the activity and sync decoders, spot HRV, and the SpO2 promote gate #103 is waiting on. They ran only when someone remembered to. All 212 pass today, so this starts green; the point is that a regression would previously have gone unnoticed by a suite that exists to catch it. Tools/SleepBench is the instrument the sleep work is argued with — #934's stage-fraction tables came out of it — and #935 added 13 tests nothing ran. It path-depends on Packages/StrandAnalytics and Packages/WhoopProtocol, so it also bitrots the first time either changes shape. #935 is the case in point: that tool was scoring V2 against V2's own output for a week and no check could have told anyone. Tools/Backfill has no tests yet, so it is build-only, guarded by a grep for a testTarget rather than assuming one. The Swift side joins swift-packages.yml, whose paths now include Tools/** so a package change can fail its dependents. The Python side is its own ubuntu job: stdlib unittest, no dependencies, and `discover` rather than a hand-listed set so a new test file cannot be silently left unrun — which is the exact failure this closes. app-build.yml stays disabled; app-target Swift is a separate problem. --- .github/workflows/swift-packages.yml | 34 +++++++++++++++++++++ .github/workflows/tools-python.yml | 45 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github/workflows/tools-python.yml diff --git a/.github/workflows/swift-packages.yml b/.github/workflows/swift-packages.yml index 4dee442866..050dbaa8d4 100644 --- a/.github/workflows/swift-packages.yml +++ b/.github/workflows/swift-packages.yml @@ -12,11 +12,13 @@ on: branches: [main] paths: - 'Packages/**' + - 'Tools/**' - '.github/workflows/swift-packages.yml' push: branches: [main] paths: - 'Packages/**' + - 'Tools/**' - '.github/workflows/swift-packages.yml' workflow_dispatch: @@ -50,3 +52,35 @@ jobs: - name: Test ${{ matrix.package }} run: swift test working-directory: Packages/${{ matrix.package }} + + # The Tools/ SwiftPM packages, which path-depend on Packages/ above and were built by nothing. + # + # SleepBench is the instrument the sleep work is argued with — #934's stage-fraction tables came out of + # it — and #935 added 13 tests that no job ran. A tool whose numbers decide whether a staging change + # lands has to keep compiling against the packages it measures, or it bitrots silently and the next + # person to reach for it finds it broken. Backfill has no tests yet, so it is build-only. + tools: + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + tool: + - SleepBench + - Backfill + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build ${{ matrix.tool }} + run: swift build + working-directory: Tools/${{ matrix.tool }} + + # Only SleepBench has a test target; `swift test` on a package without one fails, so it is guarded. + - name: Test ${{ matrix.tool }} + run: | + if grep -q 'testTarget' Package.swift; then + swift test + else + echo "no test target in Tools/${{ matrix.tool }} — build-only" + fi + working-directory: Tools/${{ matrix.tool }} diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml new file mode 100644 index 0000000000..09eeca9976 --- /dev/null +++ b/.github/workflows/tools-python.yml @@ -0,0 +1,45 @@ +# The Python test suites under tools/, which nothing ran. +# +# `tools/linux-capture` carries 212 tests across 8 files — the frame decoder, the HCI extractor, the +# activity and sync decoders, the spot-HRV path, and the SpO2 promote gate that #103 is waiting on. Every +# one of them only ever executed when a contributor remembered to, which meant a decoder could regress and +# the suite that would have caught it stayed green by never being run. +# +# Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS. +# Path-filtered so it only fires when tools/ changes. +name: Tools Python CI + +on: + pull_request: + branches: [main] + paths: + - 'tools/**' + - '.github/workflows/tools-python.yml' + push: + branches: [main] + paths: + - 'tools/**' + - '.github/workflows/tools-python.yml' + workflow_dispatch: + +concurrency: + group: tools-python-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-capture: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # `discover` rather than a hand-listed set, so a NEW test file is picked up without editing this + # workflow — the failure mode that leaves a suite unrun is exactly what this job exists to end. + - name: Run tools/linux-capture tests + run: python3 -m unittest discover -p "test_*.py" -v + working-directory: tools/linux-capture From a9a8f22412a20bdca53c0989fc792363ba357c5d Mon Sep 17 00:00:00 2001 From: ryanbr Date: Tue, 28 Jul 2026 23:27:34 -0700 Subject: [PATCH 2/4] Fix the Backfill build the new job just caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool did not compile: line 57's stage dictionary mixes Double? sources with untyped 0 fallbacks, so Swift could only infer [String: Any] and the emit-module step failed. Nothing built Tools/Backfill, so nobody found out. Annotated [String: Double] — all four sources are Double? on WearableSleepSession, and that is the minute-dict shape SleepStageTotals already decodes. No behaviour change is possible here: the tool could not run at all before this. This is the job earning its place on its first run. --- Tools/Backfill/Sources/backfill/main.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Tools/Backfill/Sources/backfill/main.swift b/Tools/Backfill/Sources/backfill/main.swift index 1da6c86991..48e0821325 100644 --- a/Tools/Backfill/Sources/backfill/main.swift +++ b/Tools/Backfill/Sources/backfill/main.swift @@ -54,8 +54,13 @@ if let whoopExport, FileManager.default.fileExists(atPath: whoopExport.path) { var sessions: [CachedSleepSession] = [] for sl in r.sleeps where !sl.isNap { guard let onset = sl.sleepOnset, let wake = sl.wakeOnset else { continue } - let stages = ["light": sl.lightSleepDurationMin ?? 0, "deep": sl.deepSleepDurationMin ?? 0, - "rem": sl.remDurationMin ?? 0, "awake": sl.awakeDurationMin ?? 0] + // Annotated because the four sources are `Double?` and the `?? 0` fallbacks otherwise infer a + // heterogeneous literal, which stopped this tool compiling at all. `[String: Double]` matches the + // minute-dict shape `SleepStageTotals` already decodes. + let stages: [String: Double] = [ + "light": sl.lightSleepDurationMin ?? 0, "deep": sl.deepSleepDurationMin ?? 0, + "rem": sl.remDurationMin ?? 0, "awake": sl.awakeDurationMin ?? 0, + ] let json = (try? JSONSerialization.data(withJSONObject: stages)).flatMap { String(data: $0, encoding: .utf8) } sessions.append(CachedSleepSession(startTs: Int(onset.timeIntervalSince1970), endTs: Int(wake.timeIntervalSince1970), efficiency: sl.sleepEfficiencyPct, restingHr: nil, avgHrv: nil, stagesJSON: json)) From fe08c1f3a58e0f9756538edf866fe138c387b068 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Tue, 28 Jul 2026 23:40:34 -0700 Subject: [PATCH 3/4] Fix three things the re-review found in this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pipefail. GitHub's default shell is `bash -e` WITHOUT pipefail, so piping the test run into tee returned tee's status and a failing suite would have exited 0. The count assertion I added would therefore have MASKED every real failure — strictly worse than not adding it. Proven both ways locally: without pipefail a red suite reports exit 0; with it, exit 1. Tools/** was too wide. It matched appchangelog-gen.py, doc_comment_lint.py, the anonymize shell scripts and __pycache__, so editing a Python lint script would have queued ten macos-15 jobs. Narrowed to Tools/SleepBench/** and Tools/Backfill/**, which is what the Swift job actually builds. Tools/test_i18n_audit.py was still unrun — 36 tests covering the audit that gates every PR's translations. i18n-coverage.yml runs `i18n_audit.py --ci` but never its test file. Now covered, from inside Tools/ since the suite imports the module by bare name. Both steps now assert a floor on the collected count, because `discover` exits 0 having found NOTHING: a wrong working-directory would otherwise turn this job green while running no tests, which is the exact failure the PR exists to end. --- .github/workflows/swift-packages.yml | 6 +++-- .github/workflows/tools-python.yml | 33 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift-packages.yml b/.github/workflows/swift-packages.yml index 050dbaa8d4..43b47c0f06 100644 --- a/.github/workflows/swift-packages.yml +++ b/.github/workflows/swift-packages.yml @@ -12,13 +12,15 @@ on: branches: [main] paths: - 'Packages/**' - - 'Tools/**' + - 'Tools/SleepBench/**' + - 'Tools/Backfill/**' - '.github/workflows/swift-packages.yml' push: branches: [main] paths: - 'Packages/**' - - 'Tools/**' + - 'Tools/SleepBench/**' + - 'Tools/Backfill/**' - '.github/workflows/swift-packages.yml' workflow_dispatch: diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml index 09eeca9976..13e58a6871 100644 --- a/.github/workflows/tools-python.yml +++ b/.github/workflows/tools-python.yml @@ -14,11 +14,13 @@ on: branches: [main] paths: - 'tools/**' + - 'Tools/*.py' - '.github/workflows/tools-python.yml' push: branches: [main] paths: - 'tools/**' + - 'Tools/*.py' - '.github/workflows/tools-python.yml' workflow_dispatch: @@ -40,6 +42,35 @@ jobs: # `discover` rather than a hand-listed set, so a NEW test file is picked up without editing this # workflow — the failure mode that leaves a suite unrun is exactly what this job exists to end. + # + # The count assertion is the same idea one level up: `discover` exits 0 having found NOTHING, so a + # wrong working-directory or a renamed pattern would turn this job green while running no tests at + # all. Checking a floor makes that fail loudly instead. Raise the floor when suites are added; do + # not lower it to make a red run pass. - name: Run tools/linux-capture tests - run: python3 -m unittest discover -p "test_*.py" -v + run: | + set -o pipefail # GitHub's default shell is `bash -e` WITHOUT pipefail: piping into tee + # would return tee's status and swallow a failing suite entirely. + python3 -m unittest discover -p "test_*.py" -v 2>&1 | tee out.txt + ran=$(grep -oE '^Ran [0-9]+ test' out.txt | grep -oE '[0-9]+') + echo "collected ${ran:-0} tests" + if [ "${ran:-0}" -lt 200 ]; then + echo "::error::expected at least 200 tests, collected ${ran:-0} — discovery is broken, not the suite" + exit 1 + fi working-directory: tools/linux-capture + + # Tools/test_i18n_audit.py covers the audit that gates every PR's translations, and nothing ran it + # either: i18n-coverage.yml invokes `i18n_audit.py --ci` but never its own test file. Run from + # inside Tools/ because the suite imports the module by bare name. + - name: Run Tools/ audit tests + run: | + set -o pipefail # see above — without this a red suite exits 0 through the pipe. + python3 -m unittest test_i18n_audit -v 2>&1 | tee out.txt + ran=$(grep -oE '^Ran [0-9]+ test' out.txt | grep -oE '[0-9]+') + echo "collected ${ran:-0} tests" + if [ "${ran:-0}" -lt 30 ]; then + echo "::error::expected at least 30 tests, collected ${ran:-0} — discovery is broken, not the suite" + exit 1 + fi + working-directory: Tools From eebf28d72ed2f69fa1376c3c162a4d1bde882006 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Tue, 28 Jul 2026 23:53:15 -0700 Subject: [PATCH 4/4] Write the scratch capture outside the checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tee wrote out.txt into tools/linux-capture and Tools, neither of which is gitignored — so rehearsing these commands locally leaves a stray file that can be committed by accident. $RUNNER_TEMP keeps the streamed log and leaves the working tree clean. --- .github/workflows/tools-python.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml index 13e58a6871..9b9d39cfb9 100644 --- a/.github/workflows/tools-python.yml +++ b/.github/workflows/tools-python.yml @@ -51,8 +51,8 @@ jobs: run: | set -o pipefail # GitHub's default shell is `bash -e` WITHOUT pipefail: piping into tee # would return tee's status and swallow a failing suite entirely. - python3 -m unittest discover -p "test_*.py" -v 2>&1 | tee out.txt - ran=$(grep -oE '^Ran [0-9]+ test' out.txt | grep -oE '[0-9]+') + python3 -m unittest discover -p "test_*.py" -v 2>&1 | tee "$RUNNER_TEMP/out.txt" + ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') echo "collected ${ran:-0} tests" if [ "${ran:-0}" -lt 200 ]; then echo "::error::expected at least 200 tests, collected ${ran:-0} — discovery is broken, not the suite" @@ -66,8 +66,8 @@ jobs: - name: Run Tools/ audit tests run: | set -o pipefail # see above — without this a red suite exits 0 through the pipe. - python3 -m unittest test_i18n_audit -v 2>&1 | tee out.txt - ran=$(grep -oE '^Ran [0-9]+ test' out.txt | grep -oE '[0-9]+') + python3 -m unittest test_i18n_audit -v 2>&1 | tee "$RUNNER_TEMP/out.txt" + ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') echo "collected ${ran:-0} tests" if [ "${ran:-0}" -lt 30 ]; then echo "::error::expected at least 30 tests, collected ${ran:-0} — discovery is broken, not the suite"