Test Report #48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================= | |
| # Test Report Workflow - Publish test results to PR | |
| # Trigger: After CI workflow completes | |
| # Features: | |
| # 1. Download pytest artifacts from CI run | |
| # 2. Publish test report as GitHub Check Run (visible in PR Checks tab) | |
| # 3. Post/update a sticky comment on the PR with detailed test results | |
| # Strategy: PR reports Python 3.8 only, main push reports full matrix | |
| # Security: | |
| # - workflow_run always runs in base repository context | |
| # - Checkout uses base repo only (never fork code) to prevent code injection | |
| # - All untrusted inputs passed via env vars (never ${{ }} in run scripts) | |
| # - Permissions scoped to minimum required | |
| # ============================================================================= | |
| name: Test Report | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: | |
| - completed | |
| permissions: | |
| checks: write # Create/update Check Run | |
| pull-requests: write # Post sticky comment on PR | |
| actions: read # Read workflow run info and download artifacts | |
| contents: read # Checkout base repository code | |
| jobs: | |
| report: | |
| name: Publish Test Results | |
| runs-on: ubuntu-latest | |
| if: | | |
| github.event.workflow_run.conclusion == 'success' || | |
| github.event.workflow_run.conclusion == 'failure' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # PR: report 3.8 only; main push: full matrix | |
| python-version: ${{ github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && fromJSON('["3.8", "3.9", "3.10", "3.11", "3.12"]') || fromJSON('["3.8"]') }} | |
| steps: | |
| # Checkout BASE repo only (not fork) - dorny/test-reporter needs a git | |
| # repo for path resolution, but does not need the actual PR code. | |
| # SECURITY: Never checkout fork code in a privileged workflow_run context. | |
| - name: Checkout base repository | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: main | |
| fetch-depth: 1 | |
| - name: Download test results | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pytest-results-${{ matrix.python-version }} | |
| path: test-results | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| - name: Publish Test Report (Check Run) | |
| uses: dorny/test-reporter@v3 | |
| if: always() | |
| with: | |
| name: PyTest Results (Python ${{ matrix.python-version }}) | |
| path: test-results/pytest-report.xml | |
| reporter: java-junit | |
| fail-on-error: false | |
| fail-on-empty: true | |
| list-suites: all | |
| list-tests: all | |
| max-annotations: 10 | |
| # Sticky comment with actual test results embedded in PR | |
| comment: | |
| name: Update PR Comment | |
| runs-on: ubuntu-latest | |
| needs: report | |
| if: | | |
| always() && | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.pull_requests[0] | |
| steps: | |
| - name: Download test results | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pytest-results-3.8 | |
| path: test-results | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| # SECURITY: All workflow_run event data is passed via env vars, | |
| # never interpolated with ${{ }} inside run scripts to prevent | |
| # code injection via attacker-controlled branch names etc. | |
| - name: Parse test results and build comment | |
| id: parse | |
| env: | |
| WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} | |
| WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }} | |
| WORKFLOW_BRANCH: ${{ github.event.workflow_run.head_branch }} | |
| WORKFLOW_RUN_URL: ${{ github.event.workflow_run.html_url }} | |
| run: | | |
| python3 << 'PYEOF' | |
| import xml.etree.ElementTree as ET | |
| import os | |
| xml_path = "test-results/pytest-report.xml" | |
| conclusion = os.environ["WORKFLOW_CONCLUSION"] | |
| sha = os.environ["WORKFLOW_SHA"] | |
| branch = os.environ["WORKFLOW_BRANCH"] | |
| run_url = os.environ["WORKFLOW_RUN_URL"] | |
| icon = "✅" if conclusion == "success" else "❌" | |
| # Parse JUnit XML | |
| tree = ET.parse(xml_path) | |
| root = tree.getroot() | |
| # Collect suite-level stats | |
| total_tests = 0 | |
| total_failures = 0 | |
| total_errors = 0 | |
| total_skipped = 0 | |
| total_time = 0.0 | |
| # Collect individual test cases | |
| failed_cases = [] | |
| passed_cases = [] | |
| skipped_cases = [] | |
| errored_cases = [] | |
| for suite in root.iter("testsuite"): | |
| total_tests += int(suite.get("tests", 0)) | |
| total_failures += int(suite.get("failures", 0)) | |
| total_errors += int(suite.get("errors", 0)) | |
| total_skipped += int(suite.get("skipped", 0)) | |
| total_time += float(suite.get("time", 0)) | |
| for tc in root.iter("testcase"): | |
| name = tc.get("name", "unknown") | |
| classname = tc.get("classname", "") | |
| time_s = float(tc.get("time", 0)) | |
| display = f"{classname}::{name}" if classname else name | |
| failure = tc.find("failure") | |
| error = tc.find("error") | |
| skip = tc.find("skipped") | |
| if failure is not None: | |
| msg = (failure.get("message") or failure.text or "")[:120].replace("\n", " ") | |
| failed_cases.append((display, time_s, msg)) | |
| elif error is not None: | |
| msg = (error.get("message") or error.text or "")[:120].replace("\n", " ") | |
| errored_cases.append((display, time_s, msg)) | |
| elif skip is not None: | |
| skipped_cases.append((display, time_s)) | |
| else: | |
| passed_cases.append((display, time_s)) | |
| total_passed = total_tests - total_failures - total_errors - total_skipped | |
| # Build markdown comment | |
| lines = [] | |
| lines.append(f"## {icon} CI Test Results — Python 3.8\n") | |
| # Summary table | |
| pass_icon = "✅" if total_passed == total_tests else "⚠️" | |
| lines.append("| Metric | Value |") | |
| lines.append("|--------|-------|") | |
| lines.append(f"| **Tests** | {total_tests} |") | |
| lines.append(f"| **Passed** | {pass_icon} {total_passed} |") | |
| if total_failures > 0: | |
| lines.append(f"| **Failed** | ❌ {total_failures} |") | |
| if total_errors > 0: | |
| lines.append(f"| **Errors** | ❌ {total_errors} |") | |
| if total_skipped > 0: | |
| lines.append(f"| **Skipped** | ⏭️ {total_skipped} |") | |
| lines.append(f"| **Duration** | {total_time:.2f}s |") | |
| lines.append(f"| **Commit** | `{sha[:7]}` |") | |
| lines.append(f"| **Branch** | `{branch}` |") | |
| lines.append("") | |
| # Failed tests detail | |
| if failed_cases: | |
| lines.append("<details><summary>❌ Failed Tests</summary>\n") | |
| lines.append("| Test | Time | Message |") | |
| lines.append("|------|------|---------|") | |
| for name, t, msg in failed_cases: | |
| lines.append(f"| `{name}` | {t:.3f}s | {msg} |") | |
| lines.append("\n</details>\n") | |
| # Error tests detail | |
| if errored_cases: | |
| lines.append("<details><summary>💥 Errored Tests</summary>\n") | |
| lines.append("| Test | Time | Message |") | |
| lines.append("|------|------|---------|") | |
| for name, t, msg in errored_cases: | |
| lines.append(f"| `{name}` | {t:.3f}s | {msg} |") | |
| lines.append("\n</details>\n") | |
| # Passed tests (collapsed) | |
| if passed_cases: | |
| lines.append(f"<details><summary>✅ Passed Tests ({len(passed_cases)})</summary>\n") | |
| lines.append("| Test | Time |") | |
| lines.append("|------|------|") | |
| for name, t in passed_cases: | |
| lines.append(f"| `{name}` | {t:.3f}s |") | |
| lines.append("\n</details>\n") | |
| lines.append(f"[View full CI run]({run_url})") | |
| lines.append("") | |
| lines.append("---") | |
| lines.append("_🤖 This comment is automatically updated on each push._") | |
| body = "\n".join(lines) | |
| # Write to GitHub Actions output (multiline) | |
| with open(os.environ["GITHUB_OUTPUT"], "a") as f: | |
| f.write(f"comment<<ENDOFCOMMENT\n{body}\nENDOFCOMMENT\n") | |
| PYEOF | |
| - name: Post sticky PR comment | |
| uses: marocchino/sticky-pull-request-comment@v3 | |
| with: | |
| number: ${{ github.event.workflow_run.pull_requests[0].number }} | |
| header: ci-test-summary | |
| message: ${{ steps.parse.outputs.comment }} |