Files
VoiceStudio/.github/workflows/ci.yml
T
Palash Debnath 15afc6611d fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244) (#1265)
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244)

The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that
bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls
eglGetPlatformDisplay() in a way the newer driver rejects and the app dies
before it paints:

    Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...

No environment variable helps, because the failure is in EGL display creation
— before WebKit consults any rendering-path flag. #1258 confirmed
WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.

Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this
class: what we bundle is frozen and host Mesa keeps moving. So when the host
has a WebKitGTK at least as new as ours, let it win — the bundle still fills
every gap, and a host without WebKitGTK is untouched. That is exactly why
building from source works on the hardware where the AppImage does not.

The compositing workaround is re-decided against whichever library ends up
running, and AppRun.test.sh — which had never been wired into CI — now runs
there, so this logic stops being a regression test nothing executes.

* fix(review): the ordering change was a no-op; name the host libdir explicitly

CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is
searched AHEAD of the linker's default paths no matter where in that variable a
directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
remained the only explicit search directory and still won. Merely appending it
changed nothing. The host's WebKit libdir is now named explicitly, ahead of
ours. The new tests fail 3/3 against the previous version.

Greptile P1 — a host with the runtime but no -dev package has no .pc file, so
pkg-config can't answer and the check rejected a perfectly good system WebKit.
The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT
gives those users an explicit opt-in (=0 opts out) rather than gambling on an
unverified version, which would risk the #961 regression.

CodeRabbit — my changelog script had also inserted the CI entry into the
published 0.4.0 section. Removed; it belongs only under Unreleased.

CodeRabbit — the docs' source-build fallback used 'cd frontend', not the
repo-root flow the rest of the page documents. Fixed.
2026-07-29 15:36:45 -07:00

362 lines
15 KiB
YAML

