Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/actions/verify-metrics-snapshot/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ runs:
restore-keys: |
${{ inputs.artifact_key }}

# Create an empty stub so the diff artifact is always uploaded on PRs.
# The fan-in uses 1-to-1 presence of diff artifacts to detect infra failures
# (a missing diff_* artifact means this action never ran for that snapshot).
# The stub is overwritten by the compare step when a baseline exists.
- name: Create diff file stub
if: github.ref_name != 'main'
shell: bash
run: touch ./.metrics/diff_${{ inputs.snapshot }}.txt

- name: Calculate diff between the snapshots
id: compare-snapshots
if: ${{ (github.ref_name != 'main') && (steps.download-release-snapshot.outputs.cache-matched-key != '') }}
Expand All @@ -61,8 +70,10 @@ runs:
echo "has_diff=true" >> $GITHUB_OUTPUT
fi

# Always upload the diff artifact on PRs (even when empty / no baseline yet).
# Presence of this artifact in the fan-in proves this action ran for the snapshot.
- name: Upload the diff artifact
if: ${{ (github.ref_name != 'main') && (steps.compare-snapshots.outputs.has_diff == 'true') }}
if: github.ref_name != 'main'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: diff_${{ inputs.artifact_key }}
Expand Down
150 changes: 94 additions & 56 deletions .github/workflows/ci-summary-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,45 @@ on:
workflow_run:
workflows: ["CI Orchestrator"]
types: [completed]
workflow_dispatch:
inputs:
parent_run_id:
description: 'The Run ID of the CI workflow to pull artifacts from'
required: true
permissions:
contents: read
pull-requests: write
checks: write
jobs:
summary-report:
name: Summary Report
if: ${{ github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.head_branch == 'main' }}
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
(github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.head_branch == 'main'))
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
ref: ${{ github.event.repository.default_branch }}
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.repository.default_branch }}

- name: Resolve PR metadata from parent run
if: github.event_name == 'workflow_dispatch'
id: pr-metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RUN_DATA=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.event.inputs.parent_run_id }})
PR_NUMBER=$(echo "$RUN_DATA" | jq -r '.pull_requests[0].number')
HEAD_SHA=$(echo "$RUN_DATA" | jq -r '.pull_requests[0].head.sha')
if [ "$PR_NUMBER" == "null" ]; then
echo "Error: Could not find an associated PR for run ${{ github.event.inputs.parent_run_id }}."
exit 1
fi
echo "Found PR #$PR_NUMBER at SHA $HEAD_SHA"
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT
Comment thread
yurishkuro marked this conversation as resolved.
Outdated

- name: Install adm-zip
run: npm install adm-zip
Expand All @@ -44,9 +69,14 @@ jobs:
with:
script: |
const { owner, repo } = context.repo;
const workflowRunId = context.payload.workflow_run.id;
const isManual = context.eventName === 'workflow_dispatch';
const workflowRunId = isManual
? parseInt('${{ github.event.inputs.parent_run_id }}', 10)
: context.payload.workflow_run.id;

console.log(`Event: ${context.eventName}, Run ID for artifacts: ${workflowRunId}`);

// List all artifacts from the triggering workflow run
// List all artifacts from the target workflow run
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
Expand All @@ -71,32 +101,36 @@ jobs:
console.log(`Extracted artifact: ${artifact.name}`);
}

// Extract PR number (adapted from PDF logic)
// ── Resolve PR number ──
let prNumber = null;
const pullRequest = await github.rest.pulls.list({
owner,
repo,
head: `${context.payload.workflow_run.head_repository.full_name}:${context.payload.workflow_run.head_branch}`,
});

