main
js 489 lines 15.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 'use strict';
9
10 /* eslint-disable no-for-of-loops/no-for-of-loops */
11
12 // Turns `sizebot-results.json` into the body of the sizebot pull request
13 // comment. Runs from a checkout of the default branch, never from the pull
14 // request branch, so the thresholds, the critical bundle list and the table
15 // itself cannot be influenced by the pull request being measured. Everything it
16 // reads out of the results file is therefore treated as untrusted input.
17 //
18 // Reads `sizebot-context.json` (written by the resolve step) and, when the build
19 // produced one, `sizebot-results.json`. Writes `sizebot-comment.md`, plus
20 // `sizebot-message.md` when the report is too large to fit in a comment and
21 // `sizebot-problem.txt` when the build configuration no longer matches this
22 // file's expectations.
23
24 const {existsSync, readFileSync, writeFileSync} = require('fs');
25
26 // Results shapes this file knows how to read. `compare-sizes.js` on the pull
27 // request branch may be older or newer than this list.
28 const SUPPORTED_VERSIONS = new Set([1]);
29 const SUPPORTED_STATUSES = new Set(['ok', 'base-artifacts-unavailable']);
30
31 const CRITICAL_THRESHOLD = 0.02;
32 const SIGNIFICANCE_THRESHOLD = 0.002;
33 const CRITICAL_ARTIFACT_PATHS = new Set([
34 // We always report changes to these bundles, even if the change is
35 // insignificant or non-existent.
36 'oss-stable/react-dom/cjs/react-dom.production.js',
37 'oss-stable/react-dom/cjs/react-dom-client.production.js',
38 'oss-experimental/react-dom/cjs/react-dom.production.js',
39 'oss-experimental/react-dom/cjs/react-dom-client.production.js',
40 'facebook-www/ReactDOM-prod.classic.js',
41 'facebook-www/ReactDOM-prod.modern.js',
42 ]);
43
44 // GitHub comments are limited to 65536 characters.
45 const MAX_COMMENT_LENGTH = 65536;
46
47 // Both the notice and the report are delimited so each can be rewritten without
48 // disturbing the other, and so a report can be read back out of a comment
49 // verbatim when a newer build supersedes it. Relying on "everything after the
50 // notice" instead would swallow the footer and append a second one every time.
51 const MARKER_PREFIX = '<!-- sizebot-comment';
52 const NOTICE_START = '<!-- sizebot-notice-start -->';
53 const NOTICE_END = '<!-- sizebot-notice-end -->';
54 const REPORT_START = '<!-- sizebot-report-start -->';
55 const REPORT_END = '<!-- sizebot-report-end -->';
56
57 const CONTEXT_PATH = 'sizebot-context.json';
58 const RESULTS_PATH = 'sizebot-results.json';
59 const COMMENT_PATH = 'sizebot-comment.md';
60 const MESSAGE_PATH = 'sizebot-message.md';
61 const PROBLEM_PATH = 'sizebot-problem.txt';
62
63 // Build artifact paths end up inside markdown link text and inside a URL, so
64 // anything that could break out of either is rejected rather than escaped.
65 const SAFE_ARTIFACT_PATH = /^[A-Za-z0-9_@./+-]+$/;
66
67 function isSafeArtifactPath(value) {
68 return (
69 typeof value === 'string' &&
70 value.length > 0 &&
71 value.length < 512 &&
72 SAFE_ARTIFACT_PATH.test(value) &&
73 !value.includes('..') &&
74 !value.startsWith('/')
75 );
76 }
77
78 function isSize(value) {
79 return value === null || (Number.isFinite(value) && value >= 0);
80 }
81
82 function isSha(value) {
83 return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value);
84 }
85
86 const kilobyteFormatter = new Intl.NumberFormat('en', {
87 style: 'unit',
88 unit: 'kilobyte',
89 minimumFractionDigits: 2,
90 maximumFractionDigits: 2,
91 });
92
93 function kbs(bytes) {
94 // An artifact that exists on only one side has no size on the other. The
95 // report has always shown that as 0.00 kB rather than an empty cell.
96 return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000);
97 }
98
99 const percentFormatter = new Intl.NumberFormat('en', {
100 style: 'percent',
101 signDisplay: 'exceptZero',
102 minimumFractionDigits: 2,
103 maximumFractionDigits: 2,
104 });
105
106 function ratio(baseSize, headSize) {
107 if (baseSize === null) {
108 return Infinity;
109 }
110 if (headSize === null) {
111 return -1;
112 }
113 return (headSize - baseSize) / baseSize;
114 }
115
116 function change(decimal) {
117 if (decimal === Infinity) {
118 return 'New file';
119 }
120 if (decimal === -1) {
121 return 'Deleted';
122 }
123 // Compare the magnitude, not the signed value. Testing `decimal < 0.0001`
124 // reported every size decrease as unchanged, which is why `signDisplay:
125 // 'exceptZero'` above never had a negative number to render.
126 if (Math.abs(decimal) < 0.0001) {
127 return '=';
128 }
129 return percentFormatter.format(decimal);
130 }
131
132 const header = `| Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip |
133 | ---- | --- | ---- | ------- | -------- | --------- | ------------ |`;
134
135 function row(result, baseSha, headSha) {
136 const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`;
137 const rowArr = [
138 `| [${result.path}](${diffViewUrl})`,
139 `**${change(result.change)}**`,
140 `${kbs(result.baseSize)}`,
141 `${kbs(result.headSize)}`,
142 `${change(result.changeGzip)}`,
143 `${kbs(result.baseSizeGzip)}`,
144 `${kbs(result.headSizeGzip)}`,
145 ];
146 return rowArr.join(' | ');
147 }
148
149 function validateResults(raw) {
150 if (raw === null || typeof raw !== 'object') {
151 return {ok: false, reason: 'malformed'};
152 }
153 // Checked before anything else so a shape this file cannot read produces a
154 // clear message instead of a misrendered table.
155 if (!SUPPORTED_VERSIONS.has(raw.version)) {
156 return {ok: false, reason: 'unsupported-version'};
157 }
158 if (!SUPPORTED_STATUSES.has(raw.status)) {
159 return {ok: false, reason: 'malformed'};
160 }
161 if (raw.status === 'base-artifacts-unavailable') {
162 return {ok: true, results: {status: raw.status}};
163 }
164 if (!isSha(raw.baseSha) || !isSha(raw.headSha)) {
165 return {ok: false, reason: 'malformed'};
166 }
167 if (!Array.isArray(raw.artifacts)) {
168 return {ok: false, reason: 'malformed'};
169 }
170 for (const artifact of raw.artifacts) {
171 if (artifact === null || typeof artifact !== 'object') {
172 return {ok: false, reason: 'malformed'};
173 }
174 if (!isSafeArtifactPath(artifact.path)) {
175 return {ok: false, reason: 'malformed'};
176 }
177 if (
178 !isSize(artifact.baseSize) ||
179 !isSize(artifact.baseSizeGzip) ||
180 !isSize(artifact.headSize) ||
181 !isSize(artifact.headSizeGzip)
182 ) {
183 return {ok: false, reason: 'malformed'};
184 }
185 if (artifact.baseSize === null && artifact.headSize === null) {
186 return {ok: false, reason: 'malformed'};
187 }
188 }
189 return {ok: true, results: raw};
190 }
191
192 function renderTable(results) {
193 const {baseSha, headSha} = results;
194
195 const resultsMap = new Map();
196 for (const artifact of results.artifacts) {
197 resultsMap.set(artifact.path, {
198 ...artifact,
199 change: ratio(artifact.baseSize, artifact.headSize),
200 changeGzip: ratio(artifact.baseSizeGzip, artifact.headSizeGzip),
201 });
202 }
203
204 const sorted = Array.from(resultsMap.values());
205 sorted.sort((a, b) => b.change - a.change);
206
207 const criticalResults = [];
208 const missingCriticalPaths = [];
209 for (const artifactPath of CRITICAL_ARTIFACT_PATHS) {
210 const result = resultsMap.get(artifactPath);
211 if (result === undefined) {
212 missingCriticalPaths.push(artifactPath);
213 continue;
214 }
215 criticalResults.push(row(result, baseSha, headSha));
216 }
217
218 const significantResults = [];
219 for (const result of sorted) {
220 // If result exceeds critical threshold, add to top section.
221 if (
222 (Math.abs(result.change) > CRITICAL_THRESHOLD ||
223 // New file
224 result.change === Infinity ||
225 // Deleted file
226 result.change === -1) &&
227 // Skip critical artifacts. We added those earlier, in a fixed order.
228 !CRITICAL_ARTIFACT_PATHS.has(result.path)
229 ) {
230 criticalResults.push(row(result, baseSha, headSha));
231 }
232
233 // Do the same for results that exceed the significant threshold. These
234 // will go into the bottom, collapsed section. Intentionally including
235 // critical artifacts in this section, too.
236 if (
237 Math.abs(result.change) > SIGNIFICANCE_THRESHOLD ||
238 result.change === Infinity ||
239 result.change === -1
240 ) {
241 significantResults.push(row(result, baseSha, headSha));
242 }
243 }
244
245 const markdown = `Comparing: ${baseSha}...${headSha}
246
247 ## Critical size changes
248
249 Includes critical production bundles, as well as any change greater than ${
250 CRITICAL_THRESHOLD * 100
251 }%:
252
253 ${header}
254 ${criticalResults.join('\n')}
255
256 ## Significant size changes
257
258 Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%:
259
260 ${
261 significantResults.length > 0
262 ? `<details>
263 <summary>Expand to show</summary>
264
265 ${header}
266 ${significantResults.join('\n')}
267 </details>`
268 : '(No significant changes)'
269 }`;
270
271 return {markdown, missingCriticalPaths};
272 }
273
274 function renderCompletedReport(context) {
275 const {runConclusion, runUrl, devtoolsOnly} = context;
276
277 // The common outcome for a first-time contributor's pull request: the run is
278 // created but held until a maintainer approves it.
279 if (runConclusion === 'action_required') {
280 return {
281 markdown: `[The build for this commit](${runUrl}) needs maintainer approval before it can run, so there is no size report yet.`,
282 missingCriticalPaths: [],
283 };
284 }
285
286 if (runConclusion !== 'success') {
287 return {
288 markdown: `The build for this commit did not complete, so there is no size report. See [the workflow run](${runUrl}) for details.`,
289 missingCriticalPaths: [],
290 };
291 }
292
293 if (devtoolsOnly) {
294 return {
295 markdown:
296 'No size report: this pull request only touches `packages/react-devtools`, which does not affect production bundle size.',
297 missingCriticalPaths: [],
298 };
299 }
300
301 if (!existsSync(RESULTS_PATH)) {
302 return {
303 markdown: `The build succeeded but produced no size results, so there is no size report. See [the workflow run](${runUrl}) for details.`,
304 missingCriticalPaths: [],
305 };
306 }
307
308 let raw;
309 try {
310 raw = JSON.parse(readFileSync(RESULTS_PATH, 'utf8'));
311 } catch {
312 raw = null;
313 }
314
315 const validated = validateResults(raw);
316 if (!validated.ok) {
317 if (validated.reason === 'unsupported-version') {
318 return {
319 markdown:
320 'This pull request produced a size report in a format this repository no longer reads. ' +
321 'Merge the latest changes from the `main` branch to pick up the current one.',
322 missingCriticalPaths: [],
323 };
324 }
325 return {
326 markdown: `The size results for this commit could not be read, so there is no size report. See [the workflow run](${runUrl}) for details.`,
327 missingCriticalPaths: [],
328 };
329 }
330
331 if (validated.results.status === 'base-artifacts-unavailable') {
332 return {
333 markdown:
334 "Failed to read build artifacts. It's possible a build configuration has changed upstream. " +
335 'Try pulling the latest changes from the `main` branch.',
336 missingCriticalPaths: [],
337 };
338 }
339
340 return renderTable(validated.results);
341 }
342
343 function renderNotice(context, reportHead) {
344 const {action, prHeadSha, runHeadSha, runStatus, runUrl} = context;
345 const lines = [];
346
347 // One rule covers both the case where an older run's results arrive after the
348 // head moved, and the case where a new build supersedes a report already on
349 // display: the report simply is not about the pull request's current head.
350 if (reportHead !== null && reportHead !== prHeadSha) {
351 lines.push(
352 `These sizes are for ${reportHead}, which is no longer the head of this pull request.`
353 );
354 if (action === 'requested') {
355 lines.push(`A build for ${runHeadSha} is in progress.`);
356 }
357 } else if (action === 'requested' && runStatus === 'waiting') {
358 lines.push(
359 `[The build for this commit](${runUrl}) is waiting for maintainer approval before it can run.`
360 );
361 }
362
363 if (lines.length === 0) {
364 return '';
365 }
366 return lines.map(line => `> ${line}`).join('\n> \n');
367 }
368
369 function renderBody(context) {
370 let reportHead;
371 let report;
372 let missingCriticalPaths = [];
373
374 if (context.action === 'requested') {
375 // Only a comment that names the commit it describes holds real numbers. A
376 // previous placeholder has body text too, but carrying that forward would
377 // pin the comment to a stale run link instead of refreshing it.
378 if (
379 context.existingReportHead !== null &&
380 context.existingReport !== null
381 ) {
382 // Keep the numbers from the previous build visible. The notice below
383 // explains that they describe an older commit.
384 reportHead = context.existingReportHead;
385 report = context.existingReport;
386 } else {
387 reportHead = null;
388 report = `A size report will appear here when [the build](${context.runUrl}) finishes.`;
389 }
390 } else {
391 reportHead = context.runHeadSha;
392 const rendered = renderCompletedReport(context);
393 report = rendered.markdown;
394 missingCriticalPaths = rendered.missingCriticalPaths;
395 }
396
397 if (missingCriticalPaths.length > 0) {
398 report =
399 '> [!CAUTION]\n' +
400 '> These critical bundles are missing from the build. If that was an intentional\n' +
401 '> change to the build configuration, update `CRITICAL_ARTIFACT_PATHS` in\n' +
402 '> `scripts/sizebot/render-comment.js`:\n' +
403 missingCriticalPaths.map(p => `> - \`${p}\``).join('\n') +
404 '\n\n' +
405 report;
406 }
407
408 const notice = renderNotice(context, reportHead);
409 const footerSha = reportHead === null ? context.runHeadSha : reportHead;
410
411 function assemble(reportRegion) {
412 return `${MARKER_PREFIX} report-head=${
413 reportHead === null ? 'none' : reportHead
414 } -->
415 ${NOTICE_START}
416 ${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END}
417 ${REPORT_START}
418 ${reportRegion}
419 ${REPORT_END}
420
421 <sub>Generated by sizebot against ${footerSha}</sub>
422 `;
423 }
424
425 return {
426 body: assemble(report),
427 report,
428 assemble,
429 reportHead,
430 missingCriticalPaths,
431 };
432 }
433
434 // Reads the report region back out of a comment, so a completed report can be
435 // carried forward when a new build is requested for a newer commit.
436 function extractReport(body) {
437 const start = body.indexOf(REPORT_START);
438 const end = body.indexOf(REPORT_END);
439 if (start === -1 || end === -1 || end < start) {
440 return null;
441 }
442 const report = body.slice(start + REPORT_START.length, end).trim();
443 return report === '' ? null : report;
444 }
445
446 function parseReportHead(body) {
447 const match =
448 /<!-- sizebot-comment report-head=([0-9a-f]{7,40}|none) -->/.exec(body);
449 if (match === null || match[1] === 'none') {
450 return null;
451 }
452 return match[1];
453 }
454
455 function main() {
456 const context = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8'));
457 const {body, report, assemble, missingCriticalPaths} = renderBody(context);
458
459 let comment = body;
460 if (body.length > MAX_COMMENT_LENGTH) {
461 // The link resolves because the artifact is uploaded to this same run,
462 // before the comment is posted.
463 writeFileSync(MESSAGE_PATH, report + '\n');
464 comment = assemble(
465 `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.`
466 );
467 }
468 writeFileSync(COMMENT_PATH, comment);
469
470 if (missingCriticalPaths.length > 0) {
471 writeFileSync(
472 PROBLEM_PATH,
473 `Missing expected bundles:\n${missingCriticalPaths.join('\n')}\n`
474 );
475 }
476
477 process.stdout.write(comment);
478 }
479
480 module.exports = {
481 MARKER_PREFIX,
482 extractReport,
483 parseReportHead,
484 renderBody,
485 };
486
487 if (require.main === module) {
488 main();
489 }