From c4c3aebef0c96c7cb3053375fb4c0595894bf3bd Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 20:15:29 +0200 Subject: [PATCH 1/2] Add GitHub Actions workflows to measure and report library size changes --- .github/workflows/library-size-comment.yml | 102 +++++++++++++++++++++ .github/workflows/library-size.yml | 83 +++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 .github/workflows/library-size-comment.yml create mode 100644 .github/workflows/library-size.yml diff --git a/.github/workflows/library-size-comment.yml b/.github/workflows/library-size-comment.yml new file mode 100644 index 000000000..2eca78919 --- /dev/null +++ b/.github/workflows/library-size-comment.yml @@ -0,0 +1,102 @@ +name: Library Size Comment + +on: + workflow_run: + workflows: [Library Size] + types: [completed] + +permissions: + actions: read + pull-requests: write + +concurrency: + group: library-size-comment-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + comment: + name: Update PR comment + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + steps: + - name: Download measurements + uses: actions/download-artifact@v8 + with: + name: library-size-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: ${{ runner.temp }}/mathcat-size-report + + # This job never checks out or executes PR code. Treat the downloaded report as untrusted. + - name: Update size comment + uses: actions/github-script@v9 + env: + REPORT_PATH: ${{ runner.temp }}/mathcat-size-report/report.json + with: + script: | + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8')); + const run = context.payload.workflow_run; + const shaPattern = /^[0-9a-f]{40}$/i; + if (!Number.isSafeInteger(report.prNumber) || report.prNumber <= 0 || + !shaPattern.test(report.baseSha) || !shaPattern.test(report.headSha) || + !Number.isSafeInteger(report.baseBytes) || report.baseBytes <= 0 || + !Number.isSafeInteger(report.headBytes) || report.headBytes <= 0) { + core.setFailed('Invalid library size report'); + return; + } + + const {owner, repo} = context.repo; + const {data: pull} = await github.rest.pulls.get({ + owner, repo, pull_number: report.prNumber, + }); + if (pull.state !== 'open' || pull.base.sha !== report.baseSha || + pull.head.sha !== report.headSha) { + core.notice('PR revisions changed or PR closed; ignoring stale measurements'); + return; + } + if (run.head_repository?.id !== pull.head.repo?.id || + run.head_branch !== pull.head.ref) { + core.setFailed('Report does not match the triggering branch'); + return; + } + if (run.pull_requests?.length > 0 && + !run.pull_requests.some(pr => pr.number === report.prNumber)) { + core.setFailed('Report does not belong to the triggering PR'); + return; + } + + const marker = ''; + const formatSize = bytes => `${(bytes / 1048576).toFixed(2)} MiB (${bytes.toLocaleString('en-US')} bytes)`; + const delta = report.headBytes - report.baseBytes; + const deltaPercent = 100 * delta / report.baseBytes; + const signed = value => value > 0 ? `+${value}` : String(value); + const body = [ + marker, + '### Linux library size', + '', + '| Revision | Release `liblibmathcat.so` |', + '| --- | ---: |', + `| Base (${report.baseSha.slice(0, 7)}) | ${formatSize(report.baseBytes)} |`, + `| PR (${report.headSha.slice(0, 7)}) | ${formatSize(report.headBytes)} |`, + `| Change | ${delta > 0 ? '+' : ''}${delta.toLocaleString('en-US')} bytes (${signed(deltaPercent.toFixed(2))}%) |`, + '', + `Built with default features, Rust 1.96.0, and Ubuntu 24.04. [Workflow run](${run.html_url}).`, + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: report.prNumber, per_page: 100, + }); + const previous = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)); + if (previous) { + if (previous.body !== body) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: previous.id, body, + }); + } + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number: report.prNumber, body, + }); + } diff --git a/.github/workflows/library-size.yml b/.github/workflows/library-size.yml new file mode 100644 index 000000000..c94cf9e87 --- /dev/null +++ b/.github/workflows/library-size.yml @@ -0,0 +1,83 @@ +name: Library Size + +on: pull_request + +permissions: + contents: read + +concurrency: + group: library-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + measure: + name: Measure Linux shared library + runs-on: ubuntu-24.04 + steps: + - name: Check out base revision + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + + - name: Check out PR revision + uses: actions/checkout@v5 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: head + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.96.0 + + - name: Build base library + id: base + working-directory: base + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/mathcat-size-base-target + run: | + cargo build --release --lib --locked + bytes=$(stat -c%s "$CARGO_TARGET_DIR/release/liblibmathcat.so") + echo "bytes=$bytes" >> "$GITHUB_OUTPUT" + + - name: Build PR library + id: head + working-directory: head + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/mathcat-size-head-target + run: | + cargo build --release --lib --locked + bytes=$(stat -c%s "$CARGO_TARGET_DIR/release/liblibmathcat.so") + echo "bytes=$bytes" >> "$GITHUB_OUTPUT" + + - name: Save measurements + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_BYTES: ${{ steps.base.outputs.bytes }} + HEAD_BYTES: ${{ steps.head.outputs.bytes }} + REPORT_DIR: ${{ runner.temp }}/mathcat-size-report + run: | + mkdir -p "$REPORT_DIR" + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const report = { + prNumber: Number(process.env.PR_NUMBER), + baseSha: process.env.BASE_SHA, + headSha: process.env.HEAD_SHA, + baseBytes: Number(process.env.BASE_BYTES), + headBytes: Number(process.env.HEAD_BYTES), + }; + fs.writeFileSync(path.join(process.env.REPORT_DIR, 'report.json'), JSON.stringify(report)); + NODE + + - name: Upload measurements + uses: actions/upload-artifact@v7 + with: + name: library-size-report + path: ${{ runner.temp }}/mathcat-size-report/report.json + retention-days: 1 From fb97e69add346883dfc37507f073c79571a5d22f Mon Sep 17 00:00:00 2001 From: mgros Date: Sat, 26 Sep 2026 21:17:03 +0200 Subject: [PATCH 2/2] Refactor library size comment script to improve readability and error handling --- .github/workflows/library-size-comment.yml | 129 ++++++++++++--------- 1 file changed, 73 insertions(+), 56 deletions(-) diff --git a/.github/workflows/library-size-comment.yml b/.github/workflows/library-size-comment.yml index 2eca78919..3fbb3b3cf 100644 --- a/.github/workflows/library-size-comment.yml +++ b/.github/workflows/library-size-comment.yml @@ -35,68 +35,85 @@ jobs: with: script: | const fs = require('fs'); - const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8')); const run = context.payload.workflow_run; - const shaPattern = /^[0-9a-f]{40}$/i; - if (!Number.isSafeInteger(report.prNumber) || report.prNumber <= 0 || - !shaPattern.test(report.baseSha) || !shaPattern.test(report.headSha) || - !Number.isSafeInteger(report.baseBytes) || report.baseBytes <= 0 || - !Number.isSafeInteger(report.headBytes) || report.headBytes <= 0) { - core.setFailed('Invalid library size report'); - return; - } - const {owner, repo} = context.repo; - const {data: pull} = await github.rest.pulls.get({ - owner, repo, pull_number: report.prNumber, - }); - if (pull.state !== 'open' || pull.base.sha !== report.baseSha || - pull.head.sha !== report.headSha) { - core.notice('PR revisions changed or PR closed; ignoring stale measurements'); - return; - } - if (run.head_repository?.id !== pull.head.repo?.id || - run.head_branch !== pull.head.ref) { - core.setFailed('Report does not match the triggering branch'); - return; + const marker = ''; + + // Read and validate the artifact from the untrusted PR build. + function readReport() { + const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8')); + const shaPattern = /^[0-9a-f]{40}$/i; + if (!Number.isSafeInteger(report.prNumber) || report.prNumber <= 0 || + !shaPattern.test(report.baseSha) || !shaPattern.test(report.headSha) || + !Number.isSafeInteger(report.baseBytes) || report.baseBytes <= 0 || + !Number.isSafeInteger(report.headBytes) || report.headBytes <= 0) { + throw new Error('Invalid library size report'); + } + return report; } - if (run.pull_requests?.length > 0 && - !run.pull_requests.some(pr => pr.number === report.prNumber)) { - core.setFailed('Report does not belong to the triggering PR'); - return; + + // Only report measurements for the current PR and source branch. + async function matchesCurrentPull(report) { + const {data: pull} = await github.rest.pulls.get({ + owner, repo, pull_number: report.prNumber, + }); + if (pull.state !== 'open' || pull.base.sha !== report.baseSha || + pull.head.sha !== report.headSha) { + core.notice('PR revisions changed or PR closed; ignoring stale measurements'); + return false; + } + if (run.head_repository?.id !== pull.head.repo?.id || + run.head_branch !== pull.head.ref || + (run.pull_requests?.length > 0 && + !run.pull_requests.some(pr => pr.number === report.prNumber))) { + throw new Error('Report does not belong to the triggering PR branch'); + } + return true; } - const marker = ''; - const formatSize = bytes => `${(bytes / 1048576).toFixed(2)} MiB (${bytes.toLocaleString('en-US')} bytes)`; - const delta = report.headBytes - report.baseBytes; - const deltaPercent = 100 * delta / report.baseBytes; - const signed = value => value > 0 ? `+${value}` : String(value); - const body = [ - marker, - '### Linux library size', - '', - '| Revision | Release `liblibmathcat.so` |', - '| --- | ---: |', - `| Base (${report.baseSha.slice(0, 7)}) | ${formatSize(report.baseBytes)} |`, - `| PR (${report.headSha.slice(0, 7)}) | ${formatSize(report.headBytes)} |`, - `| Change | ${delta > 0 ? '+' : ''}${delta.toLocaleString('en-US')} bytes (${signed(deltaPercent.toFixed(2))}%) |`, - '', - `Built with default features, Rust 1.96.0, and Ubuntu 24.04. [Workflow run](${run.html_url}).`, - ].join('\n'); + // The summary is the entire collapsed view; the table is shown on expansion. + function renderComment(report) { + const mib = bytes => `${(bytes / 1048576).toFixed(2)} MiB`; + const size = bytes => `${mib(bytes)} (${bytes.toLocaleString('en-US')} bytes)`; + const delta = report.headBytes - report.baseBytes; + const percent = `${delta > 0 ? '+' : ''}${(100 * delta / report.baseBytes).toFixed(2)}%`; + return [ + marker, + '
', + `Linux library size: ${mib(report.headBytes)} (${percent})`, + '', + '| Revision | Release `liblibmathcat.so` |', + '| --- | ---: |', + `| Base (${report.baseSha.slice(0, 7)}) | ${size(report.baseBytes)} |`, + `| PR (${report.headSha.slice(0, 7)}) | ${size(report.headBytes)} |`, + `| Change | ${delta > 0 ? '+' : ''}${delta.toLocaleString('en-US')} bytes (${percent}) |`, + '', + `Built with default features, Rust 1.96.0, and Ubuntu 24.04. [Workflow run](${run.html_url}).`, + '
', + ].join('\n'); + } - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: report.prNumber, per_page: 100, - }); - const previous = comments.find(comment => - comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)); - if (previous) { - if (previous.body !== body) { - await github.rest.issues.updateComment({ - owner, repo, comment_id: previous.id, body, + // Reuse the bot's marked comment instead of posting on every run. + async function upsertComment(prNumber, body) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + const previous = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)); + if (previous) { + if (previous.body !== body) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: previous.id, body, + }); + } + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body, }); } - } else { - await github.rest.issues.createComment({ - owner, repo, issue_number: report.prNumber, body, - }); + } + + const report = readReport(); + if (await matchesCurrentPull(report)) { + await upsertComment(report.prNumber, renderComment(report)); }