const prArtifactPath = path.join(process.env.GITHUB_WORKSPACE, '.artifacts', 'pr_number', 'pr_number.txt');
if (fs.existsSync(prArtifactPath)) {
prNumber = fs.readFileSync(prArtifactPath, 'utf8').trim();
console.log(`Found PR Number from artifact: ${prNumber}`);
if (isManual) {
prNumber = '${{ steps.pr-metadata.outputs.pr_number }}';
console.log(`Using PR number from resolved metadata: #${prNumber}`);
} else {
if (pullRequest.data.length > 0) {
prNumber = pullRequest.data[0].number;
const pullRequest = await github.rest.pulls.list({
owner,
repo,
head: `${context.payload.workflow_run.head_repository.full_name}:${context.payload.workflow_run.head_branch}`,
});

const prArtifactPath = path.join(process.env.GITHUB_WORKSPACE, '.artifacts', 'pr_number', 'pr_number.txt');
if (fs.existsSync(prArtifactPath)) {
prNumber = fs.readFileSync(prArtifactPath, 'utf8').trim();
console.log(`Found PR Number from artifact: ${prNumber}`);
} else {
// Fallback to commit SHA if needed
const commitSha = context.payload.workflow_run.head_sha;
const prsForCommit = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: commitSha,
});
if (prsForCommit.data.length > 0) {
prNumber = prsForCommit.data[0].number;
console.log(`Found PR via commit SHA: #${prNumber}`);
if (pullRequest.data.length > 0) {
prNumber = pullRequest.data[0].number;
} else {
const commitSha = context.payload.workflow_run.head_sha;
const prsForCommit = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: commitSha,
});
if (prsForCommit.data.length > 0) {
prNumber = prsForCommit.data[0].number;
console.log(`Found PR via commit SHA: #${prNumber}`);
}
}
}
}
Expand All @@ -107,6 +141,13 @@ jobs:
} else {
console.log('Could not determine PR number; PR comment and check runs will be skipped.');
}

// ── Resolve head SHA for check-runs ──
const headSha = isManual
? '${{ steps.pr-metadata.outputs.head_sha }}'
: context.payload.workflow_run.head_sha;
core.setOutput('head_sha', headSha);
core.setOutput('source_run_id', workflowRunId);
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -123,7 +164,8 @@ jobs:
run: |
bash ./scripts/e2e/metrics_summary.sh
env:
LINK_TO_ARTIFACT: "https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
LINK_TO_ARTIFACT: "https://github.com/${{ github.repository }}/actions/runs/${{ steps.download-artifacts.outputs.source_run_id }}"
SUMMARY_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"

- name: Upload metrics comparison report as artifact
if: success() && steps.download-artifacts.outputs.pr_number
Expand Down Expand Up @@ -166,6 +208,14 @@ jobs:
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
Expand All @@ -191,16 +241,16 @@ jobs:
id: coverage-gate
run: |
CURRENT="${{ steps.coverage.outputs.percentage }}"
MINIMUM=95.0
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 (matches codecov.yml target: 95%)
# Gate 1: absolute minimum threshold
MINIMUM=95.0
if (( $(echo "$CURRENT < $MINIMUM" | bc -l) )); then
failure_reasons+=("coverage ${CURRENT}% is below required minimum ${MINIMUM}%")
failure_reasons+=("coverage ${CURRENT}% is below minimum ${MINIMUM}%")
fi

# Gate 2: no regression vs main baseline
Expand Down Expand Up @@ -240,7 +290,6 @@ jobs:
- name: Post PR comment with combined summary
if: |
(steps.compare-metrics.outputs.TOTAL_CHANGES != '0' ||
Comment thread
yurishkuro marked this conversation as resolved.
Outdated
steps.compare-metrics.outputs.HAS_ERROR == 'true' ||
steps.coverage-gate.outputs.conclusion == 'failure') &&
steps.download-artifacts.outputs.pr_number
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3
Expand All @@ -256,26 +305,12 @@ jobs:
with:
script: |
const { owner, repo } = context.repo;
const headSha = context.payload.workflow_run.head_sha;
const totalChanges = parseInt('${{ steps.compare-metrics.outputs.TOTAL_CHANGES }}' || '0');
const hasError = '${{ steps.compare-metrics.outputs.HAS_ERROR }}' === 'true';
const artifactLink = 'https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}';

let conclusion, summary, text;

