CI Report #256
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
| name: CI Report | |
| on: | |
| workflow_run: | |
| workflows: ['CI'] | |
| types: [completed] | |
| permissions: | |
| actions: read | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| pr-report: | |
| name: PR Report | |
| if: >- | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.conclusion != 'cancelled' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Download PR metadata | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: ${{ github.event.workflow_run.id }}, | |
| }); | |
| const meta = artifacts.data.artifacts.find(a => a.name === 'pr-meta'); | |
| if (!meta) { | |
| core.setFailed('pr-meta artifact not found — skipping report'); | |
| return; | |
| } | |
| const zip = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: meta.id, | |
| archive_format: 'zip', | |
| }); | |
| const dest = path.join(process.env.RUNNER_TEMP, 'pr-meta'); | |
| fs.mkdirSync(dest, { recursive: true }); | |
| fs.writeFileSync(path.join(dest, 'pr-meta.zip'), Buffer.from(zip.data)); | |
| - name: Extract PR metadata | |
| id: meta | |
| shell: bash | |
| run: | | |
| cd "$RUNNER_TEMP/pr-meta" | |
| unzip -o pr-meta.zip | |
| PR_NUMBER=$(cat pr-number | tr -d '[:space:]') | |
| if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then | |
| echo "::error::Invalid PR number: '$PR_NUMBER'" | |
| exit 1 | |
| fi | |
| echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" | |
| echo "quality=$(cat quality-result | tr -d '[:space:]')" >> "$GITHUB_OUTPUT" | |
| echo "tests=$(cat tests-result | tr -d '[:space:]')" >> "$GITHUB_OUTPUT" | |
| - name: Download test reports | |
| id: download-test-reports | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: ${{ github.event.workflow_run.id }}, | |
| }); | |
| const reports = artifacts.data.artifacts.find(a => a.name === 'test-reports'); | |
| if (!reports) { | |
| core.warning('test-reports artifact not found'); | |
| return; | |
| } | |
| const zip = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: reports.id, | |
| archive_format: 'zip', | |
| }); | |
| const dest = path.join(process.env.RUNNER_TEMP, 'test-reports'); | |
| fs.mkdirSync(dest, { recursive: true }); | |
| fs.writeFileSync(path.join(dest, 'test-reports.zip'), Buffer.from(zip.data)); | |
| - name: Extract test reports | |
| if: steps.download-test-reports.outcome == 'success' | |
| shell: bash | |
| run: | | |
| cd "$RUNNER_TEMP/test-reports" | |
| unzip -o test-reports.zip || true | |
| - name: Fetch cross-platform job results | |
| id: jobs | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 | |
| with: | |
| script: | | |
| const jobs = await github.rest.actions.listJobsForWorkflowRun({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: ${{ github.event.workflow_run.id }}, | |
| per_page: 50, | |
| }); | |
| const results = {}; | |
| for (const job of jobs.data.jobs) { | |
| if (job.name.includes('ubuntu')) results.ubuntu = job.conclusion || 'pending'; | |
| else if (job.name.includes('windows')) results.windows = job.conclusion || 'pending'; | |
| else if (job.name.includes('macos')) results.macos = job.conclusion || 'pending'; | |
| } | |
| core.setOutput('ubuntu', results.ubuntu || 'unknown'); | |
| core.setOutput('windows', results.windows || 'unknown'); | |
| core.setOutput('macos', results.macos || 'unknown'); | |
| - name: Fetch base branch coverage | |
| id: base-coverage | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const runs = await github.rest.actions.listWorkflowRuns({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| workflow_id: 'ci.yml', | |
| branch: 'main', | |
| status: 'success', | |
| per_page: 1, | |
| }); | |
| if (runs.data.workflow_runs.length === 0) { | |
| core.setOutput('found', 'false'); | |
| return; | |
| } | |
| const mainRunId = runs.data.workflow_runs[0].id; | |
| const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: mainRunId, | |
| }); | |
| const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports'); | |
| if (!testReports) { | |
| core.setOutput('found', 'false'); | |
| return; | |
| } | |
| const zip = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: testReports.id, | |
| archive_format: 'zip', | |
| }); | |
| const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage'); | |
| fs.mkdirSync(dest, { recursive: true }); | |
| fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data)); | |
| core.setOutput('found', 'true'); | |
| core.setOutput('dir', dest); | |
| - name: Extract base coverage | |
| if: steps.base-coverage.outputs.found == 'true' | |
| shell: bash | |
| run: | | |
| cd "${{ steps.base-coverage.outputs.dir }}" | |
| unzip -o base.zip -d base | |
| - name: Build and post report | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 | |
| env: | |
| PR_NUMBER: ${{ steps.meta.outputs.pr-number }} | |
| QUALITY: ${{ steps.meta.outputs.quality }} | |
| TESTS: ${{ steps.meta.outputs.tests }} | |
| UBUNTU: ${{ steps.jobs.outputs.ubuntu }} | |
| WINDOWS: ${{ steps.jobs.outputs.windows }} | |
| MACOS: ${{ steps.jobs.outputs.macos }} | |
| BASE_FOUND: ${{ steps.base-coverage.outputs.found }} | |
| BASE_DIR: ${{ steps.base-coverage.outputs.dir }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const icon = (s) => ({ success: '✅', failure: '❌', cancelled: '⏭️' }[s] || '❓'); | |
| const temp = process.env.RUNNER_TEMP; | |
| // ── Read coverage ── | |
| function readCov(dir) { | |
| const out = { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A', | |
| stmtsCov: '', branchCov: '', funcsCov: '', linesCov: '' }; | |
| try { | |
| const files = require('child_process') | |
| .execSync(`find "${dir}" -name coverage-summary.json -type f`, { encoding: 'utf8' }) | |
| .trim().split('\n').filter(Boolean); | |
| if (!files.length) return out; | |
| const d = JSON.parse(fs.readFileSync(files[0], 'utf8')).total; | |
| out.stmts = d.statements.pct; out.branch = d.branches.pct; | |
| out.funcs = d.functions.pct; out.lines = d.lines.pct; | |
| out.stmtsCov = `${d.statements.covered}/${d.statements.total}`; | |
| out.branchCov = `${d.branches.covered}/${d.branches.total}`; | |
| out.funcsCov = `${d.functions.covered}/${d.functions.total}`; | |
| out.linesCov = `${d.lines.covered}/${d.lines.total}`; | |
| } catch {} | |
| return out; | |
| } | |
| const cov = readCov(path.join(temp, 'test-reports')); | |
| const base = process.env.BASE_FOUND === 'true' | |
| ? readCov(path.join(process.env.BASE_DIR, 'base')) | |
| : { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A' }; | |
| // ── Read test results ── | |
| let total = 0, passed = 0, failed = 0, skipped = 0, suites = 0, duration = '0s'; | |
| let skippedTests = []; | |
| try { | |
| const files = require('child_process') | |
| .execSync(`find "${path.join(temp, 'test-reports')}" -name test-results.json -type f`, { encoding: 'utf8' }) | |
| .trim().split('\n').filter(Boolean); | |
| if (files.length) { | |
| const r = JSON.parse(fs.readFileSync(files[0], 'utf8')); | |
| total = r.numTotalTests || 0; | |
| passed = r.numPassedTests || 0; | |
| failed = r.numFailedTests || 0; | |
| skipped = r.numPendingTests || 0; | |
| suites = r.numTotalTestSuites || 0; | |
| const durS = Math.floor((Math.max(...r.testResults.map(t => t.endTime)) - r.startTime) / 1000); | |
| duration = durS >= 60 ? `${Math.floor(durS / 60)}m ${durS % 60}s` : `${durS}s`; | |
| // Collect skipped test names | |
| for (const suite of r.testResults) { | |
| for (const t of (suite.assertionResults || [])) { | |
| if (t.status === 'pending' || t.status === 'skipped') { | |
| skippedTests.push(`- ${t.ancestorTitles.join(' > ')} > ${t.title}`); | |
| } | |
| } | |
| } | |
| } | |
| } catch {} | |
| // ── Coverage delta ── | |
| function delta(pct, basePct) { | |
| if (pct === 'N/A' || basePct === 'N/A') return '—'; | |
| const d = (pct - basePct).toFixed(1); | |
| const dNum = parseFloat(d); | |
| if (dNum > 0) return `📈 +${d}%`; | |
| if (dNum < 0) return `📉 ${d}%`; | |
| return '='; | |
| } | |
| // ── Build markdown ── | |
| const { PR_NUMBER, QUALITY, TESTS, UBUNTU, WINDOWS, MACOS, RUN_ID, HEAD_SHA } = process.env; | |
| const prNumber = parseInt(PR_NUMBER, 10); | |
| const overall = (QUALITY === 'success' && TESTS === 'success') | |
| ? '✅ **All checks passed**' : '❌ **Some checks failed**'; | |
| const sha = HEAD_SHA.slice(0, 7); | |
| let body = `## CI Report\n\n${overall}   \`${sha}\`\n\n`; | |
| body += `### Pipeline\n\n`; | |
| body += `| Stage | Status | Ubuntu | Windows | macOS |\n`; | |
| body += `|-------|--------|--------|---------|-------|\n`; | |
| body += `| Typecheck | ${icon(QUALITY)} \`${QUALITY}\` | — | — | — |\n`; | |
| body += `| Tests | ${icon(TESTS)} \`${TESTS}\` | ${icon(UBUNTU)} | ${icon(WINDOWS)} | ${icon(MACOS)} |\n\n`; | |
| if (total > 0) { | |
| body += `### Tests\n\n`; | |
| body += `| Metric | Value |\n|--------|-------|\n`; | |
| body += `| Total | **${total}** |\n`; | |
| body += `| Passed | **${passed}** |\n`; | |
| if (failed > 0) body += `| Failed | **${failed}** |\n`; | |
| if (skipped > 0) body += `| Skipped | ${skipped} |\n`; | |
| body += `| Files | ${suites} |\n`; | |
| body += `| Duration | ${duration} |\n\n`; | |
| if (failed === 0) { | |
| body += `✅ All **${passed}** tests passed across **${suites}** files\n`; | |
| } else { | |
| body += `❌ **${failed}** failed / **${passed}** passed\n`; | |
| } | |
| if (skippedTests.length > 0) { | |
| body += `\n<details>\n<summary>${skipped} test(s) skipped</summary>\n\n`; | |
| body += skippedTests.join('\n') + '\n\n</details>\n'; | |
| } | |
| body += '\n'; | |
| } | |
| if (cov.stmts !== 'N/A') { | |
| body += `### Coverage\n\n`; | |
| body += `| Metric | Coverage | Covered | Base (main) | Delta |\n`; | |
| body += `|--------|----------|---------|-------------|-------|\n`; | |
| body += `| Statements | **${cov.stmts}%** | ${cov.stmtsCov} | ${base.stmts}% | ${delta(cov.stmts, base.stmts)} |\n`; | |
| body += `| Branches | **${cov.branch}%** | ${cov.branchCov} | ${base.branch}% | ${delta(cov.branch, base.branch)} |\n`; | |
| body += `| Functions | **${cov.funcs}%** | ${cov.funcsCov} | ${base.funcs}% | ${delta(cov.funcs, base.funcs)} |\n`; | |
| body += `| Lines | **${cov.lines}%** | ${cov.linesCov} | ${base.lines}% | ${delta(cov.lines, base.lines)} |\n\n`; | |
| } else { | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${RUN_ID}`; | |
| body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${runUrl}) for details.\n\n`; | |
| } | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${RUN_ID}`; | |
| body += `---\n<sub>📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI</sub>`; | |
| // ── Post sticky comment ── | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| direction: 'desc', | |
| }); | |
| const marker = '<!-- ci-report -->'; | |
| const existing = comments.find(c => c.body?.includes(marker)); | |
| const fullBody = marker + '\n' + body; | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body: fullBody, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: fullBody, | |
| }); | |
| } |