tts support #728
Workflow file for this run
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
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| clear_cache: | |
| # Re-runs of a failed job already force a cold build automatically | |
| # (see "Wipe restored caches" step). This input is for the rare case | |
| # where you want to start a brand-new run with a cold build — e.g. | |
| # after a CACHE_SALT bump on `main` to verify the cold path before | |
| # PR runs hit it. | |
| description: "Force a cold build on the FIRST attempt (re-runs are already cold)" | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| # Bump to invalidate every cache entry without source surgery (e.g., after a | |
| # known-bad cache or an Xcode toolchain upgrade we want to flush manually). | |
| CACHE_SALT: v2-vmlx-5b84387 | |
| # Pin Xcode so cache keys are stable across runner image bumps. When you | |
| # need to upgrade, change here AND in setup-xcode below. | |
| XCODE_VERSION: "26.4.1" | |
| jobs: | |
| test-core: | |
| # Pinned (was `macos-latest`) so a runner image bump can't quietly | |
| # change the build environment under us. | |
| runs-on: macos-26 | |
| # 45 min, not 30. Three-bucket budget: | |
| # ~25–30 min — full cold build (mlx-swift `Cmlx` C++ + SQLCipher | |
| # `sqlite3.c` ~250k LoC of C compiled with | |
| # `-DSQLITE_HAS_CODEC=1` and friends + OsaurusCore + | |
| # OsaurusCoreTests Swift) when both SPM and DerivedData | |
| # caches miss. PR #951 (run 24937664669, attempt 2) | |
| # hit the prior 30-min wall mid-Swift-compile after | |
| # 27:27 in the xcodebuild step — that's the empirical | |
| # floor on `macos-26` with the SQLCipher amalgamation. | |
| # ~ 2– 3 min — actual `xcodebuild test` once the build is warm. | |
| # ~10–15 min — buffer / future growth, runner variance. | |
| # | |
| # Once any successful run lands on `main`, the `Save DerivedData cache` | |
| # step at the bottom populates the cache and subsequent runs return to | |
| # ~5 min total. The 45-min ceiling is an "even a worst-case cold build | |
| # finishes" guard, NOT an expected duration. If you find yourself | |
| # raising it again, the right fix is to split this into a separate | |
| # build-cache-warm job that runs nightly on `main`, not to bump the | |
| # ceiling indefinitely. | |
| timeout-minutes: 45 | |
| env: | |
| WORKSPACE: osaurus.xcworkspace | |
| SPM_CACHE: .spm-cache | |
| XCRESULT_PATH: build/Tests.xcresult | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Set up Xcode ${{ env.XCODE_VERSION }} | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: ${{ env.XCODE_VERSION }} | |
| - name: Install xcbeautify | |
| run: brew install xcbeautify | |
| - name: Cache SPM packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ${{ env.SPM_CACHE }} | |
| key: spm-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('osaurus.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} | |
| restore-keys: | | |
| spm-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}- | |
| spm-${{ runner.os }}-${{ env.CACHE_SALT }}- | |
| - name: Restore DerivedData cache | |
| id: dd-cache | |
| # Always restore so `cache-primary-key` is populated for the save | |
| # step at the bottom (the wipe step below handles forced cold | |
| # builds without preventing main from repopulating the cache). | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: ~/Library/Developer/Xcode/DerivedData | |
| # Include vendored C sources (currently the SQLCipher amalgamation | |
| # under Packages/OsaurusCore/SQLCipher/). Without this, an | |
| # SQLCipher bump would land its new sqlite3.{c,h} but CI would | |
| # silently re-use a stale cached compile of the old code. | |
| key: dd-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('osaurus.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'Packages/**/*.swift', 'Packages/**/Package.swift', 'Packages/**/Resources/**', 'Packages/**/*.c', 'Packages/**/*.h') }} | |
| restore-keys: | | |
| dd-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}- | |
| # Make "clear the build cache" a one-click operation. Two triggers: | |
| # 1. `github.run_attempt != '1'` — i.e. a re-run. The default | |
| # "Re-run failed jobs" button is the natural place for someone | |
| # who just saw a build failure to land, so we make that the | |
| # intuitive escape hatch for cache poison: the first attempt | |
| # uses the cache (fast); any re-run forces a cold compile. | |
| # 2. `workflow_dispatch.clear_cache=true` — manual force-cold on | |
| # a fresh run (e.g. validating a CACHE_SALT bump before PRs | |
| # start hitting it). | |
| # | |
| # We wipe ONLY DerivedData, not the SPM cache. DerivedData holds | |
| # compiled object files / .swiftmodule / linked binaries — the | |
| # actual build outputs that can carry over a stale-source bug across | |
| # incremental builds. The SPM cache is just downloaded source code | |
| # pinned by `Package.resolved` checksums; it can't be "poisoned" in | |
| # any way that affects build correctness, and re-downloading it on | |
| # every re-run cost ~2 min in PR #951 run 24937664669 — wasted | |
| # budget that contributed to the 30-min cold-build cancellation. | |
| # | |
| # We wipe AFTER the restore step (rather than skipping the restore) | |
| # so `steps.dd-cache.outputs.cache-primary-key` stays populated and | |
| # the `Save DerivedData cache` step at the bottom can still | |
| # repopulate the cache on a successful `main` run. | |
| - name: Wipe restored DerivedData (re-run or workflow_dispatch clear_cache) | |
| if: ${{ github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }} | |
| run: | | |
| REASON="run_attempt=${{ github.run_attempt }}" | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.clear_cache }}" = "true" ]; then | |
| REASON="$REASON, workflow_dispatch clear_cache=true" | |
| fi | |
| echo "::notice title=Cold build forced::Wiping restored DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved). To re-run with the warm cache instead, push a new commit or trigger a fresh run." | |
| rm -rf "$HOME/Library/Developer/Xcode/DerivedData" | |
| - name: Resolve dependencies | |
| run: >- | |
| xcodebuild -resolvePackageDependencies | |
| -workspace "$WORKSPACE" | |
| -scheme OsaurusCoreTests | |
| -clonedSourcePackagesDirPath "$SPM_CACHE" | |
| -quiet | |
| - name: Test OsaurusCore | |
| id: test | |
| run: | | |
| set -o pipefail | |
| mkdir -p build | |
| # We deliberately pass a dummy resultBundlePath check first because | |
| # xcodebuild refuses to overwrite an existing bundle. | |
| rm -rf "$XCRESULT_PATH" | |
| # Per-test-case allowance (60s / 120s hard cap) surfaces a hung | |
| # test BEFORE the job wall-timeout — and crucially, surfaces it | |
| # WITH A NAME ATTACHED in the xcresult bundle, which the failure | |
| # summary step below relies on. | |
| # | |
| # Why no `--quiet` on xcbeautify and no `-test-iterations`: | |
| # both were band-aids that landed during PR #878's launch-hang | |
| # incident. `--quiet` strips per-test "Started/Passed" lines so a | |
| # hung test produces no output telling us WHICH test hung. | |
| # `-test-iterations 2 -retry-tests-on-failure` doubles the | |
| # wall-clock cost of every hang for zero diagnostic gain. Both | |
| # are off by default — re-add only if you have a per-PR reason | |
| # and an exit plan. | |
| xcodebuild test \ | |
| -workspace "$WORKSPACE" \ | |
| -scheme OsaurusCoreTests \ | |
| -disableAutomaticPackageResolution \ | |
| -clonedSourcePackagesDirPath "$SPM_CACHE" \ | |
| -resultBundlePath "$XCRESULT_PATH" \ | |
| -skipPackagePluginValidation \ | |
| -skipMacroValidation \ | |
| -enableCodeCoverage NO \ | |
| -test-timeouts-enabled YES \ | |
| -default-test-execution-time-allowance 60 \ | |
| -maximum-test-execution-time-allowance 120 \ | |
| COMPILER_INDEX_STORE_ENABLE=NO \ | |
| SWIFT_COMPILATION_MODE=incremental \ | |
| | xcbeautify --renderer github-actions --is-ci | |
| - name: Annotate timing | |
| if: always() | |
| run: echo "::notice title=test-core duration::$SECONDS seconds" | |
| - name: Print failure summary | |
| # Also run on `cancelled()` so a job-timeout cancellation (e.g. a | |
| # cold build that ate the 45-min wall) still gets a Mode A diag | |
| # block in the GitHub UI instead of being a silent skip — see | |
| # PR #951 run 24937664669, where attempt 2's 27:27 cold compile | |
| # was killed by the prior 30-min timeout AND `Print failure | |
| # summary` was skipped because cancellation isn't `failure()`. | |
| if: ${{ failure() || cancelled() }} | |
| env: | |
| # Surface the cache outcome inside the summary so the next person | |
| # can immediately tell "cold-cache compile timeout" from "warm | |
| # cache + actual hang" without scrolling the log. | |
| DD_CACHE_HIT: ${{ steps.dd-cache.outputs.cache-hit }} | |
| run: | | |
| set +e | |
| echo "## test-core failures" >> "$GITHUB_STEP_SUMMARY" | |
| # Four real failure modes we want to disambiguate. Each conflation | |
| # has historically cost us hours of misdirected debugging, so this | |
| # step exists specifically to put the right diagnosis at the top | |
| # of the GitHub job summary instead of forcing the next person to | |
| # re-derive it from raw logs. | |
| # | |
| # Mode A — xctest binary doesn't exist on disk | |
| # ⇒ build phase didn't finish (compile error, OR cold | |
| # cache + 30-min wall-timeout fired mid-Swift-compile, | |
| # OR linker failure). PR #881 (run 24573707695) hit this | |
| # flavor: both SPM and DerivedData caches missed because | |
| # main hadn't ever saved one, so the cold build ran out | |
| # of clock during OsaurusCoreTests Swift compilation. | |
| # | |
| # Mode B — xctest binary built BUT no xcresult bundle | |
| # ⇒ test bundle launched but produced zero output before | |
| # the wall-timeout / cancellation. Real launch-time hang | |
| # (dyld load, +load methods, or first-test module init). | |
| # PR #878 (run 24563175247) hit this flavor — see the | |
| # comment block atop `_registerFactoriesOnce` in | |
| # ModelRuntime.swift. | |
| # | |
| # Mode C — xcresult exists with zero test cases | |
| # ⇒ test execution was killed mid-flight. Look for the | |
| # last `Test Case ... started` line in the raw log; | |
| # that's the prime suspect for the hang. (`--quiet` is | |
| # intentionally NOT passed to xcbeautify so the line is | |
| # present.) | |
| # | |
| # Mode D — xcresult exists with failed test cases | |
| # ⇒ ordinary test failures; render the failures table. | |
| # Resolve the xctest binary path. Xcode hashes the DerivedData | |
| # subdirectory off the workspace path, so glob rather than | |
| # hardcode. We only care about existence, not the actual binary. | |
| XCTEST_BINARY="$(find "$HOME/Library/Developer/Xcode/DerivedData" \ | |
| -maxdepth 6 -name 'OsaurusCoreTests.xctest' -type d 2>/dev/null \ | |
| | head -1)" | |
| if [ ! -d "$XCRESULT_PATH" ]; then | |
| if [ -z "$XCTEST_BINARY" ]; then | |
| # Mode A. | |
| CACHE_NOTE="_(DerivedData cache hit: \`${DD_CACHE_HIT:-unknown}\`, run attempt: \`${{ github.run_attempt }}\`)_" | |
| { | |
| echo "**Mode A — build phase did not complete (no xctest bundle on disk).**" | |
| echo | |
| echo "Either a compile/link error fired (scroll the **Test OsaurusCore** log above for the first \`error:\` line), OR the cold build ran past the 45-min job timeout. ${CACHE_NOTE}" | |
| echo | |
| echo "If \`cache-hit: false\` AND no \`error:\` lines appear in the raw log, this is the cold-cache-timeout flavor. The fix is to land one successful run on \`main\` so the \`Save DerivedData cache\` step at the bottom of this job populates the cache; subsequent PR runs warm-start from it and finish in ~5 min. Re-running this same job will hit the cache the second time only IF the first attempt finishes successfully." | |
| echo | |
| echo "**\`run_attempt > 1\` AND \`cache-hit: false\`?** That's the deliberate cold-rebuild path triggered by **Re-run failed jobs** — see the \`Wipe restored DerivedData\` step in this job. If the cold build is exhausting the 45-min budget on every re-run, the codebase has outgrown the budget; bump \`timeout-minutes\` and update its comment block, OR move warm-cache priming to a nightly \`main\` job so PRs always warm-start." | |
| echo | |
| echo "**Suspect cache poisoning on a fresh attempt?** Click **Re-run failed jobs** — re-runs automatically wipe DerivedData (the SPM cache is preserved because it's pinned by \`Package.resolved\` and can't be poisoned)." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| # Mode B. | |
| { | |
| echo "**Mode B — xctest bundle built but launched silently (real launch-time hang).**" | |
| echo | |
| echo "The \`OsaurusCoreTests.xctest\` binary exists in DerivedData (\`$XCTEST_BINARY\`), so the build phase finished. The hang is in dyld load, an ObjC \`+load\` method, a Swift module initializer, or the first test's module init." | |
| echo | |
| echo "Prime suspects: any file-level \`let\` in OsaurusCore that touches MLX/Metal at first reference (this is the regression class that PR #878 surfaced — see \`_registerFactoriesOnce\` in \`Packages/OsaurusCore/Services/ModelRuntime.swift\` and the matching tests in \`Packages/OsaurusCore/Tests/Service/ModelRuntimeFindDirectoryTests.swift\`)." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| exit 0 | |
| fi | |
| # `xcresulttool get test-results tests` is the modern (Xcode 16+) | |
| # JSON view of the test outcomes. Older flag is now deprecated. | |
| XCRESULT_JSON="$(xcrun xcresulttool get test-results tests \ | |
| --path "$XCRESULT_PATH" \ | |
| --format json 2>/dev/null)" | |
| # Count test cases regardless of outcome so we can detect the | |
| # "started, killed mid-flight" case where there are no Failure | |
| # nodes but also no Passed nodes. | |
| TEST_CASE_COUNT="$(printf '%s' "$XCRESULT_JSON" \ | |
| | jq '[ .. | objects | select(.nodeType? == "Test Case" or .nodeType? == "Test") ] | length' \ | |
| 2>/dev/null || echo 0)" | |
| if [ "${TEST_CASE_COUNT:-0}" = "0" ]; then | |
| echo "**Mode C — xcresult bundle exists but contains zero test cases.**" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Test execution was killed before any test recorded a result — most likely a hung test that ate the job wall-timeout, or a runner crash. Check the raw log for the last \`Test Case ... started\` line; that's the prime suspect for the hang." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| echo "**Mode D — failed test cases:**" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| printf '%s' "$XCRESULT_JSON" | jq -r ' | |
| [ .. | objects | select(.result? == "Failed" and (.nodeType? == "Test Case" or .nodeType? == "Test")) ] | |
| | if length == 0 then | |
| "_No individual failed tests reported despite a non-zero exit. Likely a build error, post-test cleanup failure, or runner crash — see the raw log above._" | |
| else | |
| ( "| Test | Failure |\n| --- | --- |" ) | |
| + ( map( | |
| "| `" + (.name // "(unknown)") + "` | " | |
| + ( ( [ .. | objects | select(.nodeType? == "Failure Message") | .name ] | join("<br>") ) | |
| // "_(no message)_" ) | |
| + " |" | |
| ) | join("\n") ) | |
| end | |
| ' >> "$GITHUB_STEP_SUMMARY" || true | |
| - name: Upload xcresult on failure | |
| # Same `failure() || cancelled()` rationale as the failure-summary | |
| # step above: on a wall-timeout the xcresult bundle may be | |
| # partially populated and is still useful for postmortem. | |
| if: ${{ failure() || cancelled() }} | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: test-core-xcresult-${{ github.run_attempt }} | |
| path: ${{ env.XCRESULT_PATH }} | |
| retention-days: 7 | |
| if-no-files-found: warn | |
| # Save the cache only on `main` so a half-baked PR can never poison it. | |
| # `actions/cache/save@v5` is a no-op when the key already exists, so a | |
| # forced cold re-run on `main` (which wipes DerivedData and rebuilds | |
| # from scratch) won't overwrite a known-good cache entry under the | |
| # same key. To intentionally invalidate every cache, bump CACHE_SALT. | |
| - name: Save DerivedData cache | |
| if: ${{ github.ref == 'refs/heads/main' && success() && steps.dd-cache.outputs.cache-primary-key != '' }} | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: ~/Library/Developer/Xcode/DerivedData | |
| key: ${{ steps.dd-cache.outputs.cache-primary-key }} | |
| test-cli: | |
| # Pinned (was `macos-latest`). | |
| runs-on: macos-26 | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| # OsaurusCLI's Package.swift requires `swift-tools-version: 6.2`, which | |
| # ships with the pinned Xcode. The runner's default `swift` may be older | |
| # (currently 6.1), so point xcrun at the pinned Xcode for these steps. | |
| # GitHub Actions doesn't let job-level `env:` reference workflow-level | |
| # `env:`, so keep XCODE_VERSION in sync here if it ever changes. | |
| - name: Verify Swift toolchain | |
| env: | |
| DEVELOPER_DIR: /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer | |
| run: swift --version | |
| - name: Run CLI tests | |
| env: | |
| DEVELOPER_DIR: /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer | |
| run: swift test --package-path Packages/OsaurusCLI --parallel | |
| - name: Annotate timing | |
| if: always() | |
| run: echo "::notice title=test-cli duration::$SECONDS seconds" | |
| swiftlint: | |
| # Pinned (was `macos-latest`). | |
| runs-on: macos-26 | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Install SwiftLint | |
| run: brew install swiftlint | |
| - name: Run SwiftLint | |
| run: swiftlint lint --reporter github-actions-logging | |
| shellcheck: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| - name: Install shellcheck | |
| run: sudo apt-get update && sudo apt-get install -y shellcheck | |
| - name: Lint shell scripts | |
| run: find scripts -name '*.sh' -print0 | xargs -0 shellcheck --severity=warning |