if (hasError) {
conclusion = 'failure';
summary = '❌ Metrics comparison failed';
text = 'ERROR: No summary files were generated. Expected at least 8 diff files from CI.\n\nThis indicates a failure in the E2E test execution or metrics collection process.\n\n➡️ [View full metrics file](' + artifactLink + ')';
} else if (totalChanges === 0) {
conclusion = 'success';
summary = '✅ No significant metric changes detected';
text = `Total changes across all snapshots: ${totalChanges}\n\n➡️ [View full metrics file](${artifactLink})`;
} else {
conclusion = 'failure';
summary = `❌ ${totalChanges} metric changes detected`;
text = `Total changes across all snapshots: ${totalChanges}\n\n➡️ [View full metrics file](${artifactLink})`;
}
const headSha = '${{ steps.download-artifacts.outputs.head_sha }}';
const conclusion = '${{ steps.compare-metrics.outputs.CONCLUSION }}';
const summary = '${{ steps.compare-metrics.outputs.SUMMARY }}';
const totalChanges = '${{ steps.compare-metrics.outputs.TOTAL_CHANGES }}' || '0';
const artifactLink = 'https://github.com/${{ github.repository }}/actions/runs/${{ steps.download-artifacts.outputs.source_run_id }}';
const summaryRunLink = 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}';
Comment thread
yurishkuro marked this conversation as resolved.
Outdated

await github.rest.checks.create({
owner,
Expand All @@ -287,7 +322,7 @@ jobs:
output: {
title: 'Metrics Comparison Result',
summary: summary,
text: text
text: `Total changes across all snapshots: ${totalChanges}\n\n➡️ [View CI artifacts](${artifactLink}) | [View Summary Report logs](${summaryRunLink})`
}
});
env:
Expand All @@ -299,10 +334,11 @@ jobs:
with:
script: |
const { owner, repo } = context.repo;
const headSha = context.payload.workflow_run.head_sha;
const headSha = '${{ steps.download-artifacts.outputs.head_sha }}';
const conclusion = '${{ steps.coverage-gate.outputs.conclusion }}';
const summary = '${{ steps.coverage-gate.outputs.summary }}';
const artifactLink = 'https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}';
const artifactLink = 'https://github.com/${{ github.repository }}/actions/runs/${{ steps.download-artifacts.outputs.source_run_id }}';
const summaryRunLink = 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}';

await github.rest.checks.create({
owner,
Expand All @@ -314,7 +350,7 @@ jobs:
output: {
title: 'Coverage Gate',
summary: conclusion === 'success' ? `✅ ${summary}` : `❌ ${summary}`,
text: `➡️ [View CI run](${artifactLink})`
text: `➡️ [View CI artifacts](${artifactLink}) | [View Summary Report logs](${summaryRunLink})`
}
});
env:
Expand All @@ -324,12 +360,14 @@ jobs:
# 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
Expand Down
7 changes: 4 additions & 3 deletions docs/adr/004-migrating-coverage-gating-to-github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ Key design choices:

- **Single job for both PR analysis and baseline updates**: the job runs for `pull_request` events and for pushes to `main`. PR-specific steps (metrics comparison, coverage gate, PR comment, check runs) are conditioned on `pr_number` being set; baseline-save steps are conditioned on `head_branch == 'main'`. Coverage computation runs unconditionally so both flows share the same merge-and-measure logic. This follows the same pattern as the existing metrics snapshot baseline.

- **Coverage policy**: two gates matching `.codecov.yml`:
1. Absolute floor: fail if total coverage drops below 95%.
2. No regression: fail if total coverage dropped compared to the `main` baseline.
- **Coverage policy**: two independent gates are applied to the filtered merged profile:
1. **Absolute floor**: total coverage must be ≥ 95%, matching the Codecov project target.
2. **No regression**: total coverage must not drop compared to the `main` baseline.
The merged profile is filtered before computing coverage using the same `ignore:` patterns from `.codecov.yml` (generated files, mocks, integration test infrastructure), read at runtime so both tools stay in sync from a single source of truth. Without this filtering, `go tool cover -func` on merged profiles yields ~42% because it counts all instrumented packages including generated code with zero coverage, far below the meaningful 95% Codecov reports per flag.

## Implementation

Expand Down
Loading
Loading