diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..5a96b778 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,282 @@ +name: Code Coverage + +on: + pull_request: + branches: [main] + + push: + branches: [main] + + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + pages: write + id-token: write + +defaults: + run: + shell: bash + +jobs: + coverage: + name: Generate code coverage with cargo-llvm-cov + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + # Fetch enough history for diff against base branch + fetch-depth: 0 + + - name: Cache cargo builds + uses: Swatinem/rust-cache@v2 + + - name: Install Rust toolchain with llvm-tools-preview + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Install Linux deps for winit/wgpu + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config libx11-dev libxcb1-dev libxcb-render0-dev \ + libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev \ + libwayland-dev libudev-dev \ + libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools + + - name: Install Linux deps for audio + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev + + - name: Configure Vulkan (Ubuntu) + run: | + echo "WGPU_BACKEND=vulkan" >> "$GITHUB_ENV" + # Prefer Mesa's software Vulkan (lavapipe) for headless availability + echo "VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json" >> "$GITHUB_ENV" + vulkaninfo --summary || true + + - name: Generate full coverage JSON + run: | + cargo llvm-cov --workspace \ + --features lambda-rs/with-vulkan,lambda-rs/audio-output-device \ + --json \ + --output-path coverage.json + + - name: Generate HTML coverage report + run: | + cargo llvm-cov --workspace \ + --features lambda-rs/with-vulkan,lambda-rs/audio-output-device \ + --html \ + --output-dir coverage-html \ + --no-run + + - name: Get changed files in PR + if: github.event_name == 'pull_request' + id: changed + run: | + # Use GitHub's provided base/head SHAs for accurate diff + base_sha="${{ github.event.pull_request.base.sha }}" + head_sha="${{ github.event.pull_request.head.sha }}" + changed_files=$(git diff --name-only "$base_sha" "$head_sha" -- '*.rs' | tr '\n' ' ') + echo "files=$changed_files" >> "$GITHUB_OUTPUT" + + - name: Generate coverage report data + id: cov + run: | + # Extract total coverage and round to 2 decimal places + pct_raw=$(jq -r '(.data[0].totals.lines.percent // 0)' coverage.json) + pct=$(printf "%.2f" "$pct_raw") + covered=$(jq -r '(.data[0].totals.lines.covered // 0)' coverage.json) + total=$(jq -r '(.data[0].totals.lines.count // 0)' coverage.json) + echo "pct=$pct" >> "$GITHUB_OUTPUT" + echo "covered=$covered" >> "$GITHUB_OUTPUT" + echo "total=$total" >> "$GITHUB_OUTPUT" + + # Extract per-file coverage as JSON for changed files + jq -r '.data[0].files[] | "\(.filename)|\(.summary.lines.percent // 0)|\(.summary.lines.covered // 0)|\(.summary.lines.count // 0)"' coverage.json > file_coverage.txt + + - name: Build PR coverage comment + if: github.event_name == 'pull_request' + id: comment + env: + CHANGED_FILES: ${{ steps.changed.outputs.files }} + RUN_ID: ${{ github.run_id }} + REPO: ${{ github.repository }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Base URL for GitHub Pages coverage (from main branch) + PAGES_BASE="https://lambda-sh.github.io/lambda/coverage" + # Get current timestamp in UTC + TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC") + # Short commit SHA for display + SHORT_SHA="${COMMIT_SHA:0:7}" + + # Build the comment body + { + echo "### โœ… Coverage Report" + echo "" + echo "๐Ÿ“Š [View Full HTML Report](https://github.com/${REPO}/actions/runs/${RUN_ID}) (download artifact)" + echo "" + echo "#### Overall Coverage" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| **Total Line Coverage** | ${{ steps.cov.outputs.pct }}% |" + echo "| **Lines Covered** | ${{ steps.cov.outputs.covered }} / ${{ steps.cov.outputs.total }} |" + echo "" + + # Calculate coverage for changed files + if [ -n "$CHANGED_FILES" ]; then + echo "#### Changed Files in This PR" + echo "" + echo "| File | Coverage | Lines |" + echo "|------|----------|-------|" + + pr_covered=0 + pr_total=0 + + for file in $CHANGED_FILES; do + # Find this file in coverage data (match by filename ending) + match=$(grep -E "/${file}\|" file_coverage.txt || grep -E "^${file}\|" file_coverage.txt || true) + if [ -n "$match" ]; then + file_pct=$(echo "$match" | cut -d'|' -f2) + file_covered=$(echo "$match" | cut -d'|' -f3) + file_total=$(echo "$match" | cut -d'|' -f4) + # Format percentage to 2 decimal places + file_pct_fmt=$(printf "%.2f" "$file_pct") + # Create HTML filename (replace / with path structure, add .html) + html_file=$(echo "$file" | sed 's|/|/|g').html + echo "| [\`${file}\`](${PAGES_BASE}/${html_file}) | ${file_pct_fmt}% | ${file_covered}/${file_total} |" + pr_covered=$((pr_covered + file_covered)) + pr_total=$((pr_total + file_total)) + else + echo "| \`${file}\` | N/A | (no coverage data) |" + fi + done + + echo "" + if [ "$pr_total" -gt 0 ]; then + pr_pct=$(echo "scale=2; $pr_covered * 100 / $pr_total" | bc) + echo "**PR Files Coverage:** ${pr_pct}% (${pr_covered}/${pr_total} lines)" + fi + else + echo "*No Rust files changed in this PR.*" + fi + + echo "" + echo "---" + echo "*Generated by [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) ยท [Latest main coverage](${PAGES_BASE})*" + echo "" + echo "Last updated: ${TIMESTAMP} ยท Commit: [\`${SHORT_SHA}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + } > comment_body.md + + # Store as output (handle multiline) + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Find existing coverage comment + if: github.event_name == 'pull_request' + id: find_comment + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + }); + + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('### โœ… Coverage Report') + ); + + return botComment ? botComment.id : null; + result-encoding: string + + - name: Create or update PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('comment_body.md', 'utf8'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const existingCommentId = ${{ steps.find_comment.outputs.result }}; + + if (existingCommentId) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingCommentId, + body, + }); + console.log(`Updated existing comment ${existingCommentId}`); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + console.log('Created new coverage comment'); + } + + - name: Upload coverage HTML as artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-html-report + path: coverage-html/ + retention-days: 30 + + - name: Upload coverage JSON as artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-json + path: coverage.json + retention-days: 30 + + # Deploy HTML report to GitHub Pages on pushes to main + deploy-coverage: + name: Deploy coverage to GitHub Pages + needs: coverage + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Download coverage HTML artifact + uses: actions/download-artifact@v4 + with: + name: coverage-html-report + path: coverage-html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload to GitHub Pages + uses: actions/upload-pages-artifact@v3 + with: + path: coverage-html + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4