Files
Sebastian "Sebbie" SilbermannandClaude Code 084f5f2457 [ci] Compare size against merge base (#37355)
Sizebot compared the pull request head build against the build of
`pull_request.base.sha`, which is the tip of the base branch at event
time, not the commit the pull request diverged from. The field's
semantics are undocumented in GitHub's API schema (the OpenAPI
description types it as a bare string); the observed behavior and the
compare API's `merge_base_commit` confirm the difference.

The difference between `pull_request.base.sha` and the merge base is
confirmed with an example in https://github.com/react/react/pull/37356

The sizebot job now resolves the merge-base through the compare API and
downloads the base build for that commit instead, so the report only
ever contains the pull request's own changes. The job gains `contents:
read` for the compare call.

When no base build can be downloaded for the merge-base, for example
because its artifacts aged out of the retention window or its run
failed, the sizebot job records a `base-build-not-found` result instead
of failing immediately. `render-comment.js` on the default branch
renders that as a warning comment naming the base commit and writes the
`sizebot-problem.txt` marker, so the comment workflow fails its check
after posting the warning, the same pattern already used for build
configuration drift. The sizebot job itself intentionally stays green: a
failed run would make the renderer discard the results and mask the
warning with a generic "did not complete" message.

Co-authored-by: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
2026-09-02 12:29:06 +02:00

527 lines
16 KiB
JavaScript

/**
* 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 = '<!-- sizebot-comment';
const NOTICE_START = '<!-- sizebot-notice-start -->';
const NOTICE_END = '<!-- sizebot-notice-end -->';
const REPORT_START = '<!-- sizebot-report-start -->';
const REPORT_END = '<!-- sizebot-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
? `<details>
<summary>Expand to show</summary>
${header}
${significantResults.join('\n')}
</details>`
: '(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}
<sub>Generated by sizebot against ${footerSha}</sub>
`;
}
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 =
/<!-- sizebot-comment report-head=([0-9a-f]{7,40}|none) -->/.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();
}