--- name: PR CI on: push: branches: [main] pull_request: permissions: contents: read packages: read pull-requests: read env: NPM_CONFIG_STORE_DIR: .pnpm-store # Keep only the newest run per PR/branch. Long Rust/Tauri build commands must # run through scripts/ci-cancel-aware.sh so cancellation reaches descendants. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: changes: name: Detect Changed Areas runs-on: ubuntu-latest timeout-minutes: 5 outputs: frontend: ${{ steps.filter.outputs.frontend }} i18n: ${{ steps.filter.outputs.i18n }} rust-core: ${{ steps.filter.outputs['rust-core'] }} rust-tauri: ${{ steps.filter.outputs['rust-tauri'] }} playwright: ${{ steps.filter.outputs.playwright }} coverage: ${{ steps.filter.outputs.frontend == 'true' || steps.filter.outputs['rust-core'] == 'true' || steps.filter.outputs['rust-tauri'] == 'true' }} steps: - name: Checkout code uses: actions/checkout@v5 - name: Detect changed paths id: filter uses: dorny/paths-filter@v3 with: filters: | frontend: - '.github/workflows/pr-ci.yml' - 'package.json' - 'pnpm-lock.yaml' - 'app/package.json' - 'app/index.html' - 'app/public/**' - 'app/src/**' - 'app/test/vitest.config.ts' - 'app/tsconfig*.json' - 'app/vite.config.*' - 'app/tailwind.config.*' - 'app/postcss.config.*' i18n: - '.github/workflows/pr-ci.yml' - 'app/src/**' rust-core: - '.github/workflows/pr-ci.yml' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'src/**' - 'tests/**' - 'scripts/ci-cancel-aware.sh' - 'scripts/test-rust-e2e.sh' - 'scripts/test-rust-with-mock.sh' rust-tauri: - '.github/workflows/pr-ci.yml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'app/src-tauri/**' - 'scripts/ci-cancel-aware.sh' playwright: - '.github/workflows/pr-ci.yml' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'package.json' - 'pnpm-lock.yaml' - 'src/**' - 'app/package.json' - 'app/index.html' - 'app/public/**' - 'app/src/**' - 'app/test/e2e/**' - 'app/scripts/e2e-web-*.sh' - 'scripts/ci-cancel-aware.sh' frontend-quality: name: Frontend Quality (typecheck, lint, format) needs: [changes] if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-22.04 timeout-minutes: 15 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 - name: Cache pnpm store id: pnpm-cache uses: actions/cache/restore@v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-store-${{ runner.os }}- - name: Install dependencies run: pnpm install --frozen-lockfile - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Type check TypeScript files run: pnpm --filter openhuman-app compile env: NODE_ENV: test - name: Check Prettier formatting run: pnpm --filter openhuman-app format:check env: NODE_ENV: test - name: Run ESLint run: pnpm --filter openhuman-app lint env: NODE_ENV: test i18n-coverage: name: i18n Coverage (parity) needs: [changes] if: needs.changes.outputs.i18n == 'true' runs-on: ubuntu-22.04 timeout-minutes: 10 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 - name: Cache pnpm store id: pnpm-cache uses: actions/cache/restore@v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-store-${{ runner.os }}- - name: Install dependencies run: pnpm install --frozen-lockfile - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Verify i18n coverage (missing / extra / drifted keys across locales) run: pnpm i18n:check rust-quality: name: Rust Quality (fmt, clippy) needs: [changes] if: needs.changes.outputs['rust-core'] == 'true' || needs.changes.outputs['rust-tauri'] == 'true' runs-on: ubuntu-22.04 timeout-minutes: 20 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: "0" steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target cache-on-failure: true # shared-key (not key) so the cache name is stable and not suffixed # with the job id. Swatinem caches the full target/, which is why we # no longer layer sccache on top (it logged 0% hits under a warm # rust-cache and just doubled the cache footprint). shared-key: pr-rust-quality - name: Check Rust formatting run: cargo fmt --all -- --check - name: Run clippy (core crate) run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman build-playwright-e2e-artifact: name: Build Playwright E2E Artifact needs: [changes] if: needs.changes.outputs.playwright == 'true' runs-on: ubuntu-22.04 container: image: ghcr.io/tinyhumansai/openhuman_ci:latest timeout-minutes: 30 env: CARGO_INCREMENTAL: "0" steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache pnpm store id: pnpm-cache uses: actions/cache/restore@v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-store-${{ runner.os }}- - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 with: workspaces: . -> target cache-on-failure: true # Shared with rust-e2e (same image + env): both compile the openhuman # crate's non-instrumented dependency tree, so one cache entry serves # both instead of two ~1GB copies competing for the repo cache budget. shared-key: pr-rust-core - name: Restore cached Playwright E2E artifact id: playwright-artifact-cache uses: actions/cache/restore@v5 with: path: .ci/artifacts/e2e-playwright-linux.tar.gz key: e2e-playwright-linux-${{ hashFiles('src/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', 'app/src/**', 'app/public/**', 'app/index.html', 'app/vite.config.*', 'app/tailwind.config.*', 'app/postcss.config.*', 'app/package.json', 'pnpm-lock.yaml', 'app/scripts/e2e-web-build.sh') }} - name: Install dependencies if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Ensure .env exists for E2E build if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' run: | touch .env touch app/.env - name: Build and package Playwright E2E artifact if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' run: | set -euo pipefail pnpm --filter openhuman-app test:e2e:web:build RUST_HOST_TRIPLE="$(rustc -vV | awk '/^host: / { print $2 }')" WEB_CORE_TARGET_DIR="target/e2e-web-${RUST_HOST_TRIPLE}" STAGE="$(mktemp -d)" mkdir -p "$STAGE/repo/app/dist-web" mkdir -p "$STAGE/repo/$WEB_CORE_TARGET_DIR/debug" mkdir -p .ci/artifacts cp -a app/dist-web/. "$STAGE/repo/app/dist-web/" cp -a "$WEB_CORE_TARGET_DIR/debug/openhuman-core" "$STAGE/repo/$WEB_CORE_TARGET_DIR/debug/" tar -czf .ci/artifacts/e2e-playwright-linux.tar.gz -C "$STAGE" repo ls -lh .ci/artifacts/e2e-playwright-linux.tar.gz - name: Verify build artifact exists run: | set -euo pipefail test -f .ci/artifacts/e2e-playwright-linux.tar.gz - name: Save Playwright E2E artifact cache if: always() && steps.playwright-artifact-cache.outputs.cache-hit != 'true' && hashFiles('.ci/artifacts/e2e-playwright-linux.tar.gz') != '' uses: actions/cache/save@v5 with: path: .ci/artifacts/e2e-playwright-linux.tar.gz key: ${{ steps.playwright-artifact-cache.outputs.cache-primary-key }} - name: Upload Playwright E2E artifact uses: actions/upload-artifact@v5 with: name: e2e-playwright-linux-${{ github.run_id }} path: .ci/artifacts/e2e-playwright-linux.tar.gz retention-days: 1 if-no-files-found: error frontend-coverage: name: Frontend Coverage (Vitest) needs: [changes, frontend-quality] if: always() && needs.changes.outputs.frontend == 'true' && needs['frontend-quality'].result == 'success' runs-on: ubuntu-22.04 timeout-minutes: 30 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 - name: Cache pnpm store id: pnpm-cache uses: actions/cache/restore@v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-store-${{ runner.os }}- - name: Install dependencies run: pnpm install --frozen-lockfile - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Run Vitest with coverage run: pnpm test:coverage working-directory: app env: NODE_ENV: test - name: Normalize lcov source paths to repo root run: | set -euo pipefail test -f app/coverage/lcov.info sed -i -E 's#^SF:(src/)#SF:app/\1#' app/coverage/lcov.info sed -i -E 's#^SF:(\./src/)#SF:app/src/#' app/coverage/lcov.info - name: Upload frontend lcov uses: actions/upload-artifact@v5 with: name: lcov-frontend path: app/coverage/lcov.info retention-days: 7 if-no-files-found: error rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) needs: [changes, rust-quality] if: always() && needs.changes.outputs['rust-core'] == 'true' && needs['rust-quality'].result == 'success' runs-on: ubuntu-22.04 timeout-minutes: 30 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: "0" # Coverage-instrumented test binaries are dominated by DWARF debug info, # which exhausts the runner disk and SIGBUSes the linker. Drop debug info # entirely for this job — llvm-cov line/region coverage is read from the # embedded __llvm_covmap/__llvm_covfun sections (filenames + line numbers # included), NOT from DWARF, so coverage numbers are unaffected. This is # the maximum-shrink setting; line-tables-only was still too large for the # instrumented build. CARGO_PROFILE_DEV_DEBUG: "0" steps: - name: Free disk space run: | # Coverage-instrumented builds produce huge binaries that exhaust the # runner's ~14GB free disk during linking (SIGBUS). Inside a container, # host paths are bind-mounted: # /opt/hostedtoolcache → /__t # /home/runner/work → /__w # Deleting /__t frees ~8GB on the same partition the build uses. rm -rf /__t/* || true echo "Disk after cleanup:" df -h /__w || true - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 with: workspaces: . -> target cache-on-failure: true shared-key: pr-rust-core-cov - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - name: Run cargo llvm-cov for openhuman core run: bash scripts/ci-cancel-aware.sh cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info env: CARGO_BUILD_JOBS: "1" - name: Upload core lcov uses: actions/upload-artifact@v5 with: name: lcov-rust-core path: lcov-core.info retention-days: 7 if-no-files-found: error rust-tauri-coverage: name: Rust Tauri Coverage (cargo-llvm-cov) needs: [changes, rust-quality] if: always() && needs.changes.outputs['rust-tauri'] == 'true' && needs['rust-quality'].result == 'success' runs-on: ubuntu-22.04 timeout-minutes: 30 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: "0" # The instrumented Tauri shell binary (whole tauri/zbus/CEF graph) is # dominated by DWARF debug info, which exhausts the runner disk and # SIGBUSes the linker (collect2: ld terminated with signal 7) during the # final link. Drop debug info — llvm-cov reads coverage from the embedded # __llvm_covmap/__llvm_covfun sections, NOT from DWARF, so coverage # numbers are unaffected. Mirrors rust-core-coverage. CARGO_PROFILE_DEV_DEBUG: "0" steps: - name: Free disk space run: | # Coverage-instrumented builds produce huge binaries that exhaust the # runner's ~14GB free disk during linking (SIGBUS). Inside a container, # host paths are bind-mounted: # /opt/hostedtoolcache → /__t # /home/runner/work → /__w # Deleting /__t frees ~8GB on the same partition the build uses. # Mirrors rust-core-coverage. rm -rf /__t/* || true echo "Disk after cleanup:" df -h /__w || true - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target app/src-tauri -> target cache-on-failure: true # Tauri shell coverage (instrumented) — its own stable cache; reuses # its warm dependency tree run-over-run. shared-key: pr-rust-tauri-cov - name: Cache CEF binary distribution id: cef-cache uses: actions/cache/restore@v5 with: path: | ~/Library/Caches/tauri-cef ~/.cache/tauri-cef key: cef-x86_64-unknown-linux-gnu-v2-${{ hashFiles('app/src-tauri/Cargo.toml') }} restore-keys: | cef-x86_64-unknown-linux-gnu-v2- - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - name: Run cargo llvm-cov for Tauri shell run: bash scripts/ci-cancel-aware.sh cargo llvm-cov --manifest-path app/src-tauri/Cargo.toml --lcov --output-path lcov-tauri.info env: # Serialize codegen/link to cap peak memory + disk during the # instrumented Tauri-shell link (SIGBUS otherwise). Mirrors # rust-core-coverage. CARGO_BUILD_JOBS: "1" - name: Save CEF binary distribution cache if: always() && steps.cef-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: | ~/Library/Caches/tauri-cef ~/.cache/tauri-cef key: ${{ steps.cef-cache.outputs.cache-primary-key }} - name: Upload tauri lcov uses: actions/upload-artifact@v5 with: name: lcov-rust-tauri path: lcov-tauri.info retention-days: 7 if-no-files-found: error rust-e2e: name: Rust E2E (mock backend) needs: [changes, rust-quality] if: always() && needs.changes.outputs['rust-core'] == 'true' && needs['rust-quality'].result == 'success' runs-on: ubuntu-22.04 container: image: ghcr.io/tinyhumansai/openhuman_ci:latest timeout-minutes: 30 env: CARGO_INCREMENTAL: "0" # Trim DWARF debug info so test-binary linking doesn't exhaust runner disk # (SIGBUS). Tests don't need full debug info; backtraces keep file/line. CARGO_PROFILE_DEV_DEBUG: line-tables-only steps: - name: Free disk space run: | # Reclaim space on the build partition before compiling/linking the # Rust test binaries (host toolcache is bind-mounted at /__t inside the # container). Mirrors the rust-core-coverage cleanup. rm -rf /__t/* || true df -h /__w || true - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache pnpm store id: pnpm-cache uses: actions/cache/restore@v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-store-${{ runner.os }}- - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 with: workspaces: . -> target cache-on-failure: true # Shared with build-playwright (same image + env): one non-instrumented # core cache serves both lanes instead of two competing copies. shared-key: pr-rust-core - name: Install dependencies run: pnpm install --frozen-lockfile - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Ensure .env exists for tests run: | touch .env touch app/.env - name: Run Rust E2E suite run: pnpm test:rust:e2e env: RUST_BACKTRACE: "1" playwright-e2e: name: E2E (Playwright / web lane ${{ matrix.shard }}/4) needs: [changes, frontend-quality, rust-quality, build-playwright-e2e-artifact] if: | always() && needs.changes.outputs.playwright == 'true' && needs['build-playwright-e2e-artifact'].result == 'success' && contains(fromJSON('["success", "skipped"]'), needs['frontend-quality'].result) && contains(fromJSON('["success", "skipped"]'), needs['rust-quality'].result) runs-on: ubuntu-22.04 container: image: ghcr.io/tinyhumansai/openhuman_ci:latest timeout-minutes: 30 # Sharded 4 ways: each shard is a separate runner that boots its OWN backend # stack (mock + core + web on fixed ports) and runs playwright --shard=i/4. # Because each shard is an isolated machine there is no shared mock-state # race, so this parallelises safely despite the suite's workers:1 design. strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] # TODO(ci-flaky, #3615): shard 1/4 consistently hits toBeVisible timeouts # under CI resource contention — chat-tool-error-recovery, connector-gmail- # composio, connector-session-guard-matrix, chat-harness-send-stream, # chat-management-functional and ~7 others. Shards 2-4 pass reliably. # Playwright config bumped to 90s test / 15s expect timeout + 2 retries. # Run the lane but don't block merges until infra stabilises. continue-on-error: true steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache pnpm store id: pnpm-cache uses: actions/cache/restore@v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-store-${{ runner.os }}- - name: Install JS dependencies (test harness) run: pnpm install --frozen-lockfile - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Download Playwright E2E artifact uses: actions/download-artifact@v5 with: name: e2e-playwright-linux-${{ github.run_id }} path: . - name: Restore Playwright E2E artifact run: | set -euo pipefail tar -xzf e2e-playwright-linux.tar.gz cp -a repo/app/dist-web app/ mkdir -p target cp -a repo/target/. target/ rm -rf repo e2e-playwright-linux.tar.gz chmod +x target/e2e-web-*/debug/openhuman-core - name: Install Playwright Chromium headless shell run: pnpm --filter openhuman-app exec playwright install chromium-headless-shell - name: Run Playwright web E2E suite (shard ${{ matrix.shard }}/4) env: OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace run: | mkdir -p "$OPENHUMAN_WORKSPACE" bash app/scripts/e2e-web-session.sh \ --shard=${{ matrix.shard }}/4 - name: Upload Playwright E2E failure artifacts if: failure() uses: actions/upload-artifact@v5 with: name: e2e-playwright-failure-logs-${{ github.run_id }}-shard${{ matrix.shard }} path: | ${{ runner.temp }}/openhuman-playwright-workspace/** retention-days: 7 if-no-files-found: ignore playwright-e2e-gate: name: Playwright E2E Gate # Fixed-name aggregator over the 4 dynamic shard checks. Branch protection # can require this single job instead of the per-shard matrix names (which # change if the shard count changes). if: always() so it runs even when a # shard is failed/skipped and can report the real aggregate result. needs: [playwright-e2e] if: always() runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Require all Playwright shards to pass run: | result="${{ needs['playwright-e2e'].result }}" echo "Playwright web lane aggregate result: ${result}" # NOTE: the shards currently carry continue-on-error: true, so this # aggregate is 'success' even when a shard hit a known flake — the # gate passes today. Once continue-on-error is removed from the shards # (after the flakes are fixed) this same check enforces a real pass. if [ "${result}" = "success" ] || [ "${result}" = "skipped" ]; then echo "Playwright shards passed or were intentionally skipped." exit 0 fi echo "::error::Playwright web lane did not succeed (result=${result})." exit 1 coverage-gate: name: Coverage Gate (diff-cover >= 80%) needs: [changes, frontend-coverage, rust-core-coverage, rust-tauri-coverage] if: always() runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Verify coverage lanes completed run: | set -euo pipefail coverage="${{ needs.changes.outputs.coverage }}" frontend="${{ needs['frontend-coverage'].result }}" core="${{ needs['rust-core-coverage'].result }}" tauri="${{ needs['rust-tauri-coverage'].result }}" echo "Coverage needed: ${coverage}" echo "Frontend coverage result: ${frontend}" echo "Rust core coverage result: ${core}" echo "Rust Tauri coverage result: ${tauri}" if [ "${coverage}" != "true" ]; then echo "No coverage-relevant changes detected; skipping diff-cover." exit 0 fi for result in "${frontend}" "${core}" "${tauri}"; do if [ "${result}" != "success" ] && [ "${result}" != "skipped" ]; then echo "::error::A required coverage lane did not succeed or skip cleanly (result=${result})." exit 1 fi done - name: Checkout code if: needs.changes.outputs.coverage == 'true' uses: actions/checkout@v5 with: fetch-depth: 0 - name: Resolve coverage compare ref if: needs.changes.outputs.coverage == 'true' id: coverage-compare run: | set -euo pipefail if [ "${{ github.event_name }}" = "pull_request" ]; then git fetch origin "${{ github.base_ref }}" --depth=200 echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" else echo "ref=HEAD^" >> "$GITHUB_OUTPUT" fi - name: Setup Python if: needs.changes.outputs.coverage == 'true' uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install diff-cover if: needs.changes.outputs.coverage == 'true' run: pip install 'diff-cover>=9.2.0' - name: Download all lcov artifacts if: needs.changes.outputs.coverage == 'true' uses: actions/download-artifact@v5 with: path: lcov-artifacts pattern: lcov-* merge-multiple: false - name: List collected lcov files if: needs.changes.outputs.coverage == 'true' run: | set -euo pipefail find lcov-artifacts -type f -name '*.info' -print - name: Enforce >= 80% coverage on changed lines if: needs.changes.outputs.coverage == 'true' run: | set -euo pipefail mapfile -t LCOV_FILES < <(find lcov-artifacts -type f -name '*.info' | sort) if [ "${#LCOV_FILES[@]}" -eq 0 ]; then echo "::error::No lcov files found; coverage gate cannot run" exit 1 fi diff-cover "${LCOV_FILES[@]}" \ --compare-branch="${{ steps.coverage-compare.outputs.ref }}" \ --fail-under=80 \ --html-report diff-coverage.html \ --markdown-report diff-coverage.md - name: Upload diff-cover report if: always() && needs.changes.outputs.coverage == 'true' uses: actions/upload-artifact@v5 with: name: diff-coverage-report path: | diff-coverage.html diff-coverage.md retention-days: 14 if-no-files-found: warn pr-ci-gate: name: PR CI Gate needs: - changes - frontend-quality - i18n-coverage - rust-quality - build-playwright-e2e-artifact - frontend-coverage - rust-core-coverage - rust-tauri-coverage - rust-e2e - playwright-e2e-gate - coverage-gate if: always() runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Require PR CI jobs to pass run: | set -euo pipefail declare -A results=( ["Detect Changed Areas"]="${{ needs.changes.result }}" ["Frontend Quality"]="${{ needs['frontend-quality'].result }}" ["i18n Coverage"]="${{ needs['i18n-coverage'].result }}" ["Rust Quality"]="${{ needs['rust-quality'].result }}" ["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].result }}" ["Frontend Coverage"]="${{ needs['frontend-coverage'].result }}" ["Rust Core Coverage"]="${{ needs['rust-core-coverage'].result }}" ["Rust Tauri Coverage"]="${{ needs['rust-tauri-coverage'].result }}" ["Rust E2E"]="${{ needs['rust-e2e'].result }}" ["Playwright E2E Gate"]="${{ needs['playwright-e2e-gate'].result }}" ["Coverage Gate"]="${{ needs['coverage-gate'].result }}" ) failed=0 for name in "${!results[@]}"; do result="${results[$name]}" echo "${name}: ${result}" if [ "${result}" != "success" ] && [ "${result}" != "skipped" ]; then echo "::error::${name} did not pass (result=${result})." failed=1 fi done if [ "${failed}" -ne 0 ]; then exit 1 fi