# PR-gated continuous integration — runs backend pytest + frontend node:test
# + TypeScript typecheck on every pull request and push to main. Keeps the
# heavy 4-platform Tauri bundle off this path (that's release.yml on tag
# push) so PRs turn around in a few minutes instead of ~40.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
# Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
test:
name: Tests (backend + frontend)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
# enable-cache persists ~/.cache/uv across runs, keyed on uv.lock —
# turns `uv sync` from ~45 s cold to ~5 s warm.
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
# Node 22 is needed for --experimental-strip-types so node:test can
# import .ts files directly from frontend/src/api/*.
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# apt install ffmpeg is ~30 s every run; cache the resolved .debs.
- name: System deps (ffmpeg)
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ffmpeg
version: 1.0
- name: Install Python deps
# `--all-extras` installs optional engine deps (e.g. `supertonic`)
# so their tests can exercise the real import path, not the
# "package not installed" fallback. Smoke job below stays on bare
# `uv sync` because smoke only hits /health + fixture profiles.
run: uv sync --all-extras
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
# that reaches huggingface.co fails fast and loud instead of silently
# downloading model weights mid-suite (the preload_model() Hub-probe
# bug pulled the full 2.3 GB k2-fsa/OmniVoice checkpoint into every
# networked empty-cache run before it was caught). All legitimate HF
# interactions in tests are stubbed; anything that trips this is a
# test-isolation bug.
- name: Run pytest
run: uv run pytest tests/ -q --tb=short
env:
HF_HUB_OFFLINE: "1"
# Docs-drift CI gate (Phase 1 INST-06). The validator extracts code
# blocks tagged `<!-- validate -->` from docs/install/*.md and asserts
# each line appears in scripts/desktop-prod.sh after normalisation.
# Its own correctness is enforced by tests/scripts/test_validate_install_docs.py
# (checker B-5) — those tests run in the previous step.
- name: Validate install docs against desktop-prod.sh
run: python scripts/validate-install-docs.py
# The AppImage launcher decides which WebKitGTK actually runs — the wrong
# answer is a permanently blank window on Linux (#56, #961, #1258), and
# the only place that logic is exercised is this shell harness. It had
# never been wired into CI, so its cases were a regression test nothing
# ran. Cheap (pure bash, stubs pkg-config) and it gates the class.
- name: AppImage launcher (AppRun) unit tests
run: bash frontend/src-tauri/appimage/AppRun.test.sh
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
# import chain) with a hermetic data dir from its conftest.py. It no
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
# the separate session is kept for cheaper, clearer CI output.
- name: Run pytest (backend/tests, isolated)
run: uv run pytest backend/tests/ -q --tb=short
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
# Cache ~/.bun/install/cache keyed on bun.lock — `bun install` drops
# from ~15 s cold to near-instant on warm cache.
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
# --frozen-lockfile so a frontend/package.json change that forgets to
# regenerate the root bun.lock fails HERE (fast) instead of only in the
# Docker build (deploy/Dockerfile), which is what reddened main on #485.
run: bun install --frozen-lockfile
# checkJs is true in tsconfig for IDE feedback, but 947 pre-existing
# JS errors remain. Override to false in CI so only .ts files block.
# Sourced from `typecheck:ci` in frontend/package.json so the release
# workflow runs an identical command — drift broke v0.3.x release runs.
- name: Frontend typecheck
working-directory: frontend
run: bun run typecheck:ci
# oxlint gate — fast Rust linter, blocks on errors so lint debt can't
# re-accumulate (warnings, incl. the react-compiler advisories in
# `lint:hooks`, are non-blocking). See frontend/.oxlintrc.json.
- name: Frontend lint (oxlint)
working-directory: frontend
run: bun run lint
# oxfmt format gate — JS/TS/JSX only (CSS/JSON/Tauri excluded; see
# frontend/.oxfmtrc.json). `bun run format` fixes locally.
- name: Frontend format check (oxfmt)
working-directory: frontend
run: bun run format:check
# `bun run test` (frontend/package.json), not `bunx vitest` — bunx
# resolves by npm package name and can miss workspace-hoisted bins,
# then falls back to fetching from npm (#962 class).
- name: Run Vitest (frontend)
working-directory: frontend
run: bun run test
# Legacy node:test runner for tests/frontend/*.test.mjs
- name: Run frontend node:test (legacy)
working-directory: frontend
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
# Production-bundle blank-screen gate. Everything above runs UN-minified
# (dev server + Vitest/jsdom), so a crash that exists ONLY in the minified
# release bundle — a TDZ reorder that throws before React mounts — passes
# every check and ships a black screen. That is how v0.3.22 went out (#1178),
# and it recurred pre-0.3.23. This builds the real dist/ and asserts the app
# actually mounts into #root. See frontend/e2e-prod/prod-bundle-smoke.spec.ts.
# (The in-app root <ErrorBoundary> in main-app.jsx catches such throws at
# runtime; this gate stops them reaching a release in the first place.)
- name: Install Playwright chromium
working-directory: frontend
run: bunx playwright install --with-deps chromium
- name: Production-bundle smoke — no blank screen
working-directory: frontend
run: bun run test:prod-bundle
# ── Cross-platform Tauri shell check ────────────────────────────────────
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
# gates, missing Windows/macOS deps, etc.) without spending the 15+ min
# per-platform that a full `tauri build` takes. `cargo check` is the
# lightest gate that exercises type-checking + linking for each target,
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
# Full bundling stays in release.yml on tag push.
tauri-cross-platform:
name: Tauri shell check (${{ matrix.label }})
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
label: macOS
rust_target: aarch64-apple-darwin
- os: windows-2022
label: Windows
rust_target: x86_64-pc-windows-msvc
- os: ubuntu-24.04
label: Linux
rust_target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
# Per-target cache key so we don't conflict with the release matrix.
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: frontend/src-tauri -> target
key: ${{ matrix.rust_target }}-check
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# Linux is the only host with non-trivial Tauri build deps —
# webkit2gtk + libayatana-appindicator + xdo. Mirror release.yml.
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
# --frozen-lockfile so a frontend/package.json change that forgets to
# regenerate the root bun.lock fails HERE (fast) instead of only in the
# Docker build (deploy/Dockerfile), which is what reddened main on #485.
run: bun install --frozen-lockfile
# tauri-build's setup hook reads tauri.conf.json's `frontendDist`
# ("../dist"), which only exists after a frontend build. Without this,
# `cargo check` would fail on a fresh checkout because the embedded
# asset map can't resolve.
- name: Build frontend (for tauri.conf.json frontendDist)
working-directory: frontend
run: bun run build
- name: Cargo check (Tauri shell)
working-directory: frontend/src-tauri
run: cargo check --target ${{ matrix.rust_target }} --message-format=short
# `cargo check` never compiles #[cfg(test)] code, so without this the
# shell's unit tests (crash.rs, reset.rs, commands.rs, …) neither build
# nor run anywhere in CI. --lib scopes it to the unit tests; each
# matrix target equals its host triple, so the test binary runs
# natively. Codegen is warmed by the rust-cache above.
- name: Cargo test (Tauri shell unit tests)
working-directory: frontend/src-tauri
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
# platform-specific Python import / path bugs that the Linux-only `test`
# job above misses. Narrow scope (tests/smoke/ only) — full pytest stays
# on Linux until Phase 1's INST-01 lands setuptools for WhisperX.
smoke-matrix:
name: Smoke (${{ matrix.label }})
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
label: macOS
- os: windows-2022
label: Windows
- os: ubuntu-22.04
label: Linux
runs-on: ${{ matrix.os }}
# Priced for a COLD `uv sync`, on every platform.
#
# The previous split (Windows 25, Linux/macOS 10) came from a warm-cache
# measurement — Linux and macOS finish in ~65 s when setup-uv restores its
# cache, so 10 looked generous. Then run 30439640107 hit
# "Failed to restore: Cache service responded with 400", Linux installed
# torch from scratch, and the leg was killed at 10m17s. The 65 s was the
# cache, not the platform.
#
# A cache miss is not rare enough to treat as an outage (GitHub's cache
# service 400s, a lockfile change invalidates the key, a new runner image
# starts empty), and a timeout here is self-perpetuating: the leg dies
# before the post-step saves the cache, so the next run is cold too.
# 25 everywhere is still bounded — a genuinely wedged job is caught in
# minutes, not hours — and warm runs land nowhere near it.
timeout-minutes: 25
env:
# Restricted-network resilience (RESEARCH Pitfall #6) — keeps uv from
# giving up on the first slow PyPI / python-build-standalone fetch.
UV_HTTP_TIMEOUT: "120"
UV_HTTP_RETRIES: "5"
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
# ffmpeg + libsndfile are needed by soundfile / audio fixtures even
# though the silence WAV doesn't decode anything heavy — keeps test
# collection from import-erroring on optional audio modules.
- name: System deps (macOS)
if: runner.os == 'macOS'
run: brew install ffmpeg libsndfile || true
- name: System deps (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
# The community chocolatey feed 50x's intermittently (broke PR runs on
# 2026-07-20 and 2026-07-28) — retry with backoff before failing.
#
# Test the OUTCOME, not choco's exit code. On 2026-07-28 the feed
# returned 503, choco reported "Unable to find package 'ffmpeg'" and
# "installed 0/0 packages" — and still exited 0. The `&& break` that
# was supposed to guard this fired on the first attempt, no retry ran,
# and the job died one line later on `ffmpeg: command not found`.
# A retry that trusts a lying exit code is not a retry.
for i in 1 2 3; do
choco install ffmpeg -y --no-progress || true
hash -r 2>/dev/null || true
if command -v ffmpeg >/dev/null 2>&1; then break; fi
# No backoff after the last attempt — there is no fourth try to
# wait for, and sleeping 90s only delays an already-doomed job.
if [ "$i" -eq 3 ]; then
echo "choco failed to produce ffmpeg after 3 attempts"
break
fi
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
sleep $((i * 30))
done
ffmpeg -version
- name: System deps (Linux)
if: runner.os == 'Linux'
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ffmpeg libsndfile1
version: 1.0
- name: Install Python deps
run: uv sync
- name: Run smoke tests
run: uv run pytest tests/smoke/ -q --tb=short
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job