/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; /* eslint-disable no-for-of-loops/no-for-of-loops */ // Turns `sizebot-results.json` into the body of the sizebot pull request // comment. Runs from a checkout of the default branch, never from the pull // request branch, so the thresholds, the critical bundle list and the table // itself cannot be influenced by the pull request being measured. Everything it // reads out of the results file is therefore treated as untrusted input. // // Reads `sizebot-context.json` (written by the resolve step) and, when the build // produced one, `sizebot-results.json`. Writes `sizebot-comment.md`, plus // `sizebot-message.md` when the report is too large to fit in a comment and // `sizebot-problem.txt` when the build configuration no longer matches this // file's expectations. const {existsSync, readFileSync, writeFileSync} = require('fs'); // Results shapes this file knows how to read. `compare-sizes.js` on the pull // request branch may be older or newer than this list. const SUPPORTED_VERSIONS = new Set([1]); const SUPPORTED_STATUSES = new Set([ 'ok', 'base-artifacts-unavailable', 'base-build-not-found', ]); const CRITICAL_THRESHOLD = 0.02; const SIGNIFICANCE_THRESHOLD = 0.002; const CRITICAL_ARTIFACT_PATHS = new Set([ // We always report changes to these bundles, even if the change is // insignificant or non-existent. 'oss-stable/react-dom/cjs/react-dom.production.js', 'oss-stable/react-dom/cjs/react-dom-client.production.js', 'oss-experimental/react-dom/cjs/react-dom.production.js', 'oss-experimental/react-dom/cjs/react-dom-client.production.js', 'facebook-www/ReactDOM-prod.classic.js', 'facebook-www/ReactDOM-prod.modern.js', ]); // GitHub comments are limited to 65536 characters. const MAX_COMMENT_LENGTH = 65536; // Both the notice and the report are delimited so each can be rewritten without // disturbing the other, and so a report can be read back out of a comment // verbatim when a newer build supersedes it. Relying on "everything after the // notice" instead would swallow the footer and append a second one every time. const MARKER_PREFIX = ''; const NOTICE_END = ''; const REPORT_START = ''; const REPORT_END = ''; const CONTEXT_PATH = 'sizebot-context.json'; const RESULTS_PATH = 'sizebot-results.json'; const COMMENT_PATH = 'sizebot-comment.md'; const MESSAGE_PATH = 'sizebot-message.md'; const PROBLEM_PATH = 'sizebot-problem.txt'; // Build artifact paths end up inside markdown link text and inside a URL, so // anything that could break out of either is rejected rather than escaped. const SAFE_ARTIFACT_PATH = /^[A-Za-z0-9_@./+-]+$/; function isSafeArtifactPath(value) { return ( typeof value === 'string' && value.length > 0 && value.length < 512 && SAFE_ARTIFACT_PATH.test(value) && !value.includes('..') && !value.startsWith('/') ); } function isSize(value) { return value === null || (Number.isFinite(value) && value >= 0); } function isSha(value) { return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value); } const kilobyteFormatter = new Intl.NumberFormat('en', { style: 'unit', unit: 'kilobyte', minimumFractionDigits: 2, maximumFractionDigits: 2, }); function kbs(bytes) { // An artifact that exists on only one side has no size on the other. The // report has always shown that as 0.00 kB rather than an empty cell. return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000); } const percentFormatter = new Intl.NumberFormat('en', { style: 'percent', signDisplay: 'exceptZero', minimumFractionDigits: 2, maximumFractionDigits: 2, }); function ratio(baseSize, headSize) { if (baseSize === null) { return Infinity; } if (headSize === null) { return -1; } return (headSize - baseSize) / baseSize; } function change(decimal) { if (decimal === Infinity) { return 'New file'; } if (decimal === -1) { return 'Deleted'; } // Compare the magnitude, not the signed value. Testing `decimal < 0.0001` // reported every size decrease as unchanged, which is why `signDisplay: // 'exceptZero'` above never had a negative number to render. if (Math.abs(decimal) < 0.0001) { return '='; } return percentFormatter.format(decimal); } const header = `| Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip | | ---- | --- | ---- | ------- | -------- | --------- | ------------ |`; function row(result, baseSha, headSha) { const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`; const rowArr = [ `| [${result.path}](${diffViewUrl})`, `**${change(result.change)}**`, `${kbs(result.baseSize)}`, `${kbs(result.headSize)}`, `${change(result.changeGzip)}`, `${kbs(result.baseSizeGzip)}`, `${kbs(result.headSizeGzip)}`, ]; return rowArr.join(' | '); } function validateResults(raw) { if (raw === null || typeof raw !== 'object') { return {ok: false, reason: 'malformed'}; } // Checked before anything else so a shape this file cannot read produces a // clear message instead of a misrendered table. if (!SUPPORTED_VERSIONS.has(raw.version)) { return {ok: false, reason: 'unsupported-version'}; } if (!SUPPORTED_STATUSES.has(raw.status)) { return {ok: false, reason: 'malformed'}; } if (raw.status === 'base-artifacts-unavailable') { return {ok: true, results: {status: raw.status}}; } if (raw.status === 'base-build-not-found') { return { ok: true, results: { status: raw.status, baseSha: isSha(raw.baseSha) ? raw.baseSha : null, }, }; } if (!isSha(raw.baseSha) || !isSha(raw.headSha)) { return {ok: false, reason: 'malformed'}; } if (!Array.isArray(raw.artifacts)) { return {ok: false, reason: 'malformed'}; } for (const artifact of raw.artifacts) { if (artifact === null || typeof artifact !== 'object') { return {ok: false, reason: 'malformed'}; } if (!isSafeArtifactPath(artifact.path)) { return {ok: false, reason: 'malformed'}; } if ( !isSize(artifact.baseSize) || !isSize(artifact.baseSizeGzip) || !isSize(artifact.headSize) || !isSize(artifact.headSizeGzip) ) { return {ok: false, reason: 'malformed'}; } if (artifact.baseSize === null && artifact.headSize === null) { return {ok: false, reason: 'malformed'}; } } return {ok: true, results: raw}; } function renderTable(results) { const {baseSha, headSha} = results; const resultsMap = new Map(); for (const artifact of results.artifacts) { resultsMap.set(artifact.path, { ...artifact, change: ratio(artifact.baseSize, artifact.headSize), changeGzip: ratio(artifact.baseSizeGzip, artifact.headSizeGzip), }); } const sorted = Array.from(resultsMap.values()); sorted.sort((a, b) => b.change - a.change); const criticalResults = []; const missingCriticalPaths = []; for (const artifactPath of CRITICAL_ARTIFACT_PATHS) { const result = resultsMap.get(artifactPath); if (result === undefined) { missingCriticalPaths.push(artifactPath); continue; } criticalResults.push(row(result, baseSha, headSha)); } const significantResults = []; for (const result of sorted) { // If result exceeds critical threshold, add to top section. if ( (Math.abs(result.change) > CRITICAL_THRESHOLD || // New file result.change === Infinity || // Deleted file result.change === -1) && // Skip critical artifacts. We added those earlier, in a fixed order. !CRITICAL_ARTIFACT_PATHS.has(result.path) ) { criticalResults.push(row(result, baseSha, headSha)); } // Do the same for results that exceed the significant threshold. These // will go into the bottom, collapsed section. Intentionally including // critical artifacts in this section, too. if ( Math.abs(result.change) > SIGNIFICANCE_THRESHOLD || result.change === Infinity || result.change === -1 ) { significantResults.push(row(result, baseSha, headSha)); } } const markdown = `Comparing: ${baseSha}...${headSha} ## Critical size changes Includes critical production bundles, as well as any change greater than ${ CRITICAL_THRESHOLD * 100 }%: ${header} ${criticalResults.join('\n')} ## Significant size changes Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%: ${ significantResults.length > 0 ? `
Expand to show ${header} ${significantResults.join('\n')}
` : '(No significant changes)' }`; return {markdown, missingCriticalPaths}; } function renderCompletedReport(context) { const {runConclusion, runUrl, devtoolsOnly} = context; // The common outcome for a first-time contributor's pull request: the run is // created but held until a maintainer approves it. if (runConclusion === 'action_required') { return { markdown: `[The build for this commit](${runUrl}) needs maintainer approval before it can run, so there is no size report yet.`, missingCriticalPaths: [], }; } if (runConclusion !== 'success') { return { markdown: `The build for this commit did not complete, so there is no size report. See [the workflow run](${runUrl}) for details.`, missingCriticalPaths: [], }; } if (devtoolsOnly) { return { markdown: 'No size report: this pull request only touches `packages/react-devtools`, which does not affect production bundle size.', missingCriticalPaths: [], }; } if (!existsSync(RESULTS_PATH)) { return { markdown: `The build succeeded but produced no size results, so there is no size report. See [the workflow run](${runUrl}) for details.`, missingCriticalPaths: [], }; } let raw; try { raw = JSON.parse(readFileSync(RESULTS_PATH, 'utf8')); } catch { raw = null; } const validated = validateResults(raw); if (!validated.ok) { if (validated.reason === 'unsupported-version') { return { markdown: 'This pull request produced a size report in a format this repository no longer reads. ' + 'Merge the latest changes from the `main` branch to pick up the current one.', missingCriticalPaths: [], }; } return { markdown: `The size results for this commit could not be read, so there is no size report. See [the workflow run](${runUrl}) for details.`, missingCriticalPaths: [], }; } if (validated.results.status === 'base-artifacts-unavailable') { return { markdown: "Failed to read build artifacts. It's possible a build configuration has changed upstream. " + 'Try pulling the latest changes from the `main` branch.', missingCriticalPaths: [], }; } if (validated.results.status === 'base-build-not-found') { const {baseSha} = validated.results; return { markdown: `No build was found for the base commit${ baseSha === null ? '' : ` (${baseSha})` } that this pull request diverged from, so there is no size report. ` + 'The build for that commit may have failed, or its artifacts may be ' + 'older than the retention window. Rebase the pull request onto a ' + 'newer `main` to compare against a base commit that has a build.', missingCriticalPaths: [], problem: `No base build found for ${ baseSha === null ? 'the merge-base' : baseSha }`, }; } return renderTable(validated.results); } function renderNotice(context, reportHead) { const {action, prHeadSha, runHeadSha, runStatus, runUrl} = context; const lines = []; // One rule covers both the case where an older run's results arrive after the // head moved, and the case where a new build supersedes a report already on // display: the report simply is not about the pull request's current head. if (reportHead !== null && reportHead !== prHeadSha) { lines.push( `These sizes are for ${reportHead}, which is no longer the head of this pull request.` ); if (action === 'requested') { lines.push(`A build for ${runHeadSha} is in progress.`); } } else if (action === 'requested' && runStatus === 'waiting') { lines.push( `[The build for this commit](${runUrl}) is waiting for maintainer approval before it can run.` ); } if (lines.length === 0) { return ''; } return lines.map(line => `> ${line}`).join('\n> \n'); } function renderBody(context) { let reportHead; let report; let missingCriticalPaths = []; let problem; if (context.action === 'requested') { // Only a comment that names the commit it describes holds real numbers. A // previous placeholder has body text too, but carrying that forward would // pin the comment to a stale run link instead of refreshing it. if ( context.existingReportHead !== null && context.existingReport !== null ) { // Keep the numbers from the previous build visible. The notice below // explains that they describe an older commit. reportHead = context.existingReportHead; report = context.existingReport; } else { reportHead = null; report = `A size report will appear here when [the build](${context.runUrl}) finishes.`; } } else { reportHead = context.runHeadSha; const rendered = renderCompletedReport(context); report = rendered.markdown; missingCriticalPaths = rendered.missingCriticalPaths; problem = rendered.problem; } if (missingCriticalPaths.length > 0) { report = '> [!CAUTION]\n' + '> These critical bundles are missing from the build. If that was an intentional\n' + '> change to the build configuration, update `CRITICAL_ARTIFACT_PATHS` in\n' + '> `scripts/sizebot/render-comment.js`:\n' + missingCriticalPaths.map(p => `> - \`${p}\``).join('\n') + '\n\n' + report; } const notice = renderNotice(context, reportHead); const footerSha = reportHead === null ? context.runHeadSha : reportHead; function assemble(reportRegion) { return `${MARKER_PREFIX} report-head=${ reportHead === null ? 'none' : reportHead } --> ${NOTICE_START} ${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END} ${REPORT_START} ${reportRegion} ${REPORT_END} Generated by sizebot against ${footerSha} `; } return { body: assemble(report), report, assemble, reportHead, missingCriticalPaths, problem, }; } // Reads the report region back out of a comment, so a completed report can be // carried forward when a new build is requested for a newer commit. function extractReport(body) { const start = body.indexOf(REPORT_START); const end = body.indexOf(REPORT_END); if (start === -1 || end === -1 || end < start) { return null; } const report = body.slice(start + REPORT_START.length, end).trim(); return report === '' ? null : report; } function parseReportHead(body) { const match = //.exec(body); if (match === null || match[1] === 'none') { return null; } return match[1]; } function main() { const context = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8')); const {body, report, assemble, missingCriticalPaths, problem} = renderBody(context); let comment = body; if (body.length > MAX_COMMENT_LENGTH) { // The link resolves because the artifact is uploaded to this same run, // before the comment is posted. writeFileSync(MESSAGE_PATH, report + '\n'); comment = assemble( `The size diff is too large to display in a single comment. [This workflow run](${context.commentRunUrl}) contains an artifact called \`sizebot-message.md\` with the full report.` ); } writeFileSync(COMMENT_PATH, comment); if (missingCriticalPaths.length > 0) { writeFileSync( PROBLEM_PATH, `Missing expected bundles:\n${missingCriticalPaths.join('\n')}\n` ); } if (problem !== undefined) { writeFileSync(PROBLEM_PATH, problem + '\n'); } process.stdout.write(comment); } module.exports = { MARKER_PREFIX, extractReport, parseReportHead, renderBody, }; if (require.main === module) { main(); }