From eb74eb85903f2e062f13c2edc12998b36df78dba Mon Sep 17 00:00:00 2001 From: Zander Raycraft Date: Thu, 19 Mar 2026 17:06:32 -0500 Subject: [PATCH] CI sticky notes --- .github/workflows/ci-report.yml | 344 +++++++++++++++++++++++ .github/workflows/ci.yml | 269 ++---------------- .github/workflows/claude-code-review.yml | 9 +- .github/workflows/claude.yml | 2 +- 4 files changed, 369 insertions(+), 255 deletions(-) create mode 100644 .github/workflows/ci-report.yml 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