name: CI on: push: branches: ["main"] pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true PNPM_CONFIG_MANAGE_PACKAGE_MANAGER_VERSIONS: "false" jobs: # ── Triage ─────────────────────────────────────────────────────────── # Runs the checked-in classifier (scripts/ci/classify-changes.mjs) to # determine which jobs should run for this change set. The classifier is # a pure function over filenames and event type — it is the single source # of truth for path-based CI gating. # # Outputs: # expected-jobs JSON array of job IDs that must run # run_ "true"/"false" per job ID, consumed by `if:` conditions # unknown "true" if any file is unmapped # unknown-files JSON array of unmapped filenames # categories JSON array of detected categories # # PR and main push: path-aware jobs via classify-changes.mjs. # workflow_dispatch: full CI (manual force). Full multi-platform and # accessibility matrices run on Nightly; Release owns signed packages and # Windows #284 core. See docs/CI_LAYERS.md. triage: name: Triage runs-on: ubuntu-slim timeout-minutes: 5 permissions: contents: read pull-requests: write # labeler needs this to add/remove labels outputs: expected-jobs: ${{ steps.classify.outputs.expected-jobs }} unknown: ${{ steps.classify.outputs.unknown }} run_standards-reference: ${{ steps.classify.outputs.run_standards-reference }} run_js-quality: ${{ steps.classify.outputs.run_js-quality }} run_app-frontend: ${{ steps.classify.outputs.run_app-frontend }} run_website: ${{ steps.classify.outputs.run_website }} run_playwright-ui-smoke: ${{ steps.classify.outputs.run_playwright-ui-smoke }} run_release-validation: ${{ steps.classify.outputs.run_release-validation }} run_cargo-deny: ${{ steps.classify.outputs.run_cargo-deny }} run_dependency-checks: ${{ steps.classify.outputs.run_dependency-checks }} run_prepare-model: ${{ steps.classify.outputs.run_prepare-model }} run_rust-test: ${{ steps.classify.outputs.run_rust-test }} run_rust-test-windows-compile: ${{ steps.classify.outputs.run_rust-test-windows-compile }} run_tauri-build-smoke: ${{ steps.classify.outputs.run_tauri-build-smoke }} run_tauri-build: ${{ steps.classify.outputs.run_tauri-build }} steps: # paths-filter uses the GitHub API for PR events (no checkout needed); # for push events it diffs via git, so checkout is required. - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Label PR by changed paths # Only label same-repo PRs — fork PRs get a read-only GITHUB_TOKEN # and the labeling API call 403s. Labeling is cosmetic (human # triage aid); path detection below is what gates jobs. uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 if: > github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository with: repo-token: ${{ secrets.GITHUB_TOKEN }} sync-labels: true - name: Collect changed files id: collect env: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} PR_NUMBER: ${{ github.event.pull_request.number }} BEFORE: ${{ github.event.before }} AFTER: ${{ github.event.after }} run: | set -euo pipefail if [ "$EVENT_NAME" = "pull_request" ]; then # Use the GitHub API with pagination to get the complete file list. gh api --paginate --jq '.[].filename' \ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \ > changed-files.txt elif [ "$EVENT_NAME" = "push" ]; then # Fetch BEFORE so we can diff locally. The GitHub compare/commit # APIs cap their `files` arrays at 300 entries, which would # silently drop changed paths; a local git diff has no such # limit. The checkout is shallow (fetch-depth: 1) so BEFORE is # not in local history until we fetch it. if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then # New branch — deepen by one to get the parent of AFTER. git fetch --no-tags --deepen=1 origin if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then git diff --name-only HEAD~1..HEAD > changed-files.txt else # Initial commit (no parent) — list all tracked files. git ls-tree -r --name-only HEAD > changed-files.txt fi else git fetch --no-tags --depth=1 origin "$BEFORE" git diff --name-only "$BEFORE".."$AFTER" > changed-files.txt fi else # workflow_dispatch — no changed files; classifier returns full CI. : > changed-files.txt fi echo "count=$(wc -l < changed-files.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" - name: Classify changes id: classify env: EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail node scripts/ci/classify-changes.mjs \ --files "$(cat changed-files.txt)" \ --event "$EVENT_NAME" \ > classification.json # Extract outputs for GITHUB_OUTPUT. jq -r ' "expected-jobs=\(.expectedJobs | @json)", "expected-skipped-heavy=\(.expectedSkippedHeavyJobs | @json)", "unknown=\(if .unknownFiles | length > 0 then "true" else "false" end)", "unknown-files=\(.unknownFiles | @json)", "categories=\(.categories | @json)" ' classification.json >> "$GITHUB_OUTPUT" # Per-job run_ booleans. jq -r '.run | to_entries[] | "run_\(.key)=\(.value)"' \ classification.json >> "$GITHUB_OUTPUT" # Print classification to the step summary. { echo "## CI Triage" echo "" echo "**Event:** \`${EVENT_NAME}\`" echo "" echo "### Changed files" echo "" if [ -s changed-files.txt ]; then while IFS= read -r file; do cats=$(jq -r --arg f "$file" \ '.categoriesByFile[$f] // ["unknown"] | join(", ")' \ classification.json) echo "- \`${file}\` → ${cats}" done < changed-files.txt else echo "- _(none — full CI by event type)_" fi echo "" echo "### Expected jobs" echo "" jq -r '.expectedJobs[] | "- \(.)"' classification.json echo "" echo "### Expected skipped heavy jobs" echo "" skipped=$(jq -r '.expectedSkippedHeavyJobs | length' classification.json) if [ "$skipped" -gt 0 ]; then jq -r '.expectedSkippedHeavyJobs[] | "- \(.)"' classification.json else echo "- _(none)_" fi if [ "$(jq '.unknownFiles | length' classification.json)" -gt 0 ]; then echo "" echo "### ⚠️ Unknown files (full CI safety fallback)" echo "" jq -r '.unknownFiles[] | "- `\(.)`"' classification.json fi } >> "$GITHUB_STEP_SUMMARY" prepare-model: name: Prepare separation model needs: triage if: | !cancelled() && needs.triage.outputs.run_prepare-model == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 package-manager-cache: false # Model identity comes from the pinned catalog snapshot # (src-tauri/catalog/release-manifest.json) via the same resolver # setup.sh uses, so CI, local setup, and the app share one contract. - name: Resolve separation model from catalog snapshot id: model run: | { echo "url=$(node scripts/resolve-model.mjs --field url)" echo "sha256=$(node scripts/resolve-model.mjs --field sha256)" echo "file=$(node scripts/resolve-model.mjs --field filename)" echo "file_sha256=$(node scripts/resolve-model.mjs --field file_sha256)" echo "archived=$(node scripts/resolve-model.mjs --field archived)" } >> "$GITHUB_OUTPUT" - name: Restore separation model cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: src-tauri/models/${{ steps.model.outputs.file }} key: separation-model-${{ steps.model.outputs.file_sha256 }} enableCrossOsArchive: true - name: Download and verify separation model env: MODEL_URL: ${{ steps.model.outputs.url }} DOWNLOAD_SHA256: ${{ steps.model.outputs.sha256 }} MODEL_FILE: ${{ steps.model.outputs.file }} MODEL_FILE_SHA256: ${{ steps.model.outputs.file_sha256 }} MODEL_ARCHIVED: ${{ steps.model.outputs.archived }} run: | if echo "$MODEL_FILE_SHA256 src-tauri/models/$MODEL_FILE" | sha256sum -c --status 2>/dev/null; then echo "Model already present and verified" exit 0 fi mkdir -p src-tauri/models curl -L --fail "$MODEL_URL" -o /tmp/model-download echo "$DOWNLOAD_SHA256 /tmp/model-download" | sha256sum -c if [ "$MODEL_ARCHIVED" = "true" ]; then tar -xzf /tmp/model-download -C src-tauri/models "$MODEL_FILE" else mv /tmp/model-download "src-tauri/models/$MODEL_FILE" fi echo "$MODEL_FILE_SHA256 src-tauri/models/$MODEL_FILE" | sha256sum -c - name: Upload separation model uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: separation-model path: src-tauri/models/${{ steps.model.outputs.file }} if-no-files-found: error retention-days: 1 conventional-commits: name: Conventional commits if: github.event_name == 'pull_request' runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Enforce conventional PR title uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ── Standards reference ───────────────────────────────────────────── # Validates the mandatory AGENTS.md → index → profile route without # running a full application build for a documentation-only change. standards-reference: name: Standards reference needs: triage if: | !cancelled() && needs.triage.outputs.run_standards-reference == 'true' runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 - name: Verify standards route run: node --run check:standards # ── JS quality ─────────────────────────────────────────────────────── # Format, lint, i18n, knip, schema drift, and dependency audit. # Runs for any JS-touching category but does not build or run tests. js-quality: name: JS quality needs: triage if: | !cancelled() && needs.triage.outputs.run_js-quality == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile # Read-only. db-schema drift stays below: it rewrites a generated # file and then diffs it, so it cannot share the worktree. - parallel: - name: Audit dependencies run: pnpm audit --audit-level=high - name: Verify formatting run: node --run format - name: Lint frontend run: node --run lint - name: Check i18n keys run: node --run check:i18n - name: knip (unused exports / dependencies) run: pnpm knip --no-progress - name: Check db-schema drift run: | pnpm generate:db-schema git diff --exit-code docs/references/generated/db-schema.md # ── App frontend ───────────────────────────────────────────────────── # App typecheck, production build, and unit tests with coverage. app-frontend: name: App frontend build / test needs: triage if: | !cancelled() && needs.triage.outputs.run_app-frontend == 'true' runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read pull-requests: write # Needed by vitest-coverage-report-action to comment coverage on PRs. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile - name: Build frontend run: node --run build - name: Typecheck frontend run: pnpm tsc --noEmit --project tsconfig.json - name: Run frontend tests with coverage run: node --run test -- --coverage --reporter=junit --outputFile=test-results.junit.xml - name: Upload test results to Codecov if: always() uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: report_type: test_results files: test-results.junit.xml token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false - name: Test metrics summary if: always() run: | { echo "## Frontend Test Metrics" echo "" # Parse coverage from json-summary if available if [ -f coverage/coverage-summary.json ]; then BRANCHES=$(jq '.total.branches.pct' coverage/coverage-summary.json) FUNCTIONS=$(jq '.total.functions.pct' coverage/coverage-summary.json) LINES=$(jq '.total.lines.pct' coverage/coverage-summary.json) STATEMENTS=$(jq '.total.statements.pct' coverage/coverage-summary.json) echo "### Coverage" echo "| Metric | Percentage |" echo "|--------|------------|" echo "| Branches | ${BRANCHES}% |" echo "| Functions | ${FUNCTIONS}% |" echo "| Lines | ${LINES}% |" echo "| Statements | ${STATEMENTS}% |" echo "" fi } >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: frontend-coverage path: coverage/ retention-days: 14 - name: Report coverage on PR if: always() && github.event_name == 'pull_request' uses: davelosert/vitest-coverage-report-action@c4bbc33a89b7ace0e63d35f1f7d4bcee31155a73 # v2.13.0 with: json-summary-path: ./coverage/coverage-summary.json vite-config-path: ./vite.config.ts - name: Upload coverage to Codecov if: always() uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} directory: coverage flags: frontend fail_ci_if_error: false # ── Website ────────────────────────────────────────────────────────── # Website typecheck and docs build only. Does not run the full app # test suite — website-only changes do not affect app behavior. website: name: Website build needs: triage if: | !cancelled() && needs.triage.outputs.run_website == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile - name: Typecheck landing page run: pnpm tsc --noEmit --project website/tsconfig.json - name: Build landing page run: node --run docs:build cargo-deny: name: Cargo deny needs: triage if: | !cancelled() && needs.triage.outputs.run_cargo-deny == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Check Rust dependencies uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 with: manifest-path: src-tauri/Cargo.toml workflow-lint: name: Workflow lint runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read security-events: write # Needed by zizmor-action to upload SARIF results. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: jactionlint uses: jdx/jactionlint@a0bb9f012b6b5760c17c9ccd040001d08002a10e # v2.1.0 with: profile: default - name: zizmor uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: persona: pedantic min-severity: low # ── Release validation ─────────────────────────────────────────────── # Focused validation for release-workflow and release-metadata changes. # Runs the release-workflow and release-metadata contract tests. Does not # run app/frontend/Rust CI. release-validation: name: Release validation needs: triage if: | !cancelled() && needs.triage.outputs.run_release-validation == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile - name: Run release contract tests run: | pnpm vitest run \ tests/release-workflow.test.ts \ tests/release-metadata.test.ts \ tests/macos-release-signing.test.ts dependency-checks: name: Dependency surface checks needs: triage if: | !cancelled() && needs.triage.outputs.run_dependency-checks == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Rust uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 package-manager-cache: false - name: ort C API level vs catalog runtimes run: node scripts/ci/check-ort-api-level.mjs # Prebuilt binary rather than `cargo install`, which rebuilt cargo-shear # from source on every run of a job whose actual work takes seconds. - name: Install cargo-shear uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28 with: tool: cargo-shear@1.12.4 - name: cargo-shear (unused Cargo dependencies) working-directory: src-tauri run: cargo shear --deny-warnings --locked rust-test: name: Rust tests (${{ matrix.name }}) needs: [triage, prepare-model] if: | !cancelled() && needs.triage.outputs.run_rust-test == 'true' && needs.prepare-model.result == 'success' runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: fail-fast: false matrix: include: - name: macOS os: macos-14 ort_target: aarch64-apple-darwin - name: Linux os: ubuntu-22.04 ort_target: x86_64-unknown-linux-gnu steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 package-manager-cache: false - name: Verify Cargo is not broken if: runner.os == 'macOS' run: | if cargo --version 2>&1 | grep -qi "rustup-init"; then echo "Broken cargo detected, reinstalling toolchain..." rm -rf "$HOME/.cargo" fi - name: Setup Rust uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable - name: Verify Rust toolchain (macOS) if: runner.os == 'macOS' run: | cargo --version rustc --version - name: Restore Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: ${{ github.workflow }}-${{ matrix.os }}-debug workspaces: ./src-tauri -> target cache-bin: false key: ${{ matrix.os }}-debug - name: Install Linux test dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ build-essential \ curl \ libasound2-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ libxdo-dev - name: Download separation model uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: separation-model path: src-tauri/models - name: Restore ONNX Runtime cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: src-tauri/generated/onnxruntime key: onnxruntime-${{ matrix.ort_target }}-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }} - name: Prepare ONNX Runtime env: ORT_TARGET: ${{ matrix.ort_target }} shell: bash run: node scripts/prepare-onnx-runtime.mjs --target "${ORT_TARGET}" - name: Verify Rust formatting working-directory: src-tauri run: cargo fmt --check - name: Clippy working-directory: src-tauri run: cargo clippy --all-targets -- -D warnings - name: Install nextest uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28 with: tool: nextest - name: Run Rust tests working-directory: src-tauri env: # Parallel rust-lld of many heavy Tauri integration-test binaries has # hit SIGBUS (Bus error) on GitHub Linux runners. Cap build jobs so # concurrent linkers do not collide under memory pressure. CARGO_BUILD_JOBS: "2" run: | if [ "$RUNNER_OS" = "Linux" ]; then cargo nextest run --no-fail-fast else # phase5_perf asserts fixed latency thresholds for fixture audio. GitHub's # macOS runners have noisy cold metadata probes; Linux remains the CI # baseline, while macOS still runs the functional Rust suite. # Filter by binary name — test(phase5_perf) does not match the binary # and still runs backend_performance_report_stays_within_phase5_thresholds. cargo nextest run --no-fail-fast -E '!binary(phase5_perf)' fi shell: bash rust-test-windows-compile: name: Rust tests (Windows compile) needs: triage if: | !cancelled() && needs.triage.outputs.run_rust-test-windows-compile == 'true' runs-on: windows-latest timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Rust uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable - name: Restore Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: ${{ github.workflow }}-windows-latest-debug workspaces: ./src-tauri -> target cache-bin: false key: windows-latest-debug - name: Restore ONNX Runtime cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: src-tauri/generated/onnxruntime key: onnxruntime-x86_64-pc-windows-msvc-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }} - name: Prepare ONNX Runtime run: node scripts/prepare-onnx-runtime.mjs --target x86_64-pc-windows-msvc - name: Compile test binaries only working-directory: src-tauri run: cargo test -q --no-run # The installed-app CLI is feature-gated out of normal user builds, so # compile it explicitly here. This is a cached compile-only check on the # existing Windows Rust job, rather than a separate slow runner. - name: Compile installed-app automation smoke entry point working-directory: src-tauri run: cargo check -q --features automation-smoke --bin openkara - name: Verify execution-provider platform contract working-directory: src-tauri shell: pwsh run: | $env:PATH = "$env:GITHUB_WORKSPACE\src-tauri\generated\onnxruntime;$env:PATH" cargo test -q --lib execution_provider # Linux-only Tauri smoke build for frontend-only PRs. Verifies that the # frontend bundles into Tauri and the build entry point works, without # paying for the 3-platform matrix. Runs when frontend changed but rust # did not (pure frontend, deps_js, or frontend_tooling). tauri-build-smoke: name: Tauri build (Linux smoke) needs: [triage, app-frontend] if: | !cancelled() && needs.triage.outputs.run_tauri-build-smoke == 'true' && needs.app-frontend.result != 'failure' runs-on: ubuntu-22.04 timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Setup Rust uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable - name: Restore Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: ${{ github.workflow }}-ubuntu-22.04-release workspaces: ./src-tauri -> target cache-bin: false key: ubuntu-22.04-release - name: Install Linux build dependencies run: | sudo apt-get update sudo apt-get install -y \ build-essential \ binutils \ curl \ file \ patchelf \ libasound2-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ libxdo-dev \ wget - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile - name: Restore ONNX Runtime cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: src-tauri/generated/onnxruntime key: onnxruntime-x86_64-unknown-linux-gnu-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }} - name: Prepare ONNX Runtime env: ORT_TARGET: x86_64-unknown-linux-gnu shell: bash run: node scripts/prepare-onnx-runtime.mjs --target "${ORT_TARGET}" - name: Validate Tauri build env: OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON: ${{ secrets.OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON }} OPENKARA_DROPBOX_APP_KEY: ${{ secrets.OPENKARA_DROPBOX_APP_KEY }} OPENKARA_DROPBOX_APP_SECRET: ${{ secrets.OPENKARA_DROPBOX_APP_SECRET }} run: pnpm tauri build --ci --bundles deb shell: bash # Full 3-platform Tauri build for Rust/deps/workflows/unknown changes. # Waits for cheap checks (dependency-checks, windows-compile) before # starting the expensive matrix so failures surface faster. tauri-build: name: Tauri build (${{ matrix.name }}) needs: [ triage, app-frontend, rust-test, cargo-deny, dependency-checks, rust-test-windows-compile, ] # Use !cancelled() so the job can run even when some upstream jobs were # skipped (e.g. a rust-only PR skips app-frontend, but tauri-build still # needs to verify the Rust side compiles into a Tauri bundle). # The result != 'failure' guards ensure we don't build on top of a # failed dependency. Only runs for rust/deps/unknown — # frontend-only PRs use tauri-build-smoke (Linux) instead. if: | !cancelled() && needs.triage.outputs.run_tauri-build == 'true' && needs.app-frontend.result != 'failure' && needs.rust-test.result != 'failure' && needs.cargo-deny.result != 'failure' && needs.dependency-checks.result != 'failure' && needs.rust-test-windows-compile.result != 'failure' runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: fail-fast: false matrix: include: - name: macOS os: macos-14 ort_target: aarch64-apple-darwin - name: Windows os: windows-latest ort_target: x86_64-pc-windows-msvc - name: Linux os: ubuntu-22.04 ort_target: x86_64-unknown-linux-gnu steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Verify Cargo is not broken if: runner.os == 'macOS' run: | # Some macOS runner images ship with a broken toolchain where # $HOME/.cargo/bin/cargo dispatches to rustup-init. Detect and # repair without a full reinstall every time. if cargo --version 2>&1 | grep -qi "rustup-init"; then echo "Broken cargo detected, reinstalling toolchain..." rm -rf "$HOME/.cargo" fi - name: Setup Rust uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable - name: Repair toolchain if still broken if: runner.os == 'macOS' run: | # rustup can leave cargo pointing to rustup-init if the # proxy links got clobbered. Re-run the toolchain installer # so it repairs the bin symlinks. if cargo --version 2>&1 | grep -qi "rustup-init"; then rustup update stable --force fi - name: Restore Rust cache uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: ${{ github.workflow }}-${{ matrix.os }}-release workspaces: ./src-tauri -> target cache-bin: false key: ${{ matrix.os }}-release - name: Install Linux build dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ build-essential \ binutils \ curl \ file \ patchelf \ libasound2-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ libxdo-dev \ wget - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile # No `pnpm audit` here: js-quality already gates the same lockfile, and a # second run only adds a registry round trip to the slowest job in CI. - name: Restore ONNX Runtime cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: src-tauri/generated/onnxruntime key: onnxruntime-${{ matrix.ort_target }}-${{ hashFiles('scripts/prepare-onnx-runtime.mjs', 'src-tauri/catalog/release-manifest.json') }} - name: Prepare ONNX Runtime env: ORT_TARGET: ${{ matrix.ort_target }} shell: bash run: node scripts/prepare-onnx-runtime.mjs --target "${ORT_TARGET}" - name: Validate Tauri build env: OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON: ${{ secrets.OPENKARA_GOOGLE_DRIVE_OAUTH_CLIENT_JSON }} OPENKARA_DROPBOX_APP_KEY: ${{ secrets.OPENKARA_DROPBOX_APP_KEY }} OPENKARA_DROPBOX_APP_SECRET: ${{ secrets.OPENKARA_DROPBOX_APP_SECRET }} run: | if [ "$RUNNER_OS" = "Linux" ]; then pnpm tauri build --ci --bundles deb else pnpm tauri build --debug --no-bundle --ci fi shell: bash - name: Verify Linux glibc floor if: runner.os == 'Linux' shell: bash run: | set -euo pipefail find_one() { local pattern="$1" find src-tauri/target/release -path "$pattern" -print -quit } max_glibc_version() { local binary="$1" # Exclude weak undefined symbols -- Rust's stdlib weak-imports # glibc helpers like pidfd_spawnp / pidfd_getpid that gracefully # fall back at runtime and do not create a hard dependency. # objdump -T flags field: " w DF" for weak, " DF" for strong. objdump -T "$binary" \ | awk ' / w .* \*UND\*/ { next } match($0, /GLIBC_[0-9.]+/) { print substr($0, RSTART + 6, RLENGTH - 6) } ' \ | sort -V \ | tail -1 } assert_glibc_floor() { local binary="$1" local max_version max_version="$(max_glibc_version "$binary")" if [ -z "$max_version" ]; then echo "No GLIBC symbols found in $binary" return fi echo "$binary: highest strong GLIBC requirement is $max_version" if [ "$(printf '%s\n%s\n' "$max_version" "2.35" | sort -V | tail -1)" != "2.35" ]; then echo "::error::$binary requires GLIBC_$max_version, which exceeds Ubuntu 22.04's GLIBC_2.35 baseline" objdump -T "$binary" | grep "GLIBC_$max_version" | head -5 exit 1 fi } app_binary="$(find_one '*/bundle/deb/*/usr/bin/openkara')" test -n "$app_binary" assert_glibc_floor "$app_binary" playwright-ui-smoke: name: Playwright UI smoke needs: triage if: | !cancelled() && needs.triage.outputs.run_playwright-ui-smoke == 'true' runs-on: ubuntu-24.04 # Run inside Playwright's official image so the browsers and their system # libraries are already present. `playwright install --with-deps` pulled # ~114 MB of apt packages from azure.archive.ubuntu.com on every run; on # 2026-10-22 that mirror slowed to ~50 kB/s and the install step alone # used up the whole job timeout (run 39801493440, both attempts). # The image tag must equal the playwright version in pnpm-lock.yaml # (enforced by tests/ci/ci-workflow-contract.test.ts); bump both together. container: image: mcr.microsoft.com/playwright@sha256:7852dc5a7d72f5fe458a597d690f6c42fbc8e9e26ddfa95f8e6ef552558d9bbf # v1.64.1-noble # Chromium needs a larger /dev/shm than Docker's 64 MB default; # --init reaps the browser and Vite child processes. options: --ipc=host --init # Inside a container GitHub defaults `run:` to `sh -e`, which rejects # `set -o pipefail`; keep the bash the host runner used. defaults: run: shell: bash timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12.15.0 standalone: true - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile # The image already ships chromium, chromium-headless-shell, ffmpeg and # webkit under PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, so this finds # them and downloads nothing. No --with-deps: the image has the system # libraries, and that apt step is what used to hang. - name: Ensure Playwright browsers run: pnpm exec playwright install chromium webkit - name: Run Playwright UI smoke tests run: node --run test:ui-smoke # Count flaky tests (passed only after a retry) from the JSON report and # surface them in the job summary. retries:1 keeps the job green when a # test flakes, so without this a flaking test would degrade silently. # Runs on always() so failures still get a flaky breakdown. - name: Report flaky tests if: always() run: | set -euo pipefail report="playwright-report/results.json" if [ ! -f "$report" ]; then { echo "## Playwright UI smoke — flaky tests" echo "" echo "_No JSON report at ${report}; skipping flaky analysis._" } >> "$GITHUB_STEP_SUMMARY" exit 0 fi # The Playwright image has no jq, so read the report with Node. # Same semantics as the old jq filters: stats.flaky/unexpected # default to 0, and every spec under any nested suite whose tests # include status "flaky" is listed by title. flaky_json=$(node -e ' const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); const specs = []; (function walk(v) { if (Array.isArray(v)) return v.forEach(walk); if (v && typeof v === "object") { if (Array.isArray(v.specs)) specs.push(...v.specs); Object.values(v).forEach(walk); } })(r); const list = specs .filter((s) => (s.tests || []).some((t) => t.status === "flaky")) .map((s) => "- \u0060" + s.title + "\u0060"); console.log(JSON.stringify({ flaky: r.stats?.flaky ?? 0, unexpected: r.stats?.unexpected ?? 0, list: list.join("\n"), })); ' "$report") flaky=$(node -p 'JSON.parse(process.argv[1]).flaky' "$flaky_json") unexpected=$(node -p 'JSON.parse(process.argv[1]).unexpected' "$flaky_json") flaky_list=$(node -p 'JSON.parse(process.argv[1]).list' "$flaky_json") { echo "## Playwright UI smoke — flaky tests" echo "" echo "**Flaky (passed on retry):** ${flaky}" echo "**Failed:** ${unexpected}" echo "" if [ "$flaky" -gt 0 ]; then echo "These passed only after a retry — retries:1 masked them, so investigate:" echo "" echo "$flaky_list" else echo "No flaky tests detected." fi } >> "$GITHUB_STEP_SUMMARY" if [ "$flaky" -gt 0 ]; then echo "::warning::${flaky} flaky Playwright test(s) detected — passed only on retry (retries:1)." fi - name: Upload Playwright report if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: playwright-report path: playwright-report/ if-no-files-found: error retention-days: 7 # ── CI Gate ────────────────────────────────────────────────────────── # Single required check for branch protection. Always runs after all # other jobs. Reads the expected-job set from triage and verifies that: # 1. Every expected job succeeded. # 2. Every non-expected job was skipped. # 3. No unexpected expensive job ran (gate regression detection). # This catches both triage misclassification (unexpected skip) and # gate regressions (unexpected execution). ci-gate: name: CI Gate if: always() needs: - triage - conventional-commits - standards-reference - js-quality - app-frontend - website - cargo-deny - workflow-lint - release-validation - dependency-checks - prepare-model - rust-test - rust-test-windows-compile - tauri-build-smoke - tauri-build - playwright-ui-smoke runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Verify expected jobs succeeded and non-expected jobs skipped env: EXPECTED_JOBS: ${{ needs.triage.outputs.expected-jobs }} TRIAGE: ${{ needs.triage.result }} CONVENTIONAL_COMMITS: ${{ needs.conventional-commits.result }} STANDARDS_REFERENCE: ${{ needs.standards-reference.result }} JS_QUALITY: ${{ needs.js-quality.result }} APP_FRONTEND: ${{ needs.app-frontend.result }} WEBSITE: ${{ needs.website.result }} CARGO_DENY: ${{ needs.cargo-deny.result }} WORKFLOW_LINT: ${{ needs.workflow-lint.result }} RELEASE_VALIDATION: ${{ needs.release-validation.result }} DEPENDENCY_CHECKS: ${{ needs.dependency-checks.result }} PREPARE_MODEL: ${{ needs.prepare-model.result }} RUST_TEST: ${{ needs.rust-test.result }} RUST_TEST_WINDOWS_COMPILE: ${{ needs.rust-test-windows-compile.result }} TAURI_BUILD_SMOKE: ${{ needs.tauri-build-smoke.result }} TAURI_BUILD: ${{ needs.tauri-build.result }} PLAYWRIGHT_UI_SMOKE: ${{ needs.playwright-ui-smoke.result }} UNKNOWN_FLAG: ${{ needs.triage.outputs.unknown }} run: | set -euo pipefail # All checkable jobs and their result env vars. JOBS=( "triage:$TRIAGE" "conventional-commits:$CONVENTIONAL_COMMITS" "standards-reference:$STANDARDS_REFERENCE" "js-quality:$JS_QUALITY" "app-frontend:$APP_FRONTEND" "website:$WEBSITE" "cargo-deny:$CARGO_DENY" "workflow-lint:$WORKFLOW_LINT" "release-validation:$RELEASE_VALIDATION" "dependency-checks:$DEPENDENCY_CHECKS" "prepare-model:$PREPARE_MODEL" "rust-test:$RUST_TEST" "rust-test-windows-compile:$RUST_TEST_WINDOWS_COMPILE" "tauri-build-smoke:$TAURI_BUILD_SMOKE" "tauri-build:$TAURI_BUILD" "playwright-ui-smoke:$PLAYWRIGHT_UI_SMOKE" ) # Verify triage produced valid expected-jobs JSON. if ! echo "$EXPECTED_JOBS" | jq -e 'type == "array"' >/dev/null 2>&1; then echo "::error::Triage did not produce valid expected-jobs JSON: ${EXPECTED_JOBS:-}" exit 1 fi fail=0 for entry in "${JOBS[@]}"; do job="${entry%%:*}" result="${entry#*:}" is_expected=$(echo "$EXPECTED_JOBS" | jq --arg job "$job" 'any(. == $job)') echo "$job: result=$result expected=$is_expected" if [ "$is_expected" = "true" ]; then if [ "$result" != "success" ]; then echo "::error::Expected job '$job' did not succeed (result: $result)" fail=1 fi else if [ "$result" != "skipped" ]; then echo "::error::Unexpected job '$job' ran (result: $result) — gate regression" fail=1 fi fi done # Verify unknown files are printed and full CI is expected when # unknown files exist. UNKNOWN=$(echo "$EXPECTED_JOBS" | jq 'any(. == "tauri-build")') if [ "$UNKNOWN_FLAG" = "true" ] && [ "$UNKNOWN" != "true" ]; then echo "::error::Unknown files exist but full CI (tauri-build) was not expected" fail=1 fi if [ "$fail" -ne 0 ]; then echo "" echo "Expected jobs: $(echo "$EXPECTED_JOBS" | jq -r '. | join(", ")')" fi exit $fail