Files
Sebastian "Sebbie" SilbermannandClaude Code 675a29c3e9 [ci] Rebuild sizebot on the GitHub Actions token (#37322)
The build size comparison comment was posted by Danger, which
authenticated with a personal access token hardcoded in
`scripts/tasks/danger.js`. That token has since been revoked, so sizebot
has been posting nothing at all (due to e.g.
https://github.com/react/react/actions/runs/32181295467/job/95855395224?pr=37315).
This change rebuilds it on the short-lived `GITHUB_TOKEN` that Actions
mints per run and a new workflow only responsible for rendering
untrusted JSON input as markdown in a PR comment.

A straight token swap would not have worked. Fork pull requests did
receive sizebot comments, but only because the token was in checked-out
source: the sizebot job runs on the `pull_request` trigger, where a
fork's `GITHUB_TOKEN` is read-only and cannot comment. The comment
therefore moves to a new `workflow_run` workflow,
`runtime_sizebot_comment.yml`, which runs in this repository with a
writable token no matter where the pull request came from. It posts a
placeholder when a build is requested and rewrites it in place when the
build completes, fails, is cancelled, or is held for maintainer
approval.

The measurement stays on the unprivileged side of that boundary which
are recorded as raw sizes into a `sizebot-results` artifact, and the new
workflow downloads only that JSON and renders it from a default-branch
checkout. The job holding `pull-requests: write` never unpacks a build
produced by a fork, which matters because the existing base-build
download justifies using an unverified artifact on the grounds that the
job has restricted permissions. Thresholds, the critical bundle list,
and the comment template all live on the trusted side, and the renderer
validates every field it reads out of the artifact so that a crafted
build path cannot inject markdown. The pull request number is resolved
from the API rather than from the artifact, since a number read from
fork-controlled data would let any contributor post a bot comment on an
arbitrary pull request.

Resolving that number needs a branch lookup rather than any of the
obvious approaches. `workflow_run.pull_requests` is empty for fork runs,
and neither `commits/{sha}/pulls` nor the search API indexes fork pull
request head commits, so the workflow looks the pull request up by
`owner:ref` instead.

A comment is only ever left alone in one situation: when it already
describes the pull request's current head and the event being handled
belongs to an older commit. Everything else is written, and marked stale
whenever the report does not describe the current head. That single rule
covers both an old run finishing after a force push and a new build
superseding a report already on display, and in the latter case the
previous numbers stay visible instead of being blanked back to a
placeholder.

The results file carries a `version` field. Its writer is whatever
`compare-sizes.js` a pull request branch happens to carry, while its
reader is on the default branch, so the two can mismatch and the
renderer needs to be able to say so instead of misrendering a table.

Porting the table fixed a longstanding bug in `change()`. Testing
`decimal < 0.0001` reported every size decrease as unchanged, which is
why `signDisplay: 'exceptZero'` never had a negative number to render: a
709.04 kB to 708.68 kB drop printed as `=`. It now compares the
magnitude.

Co-authored-by: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
2026-08-23 17:29:26 +02:00

115 lines
3.8 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 */
// Measures every build artifact in `build` against the base revision's artifacts
// in `base-build` and writes the raw numbers to `sizebot-results.json`.
//
// This runs in the `pull_request` half of CI, where the GitHub token is
// read-only, so it never talks to the API and never renders anything
// user-facing. `render-comment.js` turns this JSON into the pull request comment
// from a trusted checkout. See `.github/workflows/runtime_sizebot_comment.yml`
// for why the two halves are separate.
const {promisify} = require('util');
const glob = promisify(require('glob'));
const gzipSize = require('gzip-size');
const {readFileSync, statSync, writeFileSync} = require('fs');
// Bump on any incompatible change to the JSON below: added required fields,
// renamed or removed fields, or a changed meaning for an existing one. Purely
// additive optional fields do not need a bump. The reader lives on the default
// branch while the writer lives on the pull request branch, so the two can
// legitimately disagree and `render-comment.js` needs to be able to tell.
const RESULTS_VERSION = 1;
const RESULTS_PATH = 'sizebot-results.json';
const BASE_DIR = 'base-build';
const HEAD_DIR = 'build';
function measure(dir, artifactPath) {
const file = dir + '/' + artifactPath;
return {
size: statSync(file).size,
sizeGzip: gzipSize.fileSync(file),
};
}
function writeResults(results) {
writeFileSync(RESULTS_PATH, JSON.stringify(results, null, 2) + '\n');
}
(async function () {
let headSha;
let baseSha;
try {
headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim();
baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim();
} catch {
// Let the renderer explain this one. It is expected to happen whenever the
// build configuration changes upstream, which is not a CI failure.
writeResults({
version: RESULTS_VERSION,
status: 'base-artifacts-unavailable',
});
return;
}
// A missing size is recorded as null rather than 0, so the renderer can tell
// "this artifact does not exist on that side" apart from "this artifact is
// empty". It derives the new-file and deleted-file cases from those nulls.
const artifactsByPath = new Map();
const headArtifactPaths = await glob('**/*.js', {cwd: HEAD_DIR});
for (const artifactPath of headArtifactPaths) {
let base;
try {
base = measure(BASE_DIR, artifactPath);
} catch {
// There's no matching base artifact. This is a new file.
base = null;
}
const head = measure(HEAD_DIR, artifactPath);
artifactsByPath.set(artifactPath, {
path: artifactPath,
baseSize: base === null ? null : base.size,
baseSizeGzip: base === null ? null : base.sizeGzip,
headSize: head.size,
headSizeGzip: head.sizeGzip,
});
}
// Check for base artifacts that were deleted in the head.
const baseArtifactPaths = await glob('**/*.js', {cwd: BASE_DIR});
for (const artifactPath of baseArtifactPaths) {
if (!artifactsByPath.has(artifactPath)) {
const base = measure(BASE_DIR, artifactPath);
artifactsByPath.set(artifactPath, {
path: artifactPath,
baseSize: base.size,
baseSizeGzip: base.sizeGzip,
headSize: null,
headSizeGzip: null,
});
}
}
// Every artifact is reported, with no threshold filtering. The thresholds and
// the critical bundle list belong to the renderer, so that a pull request
// cannot quietly widen them to hide a regression.
writeResults({
version: RESULTS_VERSION,
status: 'ok',
baseSha,
headSha,
artifacts: Array.from(artifactsByPath.values()),
});
})();