diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 6dc3cc49e..df313fc4c 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -101,115 +101,101 @@ jobs: - 'app/scripts/e2e-web-*.sh' - 'scripts/ci-cancel-aware.sh' - frontend-quality: - name: Frontend Quality (typecheck, lint, format) + frontend-checks: + name: Frontend Checks (quality, i18n, docs, coverage) needs: [changes] - if: needs.changes.outputs.frontend == 'true' + if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.i18n == 'true' || needs.changes.outputs.docs == '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 - - docs-drift: - name: Docs Drift (generated architecture docs) - needs: [changes] - if: needs.changes.outputs.docs == 'true' - runs-on: ubuntu-22.04 - timeout-minutes: 10 + timeout-minutes: 60 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code # Pinned to the v5 commit SHA for supply-chain hardening (CodeRabbit - # review on #3892). The other lanes still use the @v5 tag; migrating - # them is a separate repo-wide change. + # review on #3892). The combined frontend lane keeps that hardening + # from the former docs-drift job. uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 1 + persist-credentials: false + + - name: Cache pnpm store + id: pnpm-cache + if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.i18n == 'true' + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install dependencies + if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.i18n == 'true' + run: pnpm install --frozen-lockfile + + - name: Save pnpm store cache + if: always() && (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.i18n == 'true') && steps.pnpm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} + + - name: Type check TypeScript files + if: needs.changes.outputs.frontend == 'true' + run: pnpm --filter openhuman-app compile + env: + NODE_ENV: test + + - name: Check Prettier formatting + if: needs.changes.outputs.frontend == 'true' + run: pnpm --filter openhuman-app format:check + env: + NODE_ENV: test + + - name: Run ESLint + if: needs.changes.outputs.frontend == 'true' + run: pnpm --filter openhuman-app lint + env: + NODE_ENV: test + + - name: Verify i18n coverage (missing / extra / drifted keys across locales) + if: needs.changes.outputs.i18n == 'true' + run: pnpm i18n:check # The generator + its tests use only Node built-ins (no workspace deps), - # so there is no `pnpm install` step — this lane stays fast. + # so docs-only changes still skip `pnpm install`. - name: Generator unit tests + if: needs.changes.outputs.docs == 'true' run: pnpm docs:test - name: Verify generated architecture docs are not stale + if: needs.changes.outputs.docs == 'true' run: pnpm docs:check + - name: Run Vitest with coverage + if: needs.changes.outputs.frontend == 'true' + run: pnpm test:coverage + working-directory: app + env: + NODE_ENV: test + + - name: Normalize lcov source paths to repo root + if: needs.changes.outputs.frontend == 'true' + 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 + if: needs.changes.outputs.frontend == 'true' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: lcov-frontend + path: app/coverage/lcov.info + retention-days: 7 + if-no-files-found: error + rust-quality: name: Rust Quality (fmt, clippy) needs: [changes] @@ -266,7 +252,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -285,7 +271,7 @@ jobs: - name: Restore cached Playwright E2E artifact id: playwright-artifact-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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') }} @@ -296,7 +282,7 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} @@ -332,73 +318,19 @@ jobs: - 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 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 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] @@ -453,7 +385,7 @@ jobs: CARGO_BUILD_JOBS: "1" - name: Upload core lcov - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: lcov-rust-core path: lcov-core.info @@ -511,7 +443,7 @@ jobs: - name: Cache CEF binary distribution id: cef-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | ~/Library/Caches/tauri-cef @@ -533,7 +465,7 @@ jobs: - name: Save CEF binary distribution cache if: always() && steps.cef-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | ~/Library/Caches/tauri-cef @@ -541,7 +473,7 @@ jobs: key: ${{ steps.cef-cache.outputs.cache-primary-key }} - name: Upload tauri lcov - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: lcov-rust-tauri path: lcov-tauri.info @@ -579,7 +511,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -600,7 +532,7 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} @@ -616,30 +548,23 @@ jobs: RUST_BACKTRACE: "1" playwright-e2e: - name: E2E (Playwright / web lane ${{ matrix.shard }}/4) - needs: [changes, frontend-quality, rust-quality, build-playwright-e2e-artifact] + name: E2E (Playwright / web lane) + needs: [changes, 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) + needs['build-playwright-e2e-artifact'].result == 'success' 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. + timeout-minutes: 90 + # Run the Playwright suite in one container to reduce PR CI fanout and stay + # under GitHub concurrency limits. This is intentionally slower than the + # previous shard matrix but avoids four separate runner/container slots. + # TODO(ci-flaky, #3615): several specs consistently hit 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 others. # 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 @@ -653,7 +578,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -665,13 +590,13 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Download Playwright E2E artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: name: e2e-playwright-linux-${{ github.run_id }} path: . @@ -689,81 +614,99 @@ jobs: - 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) + - name: Run Playwright web E2E suite env: OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace run: | mkdir -p "$OPENHUMAN_WORKSPACE" - bash app/scripts/e2e-web-session.sh \ - --shard=${{ matrix.shard }}/4 + bash app/scripts/e2e-web-session.sh - name: Upload Playwright E2E failure artifacts if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: - name: e2e-playwright-failure-logs-${{ github.run_id }}-shard${{ matrix.shard }} + name: e2e-playwright-failure-logs-${{ github.run_id }} 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] + pr-ci-gate: + name: PR CI Gate + needs: + - changes + - frontend-checks + - rust-quality + - build-playwright-e2e-artifact + - rust-core-coverage + - rust-tauri-coverage + - rust-e2e + - playwright-e2e if: always() runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - - name: Require all Playwright shards to pass + - name: Require PR CI jobs 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 + set -euo pipefail + declare -A results=( + ["Detect Changed Areas"]="${{ needs.changes.result }}" + ["Frontend Checks"]="${{ needs['frontend-checks'].result }}" + ["Rust Quality"]="${{ needs['rust-quality'].result }}" + ["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].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"]="${{ needs['playwright-e2e'].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 - 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 }}" + frontend_needed="${{ needs.changes.outputs.frontend }}" + core_needed="${{ needs.changes.outputs['rust-core'] }}" + tauri_needed="${{ needs.changes.outputs['rust-tauri'] }}" + frontend="${{ needs['frontend-checks'].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}" + echo "Frontend changed: ${frontend_needed} (job=${frontend})" + echo "Rust core changed: ${core_needed} (job=${core})" + echo "Rust Tauri changed: ${tauri_needed} (job=${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 + if [ "${frontend_needed}" = "true" ] && [ "${frontend}" != "success" ]; then + echo "::error::Frontend coverage was required but Frontend Checks did not succeed (result=${frontend})." + exit 1 + fi + if [ "${core_needed}" = "true" ] && [ "${core}" != "success" ]; then + echo "::error::Rust core coverage was required but the lane did not succeed (result=${core})." + exit 1 + fi + if [ "${tauri_needed}" = "true" ] && [ "${tauri}" != "success" ]; then + echo "::error::Rust Tauri coverage was required but the lane did not succeed (result=${tauri})." + exit 1 + fi - name: Checkout code if: needs.changes.outputs.coverage == 'true' @@ -797,7 +740,7 @@ jobs: - name: Download all lcov artifacts if: needs.changes.outputs.coverage == 'true' - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: path: lcov-artifacts pattern: lcov-* @@ -826,7 +769,7 @@ jobs: - name: Upload diff-cover report if: always() && needs.changes.outputs.coverage == 'true' - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: diff-coverage-report path: | @@ -834,54 +777,3 @@ jobs: diff-coverage.md retention-days: 14 if-no-files-found: warn - - pr-ci-gate: - name: PR CI Gate - needs: - - changes - - frontend-quality - - i18n-coverage - - docs-drift - - 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 }}" - ["Docs Drift"]="${{ needs['docs-drift'].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