Skip to content

CI Summary Report

CI Summary Report #81

# Copyright (c) 2026 The Jaeger Authors.
# SPDX-License-Identifier: Apache-2.0
# CI Summary Report: fan-in workflow that posts a consolidated PR comment with
# performance metrics comparison and code coverage gating.
#
# Design: docs/adr/004-migrating-coverage-gating-to-github-actions.md
#
# This workflow is triggered by workflow_run (on "CI Orchestrator" completion)
# so that it can write PR comments from fork PRs while retaining write permissions.
# The CI Orchestrator completes only after all stages (unit tests + E2E) finish,
# ensuring all coverage-* artifacts are available when this job runs.
name: CI Summary Report
on:
workflow_run:
workflows: ["CI Orchestrator"]
types: [completed]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to generate summary report for'
required: true
permissions:
contents: read
pull-requests: write
checks: write
jobs:
summary-report:
name: Summary Report
if: |
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.repository.default_branch }}
- name: Debug event payload
run: cat "$GITHUB_EVENT_PATH" | jq .
# Resolve the source run ID from either trigger, then fetch PR metadata.
# For workflow_run events, PR metadata comes from the event payload
# (the REST API returns empty .pull_requests[] for cross-repo fork PRs).
- name: Resolve source run
id: source-run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
PR_NUMBER="${{ github.event.inputs.pr_number }}"
# Get the PR's head SHA to find the CI Orchestrator run
HEAD_SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha')
# Find the latest successful CI Orchestrator run for this commit
RUN_ID=$(gh api "repos/${{ github.repository }}/actions/workflows/ci-orchestrator.yml/runs?head_sha=$HEAD_SHA&status=success&per_page=1" \
--jq '.workflow_runs[0].id')
if [ -z "$RUN_ID" ] || [ "$RUN_ID" == "null" ]; then
echo "::error::No successful CI Orchestrator run found for PR #$PR_NUMBER (SHA $HEAD_SHA)."
exit 1
fi
echo "Found CI Orchestrator run $RUN_ID for PR #$PR_NUMBER"
else
RUN_ID="${{ github.event.workflow_run.id }}"
PR_NUMBER="${{ github.event.workflow_run.pull_requests[0].number }}"
HEAD_SHA="${{ github.event.workflow_run.pull_requests[0].head.sha || github.event.workflow_run.head_sha }}"
# workflow_run.pull_requests is empty for fork PRs (renovate-bot, dependabot, etc.).
# Fall back to searching for a PR matching the head branch.
if [ -z "$PR_NUMBER" ] && [ "${{ github.event.workflow_run.event }}" == "pull_request" ]; then
PR_NUMBER=$(gh pr list \
--repo "${{ github.repository }}" \
--search "head:${{ github.event.workflow_run.head_branch }}" \
--json number,headRefOid \
--jq ".[] | select(.headRefOid == \"$HEAD_SHA\") | .number")
echo "Resolved PR #$PR_NUMBER from head branch (fork PR fallback)"
fi
fi
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
echo "source_run_url=https://github.com/${{ github.repository }}/actions/runs/$RUN_ID" >> $GITHUB_OUTPUT
echo "summary_run_url=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT
if [ -n "$PR_NUMBER" ]; then
echo "Found PR #$PR_NUMBER at SHA $HEAD_SHA"
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
else
echo "No associated PR found for run $RUN_ID; PR comment will be skipped."
fi
# Download all artifacts from the source CI run.
# Each artifact is extracted into .artifacts/<artifact-name>/.
- name: Download all artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh run download "${{ steps.source-run.outputs.run_id }}" \
--repo "${{ github.repository }}" --dir .artifacts
- name: Install dependencies
if: success() && steps.source-run.outputs.head_sha
run: |
python3 -m pip install prometheus-client
- name: Compare metrics and generate summary
if: success() && steps.source-run.outputs.head_sha
id: compare-metrics
shell: bash
run: |
bash ./scripts/e2e/metrics_summary.sh
env:
LINK_TO_ARTIFACT: ${{ steps.source-run.outputs.source_run_url }}
SUMMARY_RUN_URL: ${{ steps.source-run.outputs.summary_run_url }}
- name: Upload metrics comparison report as artifact
if: success() && steps.source-run.outputs.head_sha
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: metrics-comparison-report
path: ./.artifacts/combined_summary.md
retention-days: 30
# =========================================================================
# Coverage gating: merge all coverage-* profiles, compare against baseline,
# and fail the check if coverage drops beyond the threshold.
# See: docs/adr/004-migrating-coverage-gating-to-github-actions.md (Step 4c)
# =========================================================================
- name: Set up Go for coverage tools
if: success()
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: 1.26.x
cache-dependency-path: |
./go.sum
./internal/tools/go.sum
- name: Install coverage tools
if: success()
run: make install-coverage-tools
- name: Merge coverage profiles
if: success()
id: merge-coverage
run: |
mapfile -t COVER_FILES < <(find .artifacts -path "*/coverage-*/*.out" -type f)
if [ ${#COVER_FILES[@]} -eq 0 ]; then
echo "No coverage files found; skipping coverage gate."
echo "skipped=true" >> "$GITHUB_OUTPUT"
else
echo "Merging ${#COVER_FILES[@]} coverage profiles"
./.tools/gocovmerge "${COVER_FILES[@]}" > .artifacts/merged-coverage.out
echo "skipped=false" >> "$GITHUB_OUTPUT"
fi
- name: Filter excluded paths from merged coverage
if: success() && steps.merge-coverage.outputs.skipped == 'false'
run: |
# Applies the same exclusions as .codecov.yml (single source of truth).
# filter_coverage.py modifies the file in-place.
python3 scripts/e2e/filter_coverage.py .artifacts/merged-coverage.out
echo "Coverage lines after filtering: $(wc -l < .artifacts/merged-coverage.out)"
- name: Calculate current coverage percentage
if: success() && steps.merge-coverage.outputs.skipped == 'false'
id: coverage
run: |
PCT=$(go tool cover -func=.artifacts/merged-coverage.out \
| grep "^total:" | awk '{print $3}' | tr -d '%')
echo "percentage=${PCT}" >> "$GITHUB_OUTPUT"
echo "${PCT}" > .artifacts/current-coverage.txt
echo "Current coverage: ${PCT}%"
- name: Restore baseline coverage from main
if: success() && steps.merge-coverage.outputs.skipped == 'false'
id: restore-baseline
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: .artifacts/baseline-coverage.txt
key: coverage-baseline
restore-keys: |
coverage-baseline
- name: Gate on coverage regression
if: success() && steps.merge-coverage.outputs.skipped == 'false'
id: coverage-gate
run: |
CURRENT="${{ steps.coverage.outputs.percentage }}"
BASELINE_MSG="(no baseline yet)"
failure_reasons=()
if [ -z "$CURRENT" ]; then
failure_reasons+=("coverage percentage is empty; go tool cover may have failed")
else
# Gate 1: absolute minimum threshold
MINIMUM=95.0
if (( $(echo "$CURRENT < $MINIMUM" | bc -l) )); then
failure_reasons+=("coverage ${CURRENT}% is below minimum ${MINIMUM}%")
fi
# Gate 2: no regression vs main baseline
if [ -f .artifacts/baseline-coverage.txt ]; then
BASELINE=$(cat .artifacts/baseline-coverage.txt)
if [ -z "$BASELINE" ]; then
failure_reasons+=("baseline coverage file is empty; cannot perform regression check")
else
BASELINE_MSG="(baseline ${BASELINE}%)"
if (( $(echo "$CURRENT < $BASELINE" | bc -l) )); then
failure_reasons+=("coverage dropped from ${BASELINE}% to ${CURRENT}%")
fi
fi
fi
fi
if [ ${#failure_reasons[@]} -gt 0 ]; then
msg=$(IFS='; '; echo "${failure_reasons[*]}")
echo "conclusion=failure" >> "$GITHUB_OUTPUT"
echo "summary=${msg}" >> "$GITHUB_OUTPUT"
echo "::error::${msg}"
else
echo "conclusion=success" >> "$GITHUB_OUTPUT"
echo "summary=Coverage ${CURRENT}% ${BASELINE_MSG}" >> "$GITHUB_OUTPUT"
fi
- name: Append coverage section to combined summary
if: success() && steps.source-run.outputs.head_sha && steps.merge-coverage.outputs.skipped == 'false'
run: |
{
echo ""
echo "## Code Coverage"
echo ""
echo "${{ steps.coverage-gate.outputs.summary }}"
} >> ./.artifacts/combined_summary.md
- name: Post PR comment with combined summary
if: |
(steps.compare-metrics.outputs.CONCLUSION == 'failure' ||
steps.compare-metrics.outputs.TOTAL_CHANGES != '0' ||
steps.coverage-gate.outputs.conclusion == 'failure') &&
steps.source-run.outputs.pr_number
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3
with:
file-path: ./.artifacts/combined_summary.md
github-token: '${{ secrets.GITHUB_TOKEN }}'
comment-tag: "## CI Summary Report"
pr-number: ${{ steps.source-run.outputs.pr_number }}
- name: Create check runs
if: success() && steps.source-run.outputs.head_sha
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { owner, repo } = context.repo;
const headSha = '${{ steps.source-run.outputs.head_sha }}';
const artifactLink = '${{ steps.source-run.outputs.source_run_url }}';
const summaryRunLink = '${{ steps.source-run.outputs.summary_run_url }}';
const links = `➡️ [View CI artifacts](${artifactLink}) | [View Summary Report logs](${summaryRunLink})`;
// Metrics comparison check
const metricsConclusion = '${{ steps.compare-metrics.outputs.CONCLUSION }}';
const metricsSummary = '${{ steps.compare-metrics.outputs.SUMMARY }}';
const totalChanges = '${{ steps.compare-metrics.outputs.TOTAL_CHANGES }}' || '0';
await github.rest.checks.create({
owner, repo, head_sha: headSha,
name: 'Metrics Comparison',
status: 'completed',
conclusion: metricsConclusion,
output: {
title: 'Metrics Comparison Result',
summary: metricsSummary,
text: `Total changes across all snapshots: ${totalChanges}\n\n${links}`
}
});
// Coverage gate check — always created so it works as a required status check.
// When no coverage data was collected, report success with a "skipped" note.
const coverageSkipped = '${{ steps.merge-coverage.outputs.skipped }}' !== 'false';
const coverageConclusion = coverageSkipped ? 'success' : '${{ steps.coverage-gate.outputs.conclusion }}';
const coverageSummary = coverageSkipped
? 'No coverage profiles found; coverage gate skipped.'
: '${{ steps.coverage-gate.outputs.summary }}';
await github.rest.checks.create({
owner, repo, head_sha: headSha,
name: 'Coverage Gate',
status: 'completed',
conclusion: coverageConclusion,
output: {
title: 'Coverage Gate',
summary: coverageSkipped ? `⏭️ ${coverageSummary}` : (coverageConclusion === 'success' ? `✅ ${coverageSummary}` : `❌ ${coverageSummary}`),
text: links
}
});
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Save baseline coverage on main-branch runs so PRs can compare against it.
# Uses the same actions/cache pattern as .github/actions/verify-metrics-snapshot/action.yaml.
- name: Save coverage baseline on main branch
if: |
github.event_name == 'workflow_run' &&
github.event.workflow_run.head_branch == 'main' &&
steps.merge-coverage.outputs.skipped == 'false'
run: cp .artifacts/current-coverage.txt .artifacts/baseline-coverage.txt
- name: Cache coverage baseline
if: |
github.event_name == 'workflow_run' &&
github.event.workflow_run.head_branch == 'main' &&
steps.merge-coverage.outputs.skipped == 'false'
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: .artifacts/baseline-coverage.txt
key: coverage-baseline_${{ github.run_id }}