diff --git a/.changeset/easy-flowers-shake.md b/.changeset/easy-flowers-shake.md new file mode 100644 index 00000000000..da78b15b99a --- /dev/null +++ b/.changeset/easy-flowers-shake.md @@ -0,0 +1,6 @@ +--- +"@fluentui-react-native/components": patch +"@fluentui-react-native/design": patch +--- + +Add sideEffects: false to package manifests diff --git a/.github/workflows/pr-bundle-size-comment.yml b/.github/workflows/pr-bundle-size-comment.yml new file mode 100644 index 00000000000..0c3a7e17fc6 --- /dev/null +++ b/.github/workflows/pr-bundle-size-comment.yml @@ -0,0 +1,46 @@ +name: Bundle Size PR Comment + +on: + workflow_run: + workflows: [PR] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +concurrency: + group: bundle-size-comment-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + comment: + name: Publish trusted bundle-size report + if: ${{ github.event.workflow_run.event == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Checkout trusted reporter + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Download bundle-size report + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bundle-size-analysis + path: bundle-size-analysis + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Publish bundle size PR comment + env: + EXPECTED_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + EXPECTED_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER_PATH: bundle-size-analysis/pull-request-number.txt + REPORT_PATH: bundle-size-analysis/report.md + SOURCE_RUN_ID: ${{ github.event.workflow_run.id }} + run: node apps/bundle-size/scripts/post-pr-comment.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 769c32079cb..7f769475815 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -38,6 +38,9 @@ jobs: name: Bundle Size PR runs-on: windows-latest timeout-minutes: 30 + permissions: + contents: read + pull-requests: write steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -72,6 +75,22 @@ jobs: - name: Publish bundle size summary run: Get-Content apps\bundle-size\dist\bundle-size\report.md -Raw | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + - name: Publish bundle size PR comment + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'dependabot[bot]' }} + env: + EXPECTED_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPORT_PATH: apps/bundle-size/dist/bundle-size/report.md + SOURCE_RUN_ID: ${{ github.run_id }} + run: node apps/bundle-size/scripts/post-pr-comment.mjs + + - name: Add pull request metadata + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: Set-Content apps\bundle-size\dist\bundle-size\pull-request-number.txt $env:PR_NUMBER -NoNewline + - name: Upload bundle analysis if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/apps/bundle-size/README.md b/apps/bundle-size/README.md index 2840ee814fd..f472889dadc 100644 --- a/apps/bundle-size/README.md +++ b/apps/bundle-size/README.md @@ -23,7 +23,9 @@ Results, source maps, and esbuild metafiles are written to the ignored `dist/bun directory. The runner passes `--tree-shake true` and `--metafile .meta.json` to `rnx-cli bundle`. The JSON report contains raw bytes, gzip bytes, contributing esbuild input counts, Metro source-map counts, workspace package contribution bytes, and shell deltas. Raw -bytes are the primary comparison; gzip and module attribution are diagnostic signals. +bytes are the primary comparison; gzip and module attribution are diagnostic signals. The +terminal summary shows the shell's absolute cost and each package scenario's incremental module +and byte cost above its platform shell, plus signed deltas from the baseline. The Babel configuration preserves ESM for the serializer while explicitly lowering JSX. The explicit JSX transform is required because the desktop React Native packages publish JSX in @@ -40,8 +42,10 @@ yarn bundle-size:update ``` Review the baseline diff together with the implementation that caused it. The PR workflow adds -the Markdown comparison to its job summary and uploads the complete `dist/bundle-size` directory, -including the esbuild metafiles, for investigation. +the Markdown comparison to its job summary, updates one persistent PR comment, and uploads the +complete `dist/bundle-size` directory, including the esbuild metafiles, for investigation. +Same-repository PRs publish directly; fork and Dependabot reports are strictly validated and +published by a separate trusted completion workflow. ## Adding a package or submodule diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json index 435b090c349..a06d1c92cff 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -15,38 +15,38 @@ { "platform": "macos", "scenario": "components-button", - "rawBytes": 1088756, - "gzipBytes": 301160, - "moduleCount": 753, - "metroModuleCount": 748, + "rawBytes": 868023, + "gzipBytes": 248334, + "moduleCount": 550, + "metroModuleCount": 546, "metafileInputCount": 753, "workspaceModules": { - "@fluentui-react-native/components": 98, - "@fluentui-react-native/design": 20, + "@fluentui-react-native/components": 10, + "@fluentui-react-native/design": 17, "@fluentui-react-native/framework-base": 26 }, "workspaceBytes": { - "@fluentui-react-native/components": 120566, - "@fluentui-react-native/design": 26777, - "@fluentui-react-native/framework-base": 12813 + "@fluentui-react-native/components": 9480, + "@fluentui-react-native/design": 26458, + "@fluentui-react-native/framework-base": 11146 } }, { "platform": "macos", "scenario": "components-catalog", - "rawBytes": 1090777, - "gzipBytes": 301702, - "moduleCount": 753, - "metroModuleCount": 748, + "rawBytes": 1090635, + "gzipBytes": 301676, + "moduleCount": 750, + "metroModuleCount": 745, "metafileInputCount": 753, "workspaceModules": { "@fluentui-react-native/components": 98, - "@fluentui-react-native/design": 20, + "@fluentui-react-native/design": 17, "@fluentui-react-native/framework-base": 26 }, "workspaceBytes": { "@fluentui-react-native/components": 122538, - "@fluentui-react-native/design": 26777, + "@fluentui-react-native/design": 26642, "@fluentui-react-native/framework-base": 12813 } }, @@ -64,38 +64,38 @@ { "platform": "win32", "scenario": "components-button", - "rawBytes": 1094123, - "gzipBytes": 301643, - "moduleCount": 754, - "metroModuleCount": 749, + "rawBytes": 873381, + "gzipBytes": 248809, + "moduleCount": 551, + "metroModuleCount": 547, "metafileInputCount": 754, "workspaceModules": { - "@fluentui-react-native/components": 98, - "@fluentui-react-native/design": 21, + "@fluentui-react-native/components": 10, + "@fluentui-react-native/design": 18, "@fluentui-react-native/framework-base": 26 }, "workspaceBytes": { - "@fluentui-react-native/components": 120582, - "@fluentui-react-native/design": 26814, - "@fluentui-react-native/framework-base": 12813 + "@fluentui-react-native/components": 9482, + "@fluentui-react-native/design": 26495, + "@fluentui-react-native/framework-base": 11146 } }, { "platform": "win32", "scenario": "components-catalog", - "rawBytes": 1096141, - "gzipBytes": 302170, - "moduleCount": 754, - "metroModuleCount": 749, + "rawBytes": 1096000, + "gzipBytes": 302161, + "moduleCount": 751, + "metroModuleCount": 746, "metafileInputCount": 754, "workspaceModules": { "@fluentui-react-native/components": 98, - "@fluentui-react-native/design": 21, + "@fluentui-react-native/design": 18, "@fluentui-react-native/framework-base": 26 }, "workspaceBytes": { "@fluentui-react-native/components": 122554, - "@fluentui-react-native/design": 26814, + "@fluentui-react-native/design": 26679, "@fluentui-react-native/framework-base": 12813 } }, @@ -113,38 +113,38 @@ { "platform": "windows", "scenario": "components-button", - "rawBytes": 1097727, - "gzipBytes": 302564, - "moduleCount": 759, - "metroModuleCount": 754, + "rawBytes": 877232, + "gzipBytes": 249752, + "moduleCount": 556, + "metroModuleCount": 552, "metafileInputCount": 759, "workspaceModules": { - "@fluentui-react-native/components": 98, - "@fluentui-react-native/design": 21, + "@fluentui-react-native/components": 10, + "@fluentui-react-native/design": 18, "@fluentui-react-native/framework-base": 26 }, "workspaceBytes": { - "@fluentui-react-native/components": 120529, - "@fluentui-react-native/design": 26849, - "@fluentui-react-native/framework-base": 12813 + "@fluentui-react-native/components": 9478, + "@fluentui-react-native/design": 26530, + "@fluentui-react-native/framework-base": 11146 } }, { "platform": "windows", "scenario": "components-catalog", - "rawBytes": 1099748, - "gzipBytes": 303151, - "moduleCount": 759, - "metroModuleCount": 754, + "rawBytes": 1099607, + "gzipBytes": 303049, + "moduleCount": 756, + "metroModuleCount": 751, "metafileInputCount": 759, "workspaceModules": { "@fluentui-react-native/components": 98, - "@fluentui-react-native/design": 21, + "@fluentui-react-native/design": 18, "@fluentui-react-native/framework-base": 26 }, "workspaceBytes": { "@fluentui-react-native/components": 122501, - "@fluentui-react-native/design": 26849, + "@fluentui-react-native/design": 26714, "@fluentui-react-native/framework-base": 12813 } } diff --git a/apps/bundle-size/package.json b/apps/bundle-size/package.json index 6f733af5f01..1bbad01f474 100644 --- a/apps/bundle-size/package.json +++ b/apps/bundle-size/package.json @@ -8,6 +8,7 @@ "format": "fluentui-scripts format", "lint": "fluentui-scripts lint", "measure": "node scripts/measure.mjs", + "test": "node --test scripts/*.test.mjs", "update-baseline": "node scripts/measure.mjs --update-baseline" }, "dependencies": { @@ -35,6 +36,7 @@ "@rnx-kit/metro-resolver-symlinks": "catalog:", "@rnx-kit/metro-serializer-esbuild": "catalog:", "@rnx-kit/tools-filesystem": "^0.2.0", + "@rnx-kit/tools-formatting": "^0.1.0", "metro": "^0.83.8", "oxc-resolver": "catalog:" }, diff --git a/apps/bundle-size/scripts/format.mjs b/apps/bundle-size/scripts/format.mjs new file mode 100644 index 00000000000..5ab029b6a19 --- /dev/null +++ b/apps/bundle-size/scripts/format.mjs @@ -0,0 +1,45 @@ +import { formatAsTable } from '@rnx-kit/tools-formatting'; + +const kilobyteFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}); + +export function formatSize(bytes) { + const absoluteBytes = Math.abs(bytes); + if (absoluteBytes < 1000) { + return `${bytes}b`; + } + + const sign = bytes < 0 ? '-' : ''; + return `${sign}${kilobyteFormatter.format(absoluteBytes / 1000)}k`; +} + +function formatSigned(value, formatter) { + const sign = value < 0 ? '-' : '+'; + return `${sign}${formatter(Math.abs(value))}`; +} + +export function formatBundleSizeTable(results) { + const rows = results.map(({ platform, scenario, comparison }) => [ + `${platform}: ${scenario}`, + comparison.currentModuleCost, + comparison.status === 'new' ? 'New' : formatSigned(comparison.moduleCostDelta, integerFormatter.format), + formatSize(comparison.currentCost), + comparison.status === 'new' ? 'New' : formatSigned(comparison.costDelta, formatSize), + ]); + + return formatAsTable(rows, { + columns: [ + { label: 'Platform: scenario' }, + { label: 'New modules', align: 'right', format: integerFormatter.format }, + { label: 'Module delta', align: 'right' }, + { label: 'New size', align: 'right' }, + { label: 'Size delta', align: 'right' }, + ], + }); +} diff --git a/apps/bundle-size/scripts/format.test.mjs b/apps/bundle-size/scripts/format.test.mjs new file mode 100644 index 00000000000..88f17003da7 --- /dev/null +++ b/apps/bundle-size/scripts/format.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { formatBundleSizeTable, formatSize } from './format.mjs'; + +describe('formatSize', () => { + it('formats bytes below 1000 without scaling', () => { + assert.equal(formatSize(0), '0b'); + assert.equal(formatSize(999), '999b'); + assert.equal(formatSize(-999), '-999b'); + }); + + it('formats kilobytes with grouping and two decimal places', () => { + assert.equal(formatSize(1000), '1.00k'); + assert.equal(formatSize(1_234_560), '1,234.56k'); + assert.equal(formatSize(-1_234_560), '-1,234.56k'); + }); +}); + +describe('formatBundleSizeTable', () => { + it('shows shell-relative costs and signed baseline deltas', () => { + const output = formatBundleSizeTable([ + { + platform: 'macos', + scenario: 'components-button', + moduleCount: 1234, + rawBytes: 1_234_560, + deltaModules: 737, + deltaBytes: 414_038, + comparison: { + status: 'compared', + currentModuleCost: 737, + currentCost: 414_038, + moduleCostDelta: 2, + costDelta: -999, + }, + }, + { + platform: 'win32', + scenario: 'shell', + moduleCount: 497, + rawBytes: 999, + comparison: { + status: 'new', + currentModuleCost: 497, + currentCost: 999, + }, + }, + { + platform: 'windows', + scenario: 'components-catalog', + moduleCount: 2000, + rawBytes: 2000, + deltaModules: 1503, + deltaBytes: 1000, + comparison: { + status: 'compared', + currentModuleCost: 1503, + currentCost: 1000, + moduleCostDelta: -12, + costDelta: 1000, + }, + }, + ]); + + assert.match(output, /Platform: scenario/); + assert.match(output, /New modules/); + assert.match(output, /macos: components-button/); + assert.match(output, /737/); + assert.match(output, /\+2/); + assert.match(output, /414\.04k/); + assert.doesNotMatch(output, /1,234\.56k/); + assert.match(output, /-999b/); + assert.match(output, /win32: shell/); + assert.match(output, /999b/); + assert.match(output, /New/); + assert.match(output, /windows: components-catalog/); + assert.match(output, /1,503/); + assert.match(output, /-12/); + assert.match(output, /\+1\.00k/); + }); +}); diff --git a/apps/bundle-size/scripts/measure.mjs b/apps/bundle-size/scripts/measure.mjs index 72e23713b47..b06645b3028 100644 --- a/apps/bundle-size/scripts/measure.mjs +++ b/apps/bundle-size/scripts/measure.mjs @@ -6,6 +6,8 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gzipSync } from 'node:zlib'; +import { formatBundleSizeTable } from './format.mjs'; + const workspaceRoot = dirname(dirname(fileURLToPath(import.meta.url))); const repositoryRoot = dirname(dirname(workspaceRoot)); const yarnVersion = readJSONFileSync(join(repositoryRoot, 'package.json')).packageManager.split('@')[1]; @@ -193,21 +195,22 @@ function resultKey({ platform, scenario }) { function createComparison(measurement, baseline, baselineShell) { const isShell = measurement.scenario === 'shell'; + const currentCost = isShell ? measurement.rawBytes : measurement.deltaBytes; + const currentModuleCost = isShell ? measurement.moduleCount : measurement.deltaModules; if (!baseline || (!isShell && !baselineShell)) { - return { status: 'new' }; + return { status: 'new', currentCost, currentModuleCost }; } const baselineCost = isShell ? baseline.rawBytes : baseline.rawBytes - baselineShell.rawBytes; - const currentCost = isShell ? measurement.rawBytes : measurement.deltaBytes; const baselineGzipCost = isShell ? baseline.gzipBytes : baseline.gzipBytes - baselineShell.gzipBytes; const currentGzipCost = isShell ? measurement.gzipBytes : measurement.deltaGzipBytes; const baselineModuleCost = isShell ? baseline.moduleCount : baseline.moduleCount - baselineShell.moduleCount; - const currentModuleCost = isShell ? measurement.moduleCount : measurement.deltaModules; const costDelta = currentCost - baselineCost; return { status: 'compared', baselineCost, currentCost, + currentModuleCost, costDelta, costPercent: baselineCost === 0 ? 0 : (costDelta / baselineCost) * 100, gzipCostDelta: currentGzipCost - baselineGzipCost, @@ -239,7 +242,9 @@ function createMarkdownReport(results) { for (const result of results) { const { comparison } = result; if (comparison.status === 'new') { - lines.push(`| ${result.platform} | ${result.scenario} | New | ${(result.rawBytes / 1024).toFixed(1)} KiB | New | New | New | New |`); + lines.push( + `| ${result.platform} | ${result.scenario} | New | ${(comparison.currentCost / 1024).toFixed(1)} KiB | New | New | New | New |`, + ); } else { lines.push( `| ${result.platform} | ${result.scenario} | ${(comparison.baselineCost / 1024).toFixed(1)} KiB | ${(comparison.currentCost / 1024).toFixed(1)} KiB | ${formatBytes(comparison.costDelta)} | ${formatPercent(comparison.costPercent)} | ${formatBytes(comparison.gzipCostDelta)} | ${comparison.moduleCostDelta >= 0 ? '+' : ''}${comparison.moduleCostDelta} |`, @@ -317,6 +322,6 @@ const markdownReportPath = join(outputRoot, 'report.md'); writeJSONFileSync(reportPath, report); writeFileSync(markdownReportPath, createMarkdownReport(results)); -console.table(results); +process.stdout.write(`${formatBundleSizeTable(results)}\n`); process.stdout.write(`Results: ${reportPath}\n`); process.stdout.write(`Report: ${markdownReportPath}\n`); diff --git a/apps/bundle-size/scripts/post-pr-comment.mjs b/apps/bundle-size/scripts/post-pr-comment.mjs new file mode 100644 index 00000000000..31f5590cdf5 --- /dev/null +++ b/apps/bundle-size/scripts/post-pr-comment.mjs @@ -0,0 +1,184 @@ +import { readFile } from 'node:fs/promises'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +export const bundleSizeCommentMarker = ''; + +const reportHeader = '# Bundle size report'; +const reportDescription = + 'Tree-shaken production Metro bundles. Component costs are relative to their platform shell; shell costs are absolute.'; +const tableHeader = '| Platform | Scenario | Baseline cost | Current cost | Cost delta | Change | Gzip delta | Module delta |'; +const tableSeparator = '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |'; +const reportFooter = + 'The job is advisory: size changes are reported but do not fail the pull request. Bundle or analysis errors still fail.'; +const tableRowPattern = + /^\| [A-Za-z0-9][A-Za-z0-9._/-]{0,79} \| [A-Za-z0-9][A-Za-z0-9._:/-]{0,79} \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+\.\d{2}%) \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+) \|$/; +const maximumReportBytes = 60_000; +const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +export function validateBundleSizeReport(report) { + if (Buffer.byteLength(report, 'utf8') > maximumReportBytes) { + throw new Error(`Bundle-size report exceeds ${maximumReportBytes} bytes`); + } + + const normalized = report.replaceAll('\r\n', '\n').trimEnd(); + const lines = normalized.split('\n'); + if ( + lines[0] !== reportHeader || + lines[1] !== '' || + lines[2] !== reportDescription || + lines[3] !== '' || + lines[4] !== tableHeader || + lines[5] !== tableSeparator + ) { + throw new Error('Bundle-size report header does not match the trusted format'); + } + + const footerIndex = lines.length - 1; + if (footerIndex < 8 || lines[footerIndex - 1] !== '' || lines[footerIndex] !== reportFooter) { + throw new Error('Bundle-size report footer does not match the trusted format'); + } + + const rows = lines.slice(6, footerIndex - 1); + if (rows.length === 0 || rows.some((row) => !tableRowPattern.test(row))) { + throw new Error('Bundle-size report contains an invalid table row'); + } + + return normalized; +} + +export function createBundleSizeComment(report, repository, runId) { + if (!repositoryPattern.test(repository) || !/^\d+$/.test(runId)) { + throw new Error('Invalid bundle-size workflow identity'); + } + + const runUrl = `https://github.com/${repository}/actions/runs/${runId}`; + return `${bundleSizeCommentMarker}\n${validateBundleSizeReport(report)}\n\n---\n[View bundle-size workflow run](${runUrl})`; +} + +export function parsePullRequestNumber(value) { + const normalized = value.trim(); + if (!/^[1-9]\d*$/.test(normalized)) { + throw new Error(`Invalid pull request number: ${normalized}`); + } + + const pullRequestNumber = Number(normalized); + if (!Number.isSafeInteger(pullRequestNumber)) { + throw new Error(`Invalid pull request number: ${normalized}`); + } + + return pullRequestNumber; +} + +async function requestJson(fetchImplementation, url, token, options = {}) { + const response = await fetchImplementation(url, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API request failed (${response.status}): ${await response.text()}`); + } + + return response.status === 204 ? undefined : response.json(); +} + +export async function upsertBundleSizeComment({ + apiUrl = 'https://api.github.com', + body, + expectedHeadRepository, + expectedHeadSha, + fetchImplementation = fetch, + pullRequestNumber, + repository, + token, +}) { + if (!repositoryPattern.test(repository)) { + throw new Error(`Invalid GitHub repository: ${repository}`); + } + if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) { + throw new Error(`Invalid pull request number: ${pullRequestNumber}`); + } + + const repositoryPath = repository + .split('/') + .map((part) => encodeURIComponent(part)) + .join('/'); + const request = (path, options) => requestJson(fetchImplementation, `${apiUrl}/repos/${repositoryPath}${path}`, token, options); + const pullRequest = await request(`/pulls/${pullRequestNumber}`); + if (pullRequest.head.sha !== expectedHeadSha || pullRequest.head.repo?.full_name !== expectedHeadRepository) { + throw new Error('Workflow run does not match the current pull request head'); + } + + let existingComment; + for (let page = 1; page <= 20 && !existingComment; page += 1) { + const comments = await request(`/issues/${pullRequestNumber}/comments?per_page=100&page=${page}`); + existingComment = comments.find( + (comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(bundleSizeCommentMarker), + ); + if (comments.length < 100) { + break; + } + } + + if (existingComment) { + await request(`/issues/comments/${existingComment.id}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + }); + } else { + await request(`/issues/${pullRequestNumber}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + }); + } +} + +async function main() { + const { + EXPECTED_HEAD_REPOSITORY: expectedHeadRepository, + EXPECTED_HEAD_SHA: expectedHeadSha, + GITHUB_API_URL: apiUrl, + GITHUB_REPOSITORY: repository, + GITHUB_TOKEN: token, + PR_NUMBER: pullRequestNumberValue, + PR_NUMBER_PATH: pullRequestNumberPath, + REPORT_PATH: reportPath, + SOURCE_RUN_ID: runId, + } = process.env; + + if ( + !expectedHeadRepository || + !expectedHeadSha || + !repository || + !token || + (!pullRequestNumberValue && !pullRequestNumberPath) || + !reportPath || + !runId + ) { + throw new Error('Missing required bundle-size PR comment environment'); + } + + const pullRequestNumber = parsePullRequestNumber(pullRequestNumberValue ?? (await readFile(pullRequestNumberPath, 'utf8'))); + const report = await readFile(reportPath, 'utf8'); + const body = createBundleSizeComment(report, repository, runId); + await upsertBundleSizeComment({ + apiUrl, + body, + expectedHeadRepository, + expectedHeadSha, + pullRequestNumber, + repository, + token, + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/apps/bundle-size/scripts/post-pr-comment.test.mjs b/apps/bundle-size/scripts/post-pr-comment.test.mjs new file mode 100644 index 00000000000..cf156aca718 --- /dev/null +++ b/apps/bundle-size/scripts/post-pr-comment.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + bundleSizeCommentMarker, + createBundleSizeComment, + parsePullRequestNumber, + upsertBundleSizeComment, + validateBundleSizeReport, +} from './post-pr-comment.mjs'; + +const validReport = `# Bundle size report + +Tree-shaken production Metro bundles. Component costs are relative to their platform shell; shell costs are absolute. + +| Platform | Scenario | Baseline cost | Current cost | Cost delta | Change | Gzip delta | Module delta | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| macos | shell | 801.3 KiB | 801.3 KiB | 0.0 KiB | 0.00% | 0.0 KiB | +0 | +| macos | components-button | New | 46.4 KiB | New | New | New | New | + +The job is advisory: size changes are reported but do not fail the pull request. Bundle or analysis errors still fail. +`; + +function jsonResponse(value, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => value, + text: async () => JSON.stringify(value), + }; +} + +describe('validateBundleSizeReport', () => { + it('accepts the generated report shape', () => { + assert.equal(validateBundleSizeReport(validReport), validReport.trimEnd()); + }); + + it('rejects content outside the generated report shape', () => { + assert.throws( + () => validateBundleSizeReport(validReport.replace('components-button', '@reviewers [click](https://example.com)')), + /invalid table row/, + ); + }); +}); + +describe('createBundleSizeComment', () => { + it('adds a stable marker and trusted workflow link', () => { + const comment = createBundleSizeComment(validReport, 'microsoft/fluentui-react-native', '123'); + + assert.match(comment, new RegExp(`^${bundleSizeCommentMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + assert.match(comment, /https:\/\/github\.com\/microsoft\/fluentui-react-native\/actions\/runs\/123\)$/); + }); + + it('rejects untrusted workflow link inputs', () => { + assert.throws(() => createBundleSizeComment(validReport, 'other/repo', '123?redirect=example.com'), /workflow identity/); + }); +}); + +describe('parsePullRequestNumber', () => { + it('accepts artifact metadata containing one positive integer', () => { + assert.equal(parsePullRequestNumber('42\n'), 42); + }); + + it('rejects artifact metadata containing anything else', () => { + assert.throws(() => parsePullRequestNumber('42\n43'), /Invalid pull request number/); + assert.throws(() => parsePullRequestNumber('0'), /Invalid pull request number/); + }); +}); + +describe('upsertBundleSizeComment', () => { + it('updates the existing Actions comment after verifying the pull request head', async () => { + const requests = []; + const fetchImplementation = async (url, options) => { + requests.push({ url, options }); + if (url.endsWith('/pulls/42')) { + return jsonResponse({ head: { sha: 'abc123', repo: { full_name: 'contributor/fluentui-react-native' } } }); + } + if (url.includes('/issues/42/comments?')) { + return jsonResponse([{ id: 7, body: `${bundleSizeCommentMarker}\nold`, user: { login: 'github-actions[bot]' } }]); + } + if (url.endsWith('/issues/comments/7')) { + return jsonResponse({ id: 7 }); + } + throw new Error(`Unexpected request: ${url}`); + }; + + await upsertBundleSizeComment({ + body: `${bundleSizeCommentMarker}\nnew`, + expectedHeadRepository: 'contributor/fluentui-react-native', + expectedHeadSha: 'abc123', + fetchImplementation, + pullRequestNumber: 42, + repository: 'microsoft/fluentui-react-native', + token: 'test-token', + }); + + assert.equal(requests.at(-1).options.method, 'PATCH'); + assert.deepEqual(JSON.parse(requests.at(-1).options.body), { body: `${bundleSizeCommentMarker}\nnew` }); + }); + + it('creates a comment when no prior marker exists', async () => { + const requests = []; + const fetchImplementation = async (url, options) => { + requests.push({ url, options }); + if (url.endsWith('/pulls/42')) { + return jsonResponse({ head: { sha: 'abc123', repo: { full_name: 'microsoft/fluentui-react-native' } } }); + } + if (url.includes('/issues/42/comments?')) { + return jsonResponse([]); + } + if (url.endsWith('/issues/42/comments')) { + return jsonResponse({ id: 8 }, 201); + } + throw new Error(`Unexpected request: ${url}`); + }; + + await upsertBundleSizeComment({ + body: `${bundleSizeCommentMarker}\nnew`, + expectedHeadRepository: 'microsoft/fluentui-react-native', + expectedHeadSha: 'abc123', + fetchImplementation, + pullRequestNumber: 42, + repository: 'microsoft/fluentui-react-native', + token: 'test-token', + }); + + assert.equal(requests.at(-1).options.method, 'POST'); + }); + + it('rejects a stale workflow run before reading comments', async () => { + const fetchImplementation = async () => + jsonResponse({ head: { sha: 'new-head', repo: { full_name: 'contributor/fluentui-react-native' } } }); + + await assert.rejects( + upsertBundleSizeComment({ + body: `${bundleSizeCommentMarker}\nnew`, + expectedHeadRepository: 'contributor/fluentui-react-native', + expectedHeadSha: 'old-head', + fetchImplementation, + pullRequestNumber: 42, + repository: 'microsoft/fluentui-react-native', + token: 'test-token', + }), + /does not match the current pull request head/, + ); + }); + + it('rejects a pull request whose fork has been deleted', async () => { + const fetchImplementation = async () => jsonResponse({ head: { sha: 'abc123', repo: null } }); + + await assert.rejects( + upsertBundleSizeComment({ + body: `${bundleSizeCommentMarker}\nnew`, + expectedHeadRepository: 'contributor/fluentui-react-native', + expectedHeadSha: 'abc123', + fetchImplementation, + pullRequestNumber: 42, + repository: 'microsoft/fluentui-react-native', + token: 'test-token', + }), + /does not match the current pull request head/, + ); + }); +}); diff --git a/packages/agentic/components/package.json b/packages/agentic/components/package.json index a5ebc7e0459..008189ac5c6 100644 --- a/packages/agentic/components/package.json +++ b/packages/agentic/components/package.json @@ -10,6 +10,7 @@ "directory": "packages/agentic/components" }, "type": "module", + "sideEffects": false, "main": "lib/index.js", "module": "lib/index.js", "types": "lib/index.d.ts", diff --git a/packages/agentic/design/package.json b/packages/agentic/design/package.json index 2e153b84080..33beafb28ec 100644 --- a/packages/agentic/design/package.json +++ b/packages/agentic/design/package.json @@ -10,6 +10,7 @@ "directory": "packages/agentic/design" }, "type": "module", + "sideEffects": false, "main": "lib/index.js", "module": "lib/index.js", "types": "lib/index.d.ts", diff --git a/yarn.lock b/yarn.lock index 984eac97324..8df089993fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2621,6 +2621,7 @@ __metadata: "@rnx-kit/metro-resolver-symlinks": "catalog:" "@rnx-kit/metro-serializer-esbuild": "catalog:" "@rnx-kit/tools-filesystem": "npm:^0.2.0" + "@rnx-kit/tools-formatting": "npm:^0.1.0" "@types/react": "npm:~19.1.4" metro: "npm:^0.83.8" oxc-resolver: "catalog:"