diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml
new file mode 100644
index 000000000..e12d5a807
--- /dev/null
+++ b/.github/workflows/ci-report.yml
@@ -0,0 +1,344 @@
+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
+ 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
+ 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);
+ if (d > 0) return `📈 +${d}%`;
+ if (d < 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\n${skipped} test(s) skipped
\n\n`;
+ body += skippedTests.join('\n') + '\n\n \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📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI`;
+
+ // ── 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,
+ });
+
+ const marker = '';
+ 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,
+ });
+ }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e2a3df818..7ce04463c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,9 +17,7 @@ concurrency:
# Each concern lives in its own workflow file for maintainability:
# ci-quality.yml — typecheck (tsc --noEmit)
# ci-tests.yml — all tests with coverage (ubuntu) + cross-platform
-#
-# The PR report runs inline (not via workflow_run) so it uses the
-# PR branch's code instead of main's — avoids stale report templates.
+# ci-report.yml — PR comment (workflow_run trigger for fork write access)
jobs:
quality:
@@ -55,260 +53,31 @@ jobs:
exit 1
fi
- # ── PR Report ────────────────────────────────────────────────────
- # Posts a sticky comment with test results, coverage, and
- # per-platform status. Runs inline so it uses the PR branch's
- # report template (not main's stale version via workflow_run).
- pr-report:
- name: PR Report
+ # ── PR metadata for ci-report.yml ────────────────────────────────
+ # Saves PR number and job results so the workflow_run-triggered
+ # report can post comments with a write token (works for forks).
+ save-pr-meta:
+ name: Save PR Metadata
if: always() && github.event_name == 'pull_request'
needs: [quality, tests]
runs-on: ubuntu-latest
- permissions:
- contents: read
- actions: read
- pull-requests: write
timeout-minutes: 5
steps:
- - name: Download test reports
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- with:
- name: test-reports
- path: ${{ runner.temp }}/test-reports
- continue-on-error: 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: context.runId,
- 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'
+ - name: Write PR metadata
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: ${{ github.event.pull_request.number }}
QUALITY: ${{ needs.quality.result }}
TESTS: ${{ needs.tests.result }}
- 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: |
+ mkdir -p pr-meta
+ echo "$PR_NUMBER" > pr-meta/pr-number
+ echo "$QUALITY" > pr-meta/quality-result
+ echo "$TESTS" > pr-meta/tests-result
+
+ - name: Upload PR metadata
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
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);
- if (d > 0) return `📈 +${d}%`;
- if (d < 0) return `📉 ${d}%`;
- return '=';
- }
-
- // ── Build markdown ──
- const { QUALITY, TESTS, UBUNTU, WINDOWS, MACOS } = process.env;
- const overall = (QUALITY === 'success' && TESTS === 'success')
- ? '✅ **All checks passed**' : '❌ **Some checks failed**';
- const sha = context.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\n${skipped} test(s) skipped
\n\n`;
- body += skippedTests.join('\n') + '\n\n \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 {
- body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.\n\n`;
- }
-
- const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- body += `---\n📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI`;
-
- // ── Post sticky comment ──
- const { data: comments } = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- per_page: 100,
- });
-
- const marker = '';
- 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: context.issue.number,
- body: fullBody,
- });
- }
+ name: pr-meta
+ path: pr-meta/
+ retention-days: 1
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index 7fb230f66..82a65f844 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -2,9 +2,10 @@ name: Claude Code Review
# Uses pull_request_target so the workflow runs as defined on the default branch,
# which allows access to secrets for posting review comments on fork PRs.
-# SECURITY: The checkout uses the fork's repo/branch directly so the action
-# can find the ref. The claude-code-action sandboxes execution — it does NOT
-# run arbitrary code from the checked-out source.
+# SECURITY: The checkout pins the fork's HEAD SHA (not the branch name) to
+# prevent TOCTOU races (force-push between trigger and checkout). The
+# claude-code-action sandboxes execution — it does NOT run arbitrary code
+# from the checked-out source.
on:
# Trigger only when explicitly requested:
@@ -78,7 +79,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ steps.pr.outputs.repo }}
- ref: ${{ steps.pr.outputs.branch }}
+ ref: ${{ steps.pr.outputs.sha }}
fetch-depth: 1
- name: Run Claude Code Review
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index e1c6f031c..55fe096c6 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -69,7 +69,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
- ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.branch || '' }}
+ ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
fetch-depth: 1
- name: Run Claude Code