Skip to content

Test Report

Test Report #38

Workflow file for this run

# =============================================================================
# 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@v7
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@v2
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@v7
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@v2
with:
number: ${{ github.event.workflow_run.pull_requests[0].number }}
header: ci-test-summary
message: ${{ steps.parse.outputs.comment }}