Skip to content

Commit 6116739

Browse files
amilzdev-jodee
authored andcommitted
ci(coverage): progressive PR body coverage table (#65)
* ci(coverage): replace combiner workflow with progressive PR body updates Delete the fragile coverage.yml workflow_run combiner that relied on cross-workflow artifact downloads with continue-on-error. Each coverage job now updates its own row in a PR body table as it finishes, using a new update-coverage-row composite action. Changes: - Create .github/actions/update-coverage-row composite action - Parses LCOV files and updates a single PR body table row - Validates inputs, retries on failure with jitter - Uses env vars (not ${{ }}) in JS to prevent script injection - Add coverage row updates to rust.yml (Core, Indexer, Gateway, E2E) - Add coverage row updates to program-unit.yml (Escrow, Withdraw) - Delete coverage.yml (workflow_run combiner) PR body output (progressively filled as jobs finish): | Component | Lines Hit | Lines Total | Coverage | Artifact | |------------------|-----------|-------------|----------|---------------------------| | Core | 4,473 | 6,605 | 67.7% | rust-unit-coverage-reports | | Indexer | 6,700 | 10,380 | 64.5% | rust-unit-coverage-reports | | Gateway | 259 | 400 | 64.7% | rust-unit-coverage-reports | | Withdraw Program | 118 | 230 | 51.3% | unit-coverage-reports | | Escrow Program | 692 | 1,688 | 40.9% | unit-coverage-reports | | E2E Integration | 6,931 | 13,883 | 49.9% | e2e-coverage-reports | | **Total** | **19,173**| **33,186** | **57.8%**| | * ci: temporarily add branch to workflow triggers for testing * ci(coverage): address review feedback from greptile - Fix bc leading zero: use printf %.1f wrapper for coverage < 1% - Use throw instead of setFailed+return for fail-fast on bad input - Increase jitter range (1-6s) and retry attempts (5) with exponential backoff - Add row existence check after skeleton insertion to catch mismatches * ci(coverage): add init-coverage-table job and address review feedback - Add init-coverage-table job to rust.yml that inserts skeleton table into PR body immediately, before any coverage jobs run - Remove skeleton creation from composite action (only updates rows now) - Action waits/retries if table not yet initialized - Fix bc leading zero for coverage < 1% (use printf %.1f wrapper) - Use throw instead of setFailed+return for fail-fast on bad input - Increase retry attempts to 5 with exponential backoff * ci: remove temporary branch from workflow triggers
1 parent 57ee7c1 commit 6116739

4 files changed

Lines changed: 286 additions & 307 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
name: Update Coverage Row
2+
description: Parse LCOV file and update a single row in the PR body coverage table
3+
4+
inputs:
5+
component:
6+
description: 'Component name matching a table row (e.g. "Core", "Indexer", "Gateway")'
7+
required: true
8+
lcov-file:
9+
description: 'Path to the LCOV file'
10+
required: true
11+
artifact-name:
12+
description: 'Name of the uploaded artifact (used for link text)'
13+
required: true
14+
github-token:
15+
description: 'GitHub token with pull-requests:write'
16+
required: true
17+
18+
runs:
19+
using: composite
20+
steps:
21+
- name: Parse LCOV coverage
22+
id: parse
23+
shell: bash
24+
run: |
25+
LCOV_FILE="${{ inputs.lcov-file }}"
26+
if [ ! -f "$LCOV_FILE" ]; then
27+
echo "::error::LCOV file not found: $LCOV_FILE"
28+
exit 1
29+
fi
30+
if [ ! -s "$LCOV_FILE" ]; then
31+
echo "::error::LCOV file is empty: $LCOV_FILE"
32+
exit 1
33+
fi
34+
LINES_TOTAL=$(awk -F: '/^LF:/{sum+=$2} END{print sum+0}' "$LCOV_FILE")
35+
LINES_HIT=$(awk -F: '/^LH:/{sum+=$2} END{print sum+0}' "$LCOV_FILE")
36+
if [ "$LINES_TOTAL" -gt 0 ]; then
37+
COV=$(printf "%.1f" "$(echo "scale=4; $LINES_HIT * 100 / $LINES_TOTAL" | bc)")
38+
else
39+
COV="0.0"
40+
fi
41+
echo "lines_hit=$LINES_HIT" >> $GITHUB_OUTPUT
42+
echo "lines_total=$LINES_TOTAL" >> $GITHUB_OUTPUT
43+
echo "coverage=$COV" >> $GITHUB_OUTPUT
44+
echo "Parsed $LCOV_FILE: $LINES_HIT / $LINES_TOTAL = ${COV}%"
45+
46+
- name: Update PR body
47+
uses: actions/github-script@v7
48+
env:
49+
INPUT_COMPONENT: ${{ inputs.component }}
50+
INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }}
51+
INPUT_LINES_HIT: ${{ steps.parse.outputs.lines_hit }}
52+
INPUT_LINES_TOTAL: ${{ steps.parse.outputs.lines_total }}
53+
INPUT_COVERAGE: ${{ steps.parse.outputs.coverage }}
54+
INPUT_RUN_ID: ${{ github.run_id }}
55+
with:
56+
github-token: ${{ inputs.github-token }}
57+
script: |
58+
const component = process.env.INPUT_COMPONENT;
59+
const linesHit = parseInt(process.env.INPUT_LINES_HIT, 10);
60+
const linesTotal = parseInt(process.env.INPUT_LINES_TOTAL, 10);
61+
const coverage = process.env.INPUT_COVERAGE;
62+
const artifactName = process.env.INPUT_ARTIFACT_NAME;
63+
const runId = process.env.INPUT_RUN_ID;
64+
const repo = context.repo;
65+
const prNumber = context.payload.pull_request?.number;
66+
67+
if (!prNumber) {
68+
console.log('Not a PR event, skipping PR body update');
69+
return;
70+
}
71+
72+
if (isNaN(linesHit) || isNaN(linesTotal)) {
73+
throw new Error(`Invalid coverage data: hit=${process.env.INPUT_LINES_HIT}, total=${process.env.INPUT_LINES_TOTAL}`);
74+
}
75+
76+
const VALID_COMPONENTS = ['Core', 'Indexer', 'Gateway', 'Withdraw Program', 'Escrow Program', 'E2E Integration'];
77+
if (!VALID_COMPONENTS.includes(component)) {
78+
throw new Error(`Unknown component "${component}". Valid: ${VALID_COMPONENTS.join(', ')}`);
79+
}
80+
81+
const artifactLink = `[${artifactName}](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})`;
82+
const fmtHit = linesHit.toLocaleString('en-US');
83+
const fmtTotal = linesTotal.toLocaleString('en-US');
84+
85+
function recalcTotal(body) {
86+
const components = ['Core', 'Indexer', 'Gateway', 'Withdraw Program', 'Escrow Program', 'E2E Integration'];
87+
let totalHit = 0;
88+
let totalTotal = 0;
89+
let hasAny = false;
90+
91+
for (const comp of components) {
92+
const re = new RegExp(`\\| ${comp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\| ([\\d,]+) \\| ([\\d,]+) \\|`);
93+
const m = body.match(re);
94+
if (m) {
95+
totalHit += parseInt(m[1].replace(/,/g, ''), 10);
96+
totalTotal += parseInt(m[2].replace(/,/g, ''), 10);
97+
hasAny = true;
98+
}
99+
}
100+
101+
if (hasAny && totalTotal > 0) {
102+
const pct = ((totalHit / totalTotal) * 100).toFixed(1);
103+
const fH = totalHit.toLocaleString('en-US');
104+
const fT = totalTotal.toLocaleString('en-US');
105+
body = body.replace(
106+
/\| \*\*Total\*\* \|[^\n]+/,
107+
`| **Total** | **${fH}** | **${fT}** | **${pct}%** | |`
108+
);
109+
}
110+
return body;
111+
}
112+
113+
const MAX_ATTEMPTS = 5;
114+
115+
async function tryUpdate(attempt = 1) {
116+
// Jitter to reduce collision probability between concurrent workflows
117+
const jitter = Math.floor(Math.random() * 5000) + 1000;
118+
await new Promise(r => setTimeout(r, jitter));
119+
120+
const { data: pr } = await github.rest.pulls.get({
121+
...repo,
122+
pull_number: prNumber,
123+
});
124+
125+
let body = pr.body || '';
126+
127+
if (!body.includes('### Coverage Report')) {
128+
console.log('Coverage table not found in PR body. It may not have been initialized yet. Retrying...');
129+
if (attempt < MAX_ATTEMPTS) {
130+
await new Promise(r => setTimeout(r, 5000));
131+
return tryUpdate(attempt + 1);
132+
}
133+
throw new Error('Coverage table not found in PR body after all attempts');
134+
}
135+
136+
// Replace this component's row
137+
const rowRegex = new RegExp(
138+
`\\| ${component.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\|[^\\n]+`
139+
);
140+
const newRow = `| ${component} | ${fmtHit} | ${fmtTotal} | ${coverage}% | ${artifactLink} |`;
141+
142+
if (!rowRegex.test(body)) {
143+
throw new Error(`Component row "${component}" not found in PR body`);
144+
}
145+
146+
body = body.replace(rowRegex, newRow);
147+
148+
// Recalculate total
149+
body = recalcTotal(body);
150+
151+
// Update timestamp (matches both initial "Coverage runs in progress..." and subsequent "Last updated:" lines)
152+
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
153+
body = body.replace(
154+
/> (Coverage runs in progress\.\.\.|Last updated:.*)/,
155+
`> Last updated: ${ts} by ${component}`
156+
);
157+
158+
try {
159+
await github.rest.pulls.update({
160+
...repo,
161+
pull_number: prNumber,
162+
body,
163+
});
164+
console.log(`Updated PR #${prNumber} — ${component}: ${fmtHit}/${fmtTotal} (${coverage}%)`);
165+
} catch (e) {
166+
if (attempt < MAX_ATTEMPTS) {
167+
const backoff = 2000 * attempt + Math.floor(Math.random() * 2000);
168+
console.log(`PR body update failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${e.message}. Retrying in ${backoff}ms...`);
169+
await new Promise(r => setTimeout(r, backoff));
170+
return tryUpdate(attempt + 1);
171+
}
172+
throw e;
173+
}
174+
}
175+
176+
await tryUpdate();

0 commit comments

Comments
 (0)