diff --git a/.github/actions/composite/postBundleSizeComment/action.yml b/.github/actions/composite/postBundleSizeComment/action.yml new file mode 100644 index 000000000000..acdcfaed40d0 --- /dev/null +++ b/.github/actions/composite/postBundleSizeComment/action.yml @@ -0,0 +1,293 @@ +name: Post the bundle size comment +description: | + Reads a `bundle-size` measurement, finds the `main` measurement to compare it against, renders the section + and posts it as a sticky pull request comment, editing the existing one when there is one. + + This is the whole body of `bundleSizeComment.yml`, factored out because `workflow_run` only fires for the + copy of a workflow file on the repository's default branch, so the follower itself cannot run from a + feature branch. A composite action is resolved from whatever ref is checked out, so a job on a branch can + exercise these steps when this needs to be changed. + + Callers must check out the repository first. Everything here reads: the only write is the comment itself. +# cspell:ignore nvmrc +inputs: + measurement-run-id: + description: The workflow run whose `bundle-size` artifact holds this pull request's measurement. + required: true + head-branch: + description: | + The branch the measured run was for, from the event payload rather than from the artifact. The pull + request is looked up by this, so it has to come from GitHub and not from anything a contributor writes. + required: true + head-repository: + description: The `owner/name` of the repository the measured run came from. A fork, for most pull requests. + required: true + head-sha: + description: The commit the measured run was for. The measurement has to agree with this or it is rejected. + required: true + github-token: + description: Token that can read actions and contents, and write pull requests. + required: true + +runs: + using: composite + steps: + # `scripts/bundleSize.ts` is type-stripping-only TypeScript that imports nothing outside `node:`, so + # Node runs it directly and this needs no `npm ci`. The version matters: type stripping needs a Node + # newer than some runner images ship, and `.nvmrc` is well past it. + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + + # The measure workflow uploads nothing when the build or the measurement failed, so a missing artifact + # is an expected outcome, not an error. The comment says so rather than never appearing. + - name: Download this pull request's measurement + continue-on-error: true + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: bundle-size + path: measure + run-id: ${{ inputs.measurement-run-id }} + github-token: ${{ inputs.github-token }} + + - name: Resolve the baseline + id: baseline + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + HEAD_BRANCH: ${{ inputs.head-branch }} + HEAD_REPOSITORY: ${{ inputs.head-repository }} + HEAD_SHA: ${{ inputs.head-sha }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const {owner, repo} = context.repo; + const headBranch = process.env.HEAD_BRANCH; + const headRepository = process.env.HEAD_REPOSITORY; + const headSha = process.env.HEAD_SHA; + + // Which pull request this is, resolved from the API rather than from the artifact. + // + // The measure workflow runs on `pull_request`, which means it runs the copy of the workflow file + // on the pull request's own branch: a fork decides what that file writes. Anything read out of the + // artifact is therefore a contributor's choice, and this job holds `pull-requests: write`, so a + // pull request number taken from the artifact would let one repository post a comment on another + // repository's pull request. Everything that decides WHERE the comment goes comes from here down. + // + // The lookup is by branch because `workflow_run.pull_requests` is empty for a fork-originated run, + // and neither `commits/{sha}/pulls` nor the search API indexes a fork's head commits. Expensify's + // contribution model is forks, so getting this wrong means the tool silently never comments on + // most pull requests. + const openOnBranch = await github.rest.pulls.list({ + owner, + repo, + head: `${headRepository.split('/')[0]}:${headBranch}`, + state: 'open', + per_page: 100, + }); + // A deleted fork leaves `head.repo` null, and there is then nothing left to check the run against. + const fromThisRun = openOnBranch.data.filter((candidate) => candidate.head.repo?.full_name === headRepository); + if (openOnBranch.data.length > 0 && fromThisRun.length === 0) { + throw new Error(`This run came from ${headRepository}, which no open pull request on \`${headBranch}\` belongs to. Refusing to comment.`); + } + // GitHub allows two open pull requests from one head branch when they target different bases. Only + // one gets a comment, and the log is where that becomes visible. + if (fromThisRun.length > 1) { + core.warning(`${fromThisRun.length} open pull requests share the head ${headRepository}:${headBranch} (${fromThisRun.map((candidate) => `#${candidate.number}`).join(', ')}). Only the first gets a comment.`); + } + const pullRequest = fromThisRun.at(0); + if (!pullRequest) { + core.info(`No open pull request for ${headRepository}:${headBranch}, so there is nothing to comment on.`); + return; + } + core.setOutput('pr-number', pullRequest.number); + + // No measurement at all means the build or the measurement did not finish. The pull request is + // already resolved above, so the comment can still say that, which is more use to the author than + // silence. + if (!fs.existsSync('measure/bundle-size.json')) { + core.setOutput('measured', 'false'); + core.setOutput('baseline-branch', pullRequest.base.ref); + core.info('No measurement for this run, so the comment will say it could not be measured.'); + return; + } + // Both refusals below say "not measured" rather than throwing. The file is written by a fork's own + // copy of the measure workflow, so a fork can produce either at will; a red run in this repository's + // Actions list, for something a pull request author cannot act on, is the wrong answer to that. + const unusable = (reason) => { + core.warning(reason); + core.setOutput('measured', 'false'); + core.setOutput('baseline-branch', pullRequest.base.ref); + }; + + let measuredSha; + try { + measuredSha = JSON.parse(fs.readFileSync('measure/bundle-size.json', 'utf8')).sha; + } catch (error) { + unusable(`The measurement is not readable JSON: ${error.message}`); + return; + } + + // The measurement must be of the commit the event says was measured. A report naming some other + // commit is either a mix-up between runs or a fork writing whatever it liked; both are refused. + if (measuredSha !== headSha) { + unusable(`The measurement is for ${measuredSha}, but this run measured ${headSha}. Refusing to report one commit's sizes as another's.`); + return; + } + core.setOutput('measured', 'true'); + + // Baselines come from the branch this pull request is merging INTO, which is `main` for every + // ordinary pull request. Read from the pull request record rather than hardcoded, so the sentence + // stays true for one that targets a long-lived branch. Resolved before anything can fail, because + // the comment names it whatever else happens. + const baselineBranch = pullRequest.base.ref; + core.setOutput('baseline-branch', baselineBranch); + core.info(`resolving baselines from \`${baselineBranch}\``); + + // `base.sha` is the base branch tip this head was tested against, not the merge base. Comparing + // against the tip would charge the pull request for every byte that landed on `main` after it + // forked, so ask the API for the actual merge base. A fork's head commit is reachable in this + // repository through the pull request ref, so one comparison covers both cases - but if it ever + // does not, no baseline is the honest answer, and it is louder than a wrong one. + let mergeBaseSha = ''; + try { + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${pullRequest.base.sha}...${headSha}`, + }); + mergeBaseSha = comparison.data.merge_base_commit.sha; + core.info(`merge base of ${headSha} and ${pullRequest.base.sha} is ${mergeBaseSha}`); + } catch (error) { + core.warning(`Could not resolve the merge base of ${headSha} and ${pullRequest.base.sha}: ${error.message}`); + return; + } + core.setOutput('merge-base-sha', mergeBaseSha); + + // The merge base itself is the baseline, and normally has a measurement: the measure workflow is + // NOT path-filtered on the baseline branch, so every commit there is measured. What is left is a + // build that failed or has not finished. Every fallback is an ANCESTOR of the merge base, never + // the branch tip, so the comparison can only ever include commits this branch does not have, and + // the comment says when it did that. The merge base is listed first explicitly rather than relying + // on `listCommits` returning the commit it was asked about at index 0. + const ancestors = await github.rest.repos.listCommits({owner, repo, sha: mergeBaseSha, per_page: 100}); + const candidates = [mergeBaseSha, ...ancestors.data.map((commit) => commit.sha).filter((sha) => sha !== mergeBaseSha)]; + + // Asked per candidate SHA rather than by listing recent runs on the branch. A single page of runs + // covers about three days of `main` at this repository's merge rate, and because every candidate is + // OLDER than the merge base, a merge base outside that page puts every fallback outside it too: a + // pull request open for a week would lose the comparison entirely rather than degrading to an + // ancestor. Asking by SHA makes the window the artifact retention instead, and costs one call in + // the normal case, where the merge base is measured and answers first. + // + // A retained artifact can still have expired, so confirm the file is really downloadable before + // settling on a run. Both caps below are ceilings on API calls: if the nearest few measured + // ancestors have all expired, the honest answer is no baseline rather than a walk back through + // months of history. + let checked = 0; + let asked = 0; + for (const sha of candidates) { + if (checked >= 5 || asked >= 25) { + break; + } + asked += 1; + // Newest run wins for a re-run of one commit, which is the order this returns them in. + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'bundleSize.yml', + head_sha: sha, + event: 'push', + status: 'success', + per_page: 1, + }); + const runId = runs.data.workflow_runs.at(0)?.id; + if (!runId) { + continue; + } + checked += 1; + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id: runId, per_page: 100}); + if (!artifacts.data.artifacts.some((artifact) => artifact.name === 'bundle-size' && !artifact.expired)) { + core.info(`measurement for ${sha} (run ${runId}) has expired`); + continue; + } + core.setOutput('run-id', runId); + core.info(sha === mergeBaseSha ? `baseline is the merge base ${sha}` : `baseline is ${sha}, an ancestor of the merge base`); + return; + } + core.warning(`No \`${baselineBranch}\` measurement resolved for this pull request, so the comment will carry its own sizes only.`); + + - name: Download the baseline measurement + if: steps.baseline.outputs.run-id != '' + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: bundle-size + path: baseline + run-id: ${{ steps.baseline.outputs.run-id }} + github-token: ${{ inputs.github-token }} + + # The script decides on the wording: it compares the baseline's own recorded SHA against the merge base + # it is handed, so the "this is not your merge base" sentence cannot drift out of step with the numbers. + # No pull request means a closed or deleted one, which the resolve step already explained. Rendering a + # comment with nowhere to post it would fail the run and look like the tool is broken. + - name: Render the comment + if: steps.baseline.outputs.pr-number != '' + shell: bash + env: + BASELINE_RUN_ID: ${{ steps.baseline.outputs.run-id }} + MERGE_BASE_SHA: ${{ steps.baseline.outputs.merge-base-sha }} + BASELINE_BRANCH: ${{ steps.baseline.outputs.baseline-branch }} + MEASURED: ${{ steps.baseline.outputs.measured }} + MEASUREMENT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ inputs.measurement-run-id }} + run: | + node ./scripts/bundleSize.ts --marker > marker.txt + if [ "$MEASURED" != 'true' ]; then + { + cat marker.txt + echo + echo '## Bundle size' + echo + echo "This pull request has no usable size measurement, so there is nothing to compare against \`$BASELINE_BRANCH\`. The [measurement run]($MEASUREMENT_RUN_URL) has the reason." + } > comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [ -n "$BASELINE_RUN_ID" ]; then + node ./scripts/bundleSize.ts --compare baseline/bundle-size.json measure/bundle-size.json --merge-base-sha "$MERGE_BASE_SHA" --baseline-branch "$BASELINE_BRANCH" > comment.md + else + node ./scripts/bundleSize.ts --no-baseline measure/bundle-size.json --baseline-branch "$BASELINE_BRANCH" > comment.md + fi + cat comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: Post or edit the comment + if: steps.baseline.outputs.pr-number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.baseline.outputs.pr-number }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const {owner, repo} = context.repo; + const issue_number = Number(process.env.PR_NUMBER); + if (!Number.isInteger(issue_number) || issue_number <= 0) { + throw new Error(`The resolved pull request number is unusable (got ${JSON.stringify(process.env.PR_NUMBER)}), so there is nothing to comment on.`); + } + const body = fs.readFileSync('comment.md', 'utf8'); + const marker = fs.readFileSync('marker.txt', 'utf8').trim(); + + // The marker is a hidden HTML comment in the body, so the same comment is reused on every push + // instead of the pull request collecting one comment per commit. + const comments = await github.paginate(github.rest.issues.listComments, {owner, repo, issue_number, per_page: 100}); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({owner, repo, comment_id: existing.id, body}); + core.info(`edited comment ${existing.html_url}`); + return; + } + const created = await github.rest.issues.createComment({owner, repo, issue_number, body}); + core.info(`posted comment ${created.data.html_url}`); diff --git a/.github/workflows/bundleSize.yml b/.github/workflows/bundleSize.yml new file mode 100644 index 000000000000..a146b7d57b59 --- /dev/null +++ b/.github/workflows/bundleSize.yml @@ -0,0 +1,136 @@ +# Measures the web bundle a pull request emits, and every measurement `main` needs to be compared against. +# +# This workflow only measures and uploads. It never comments: a `pull_request` run from a fork holds a +# read-only token, and giving this job a write token would mean running a fork's build with that token in +# scope. `bundleSizeComment.yml` follows this workflow and does the posting, from the default branch's code. +# +# Artifact upload works from a fork run because artifacts use the runner's own runtime token rather than +# `GITHUB_TOKEN`, which is what makes the split possible at all. +name: Bundle size +# cspell:ignore argjson nvmrc + +on: + pull_request: + types: [opened, synchronize, reopened] + # Everything that can change what `npm run build` emits: app sources, the bundler configuration, the + # assets it embeds, the dependency tree, and the patches applied to it. The measurement tooling itself + # is included so a change to how bytes are counted re-counts them, `.nvmrc` with it, because the runtime + # that takes the measurement is part of how the bytes are counted. + # + # GitHub evaluates these against the files in the PUSH, not against the pull request's cumulative diff. + # A pull request that touched `src/` and then pushes a commit touching only `tests/` does not re-measure, + # so the sticky comment keeps the earlier commit's numbers. It names the commit it measured, so it is + # stale rather than wrong. + paths: + - 'src/**' + - 'config/**' + - 'assets/**' + - 'package.json' + - 'package-lock.json' + - 'patches/**' + - '.nvmrc' + - 'scripts/bundleSize.ts' + - '.github/workflows/bundleSize.yml' + push: + # A pull request is compared against the branch it merges into, so every branch that is a base for pull + # requests has to measure its own commits. `main` is that branch. + # + # Deliberately NOT path-filtered, unlike `pull_request` above. A filtered baseline branch leaves holes in + # the history - a commit touching only tests never builds - and every pull request whose merge base lands + # in a hole is then compared against an older ancestor, which charges it for whatever landed in between. + # The fallback still exists for a build that failed or has not finished, but it stops being the normal + # case. The price is one build per commit on this branch. + branches: [main] + +permissions: + contents: read + +# One measurement per pull request head is enough, so a superseded push is cancelled. A `main` commit is +# never cancelled: its measurement is the baseline every later pull request is compared against, and the +# grouping key is the commit itself so two `main` commits never contend. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + measure: + name: Measure + runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 60 + # Stated at the job rather than inherited. This is the job that builds a pull request's code, and it can + # do nothing but read. + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + # Exactly the command `buildWeb.yml` runs for a deploy, with no added flags: any bundler configuration + # change risks measuring a bundle that is not the bundle that ships. No `SENTRY_AUTH_TOKEN` is passed, + # so the source map upload 401s and the build still exits 0 - the same path a fork run takes. + # Neither this step nor the measurement is allowed to fail the job. A pull request that cannot be + # measured gets no comment, which is the honest outcome, and a red check nobody can act on is worse + # than a missing comment. Every step after this one is skipped unless both succeeded. + - name: Build web + id: build + continue-on-error: true + run: npm run build + + # For a `pull_request` run, `actions/checkout` checks out the merge ref, so `git rev-parse HEAD` is the + # ephemeral merge commit. The head SHA is what the comment has to be keyed to. + - name: Measure + id: measure + if: steps.build.outcome == 'success' + continue-on-error: true + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: node ./scripts/bundleSize.ts --out bundle-size.json --sha "$HEAD_SHA" + + # Informational only, for reading a run back later. NOTHING here is trusted by the follower: a + # `pull_request` run uses the copy of this workflow on the pull request's own branch, so a fork decides + # what this file says, and a pull request number taken from it would let one repository post a comment + # on another repository's pull request. The follower resolves all of this from the API instead. + - name: Record what was measured + if: steps.measure.outcome == 'success' + env: + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + BASE_REF: ${{ github.event.pull_request.base.ref || '' }} + run: | + jq -n \ + --argjson prNumber "${PR_NUMBER:-null}" \ + --arg headSha "$HEAD_SHA" \ + --arg baseSha "$BASE_SHA" \ + --arg baseRef "$BASE_REF" \ + --arg event "$GITHUB_EVENT_NAME" \ + '{prNumber: $prNumber, headSha: $headSha, baseSha: $baseSha, baseRef: $baseRef, event: $event}' \ + > bundle-size-meta.json + cat bundle-size-meta.json + + # `main` measurements are baselines for every pull request opened afterwards, so they are kept for the + # maximum default retention. A pull request's own measurement is read once, minutes later. + - name: Upload measurement + if: steps.measure.outcome == 'success' + # v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: bundle-size + retention-days: ${{ github.event_name == 'push' && 90 || 7 }} + path: | + bundle-size.json + bundle-size-meta.json + + - name: Summarize + if: steps.measure.outcome == 'success' + run: | + { + echo '## Bundle size measured' + echo + echo '```json' + cat bundle-size.json | jq 'del(.chunks)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/bundleSizeComment.yml b/.github/workflows/bundleSizeComment.yml new file mode 100644 index 000000000000..de10ccd29d01 --- /dev/null +++ b/.github/workflows/bundleSizeComment.yml @@ -0,0 +1,52 @@ +# Posts the sticky `## Bundle size` comment for a pull request that `bundleSize.yml` has measured. +# +# It runs as a `workflow_run` follower rather than inside the measure workflow because a `pull_request` run +# from a fork has a read-only token and cannot comment. The alternative, `pull_request_target`, would build a +# fork's code with a write token in scope. Here the build happens with no write token and no secrets, and +# this job holds the write token but only ever reads one JSON file and renders text from its own checkout of +# the default branch. A fork contribution cannot choose what gets posted under the bot's name. +# +# The work itself is in the `postBundleSizeComment` composite action, because `workflow_run` only fires for +# the copy of this file on the repository's default branch: this workflow cannot run from a feature branch, +# so it cannot be tested from one. A composite action is resolved from whatever ref is checked out, so a job +# on a branch can exercise the same steps. +name: Bundle size comment + +on: + workflow_run: + workflows: ['Bundle size'] + types: [completed] + +permissions: + actions: read # download the measurement artifacts, from this run and from the `main` run it compares against + contents: read # resolve the pull request's merge base through the compare API + pull-requests: write # post and edit the comment + +# One comment per pull request, edited in place, so a superseded run has nothing left to say. The head +# repository is part of the key because branch names are not unique across forks: two pull requests from two +# forks are routinely both called `main`, and grouping on the branch alone would let one cancel the other. +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + comment: + name: Comment + # A `push` run measures `main` for later baselines and has no pull request to comment on. + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + # No `ref`: this is deliberately the default branch's code, not the pull request's. + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Post the bundle size comment + uses: ./.github/actions/composite/postBundleSizeComment + with: + measurement-run-id: ${{ github.event.workflow_run.id }} + # From the event, never from the artifact: these decide which pull request gets commented on. + head-branch: ${{ github.event.workflow_run.head_branch }} + head-repository: ${{ github.event.workflow_run.head_repository.full_name }} + head-sha: ${{ github.event.workflow_run.head_sha }} + github-token: ${{ github.token }} diff --git a/scripts/bundleSize.ts b/scripts/bundleSize.ts new file mode 100644 index 000000000000..43ef417f1f60 --- /dev/null +++ b/scripts/bundleSize.ts @@ -0,0 +1,739 @@ +/** + * Measures the emitted web bundle in `dist/` and writes a JSON summary, or compares two summaries and + * renders the body of the sticky `## Bundle size` pull request comment. + * + * Usage: + * node ./scripts/bundleSize.ts [--dist dist] [--out bundle-size.json] [--sha ] + * node ./scripts/bundleSize.ts --compare [--merge-base-sha ] [--baseline-branch ] + * node ./scripts/bundleSize.ts --no-baseline [--baseline-branch ] + * node ./scripts/bundleSize.ts --assert-same + * node ./scripts/bundleSize.ts --marker + * + * This is type-stripping-only TypeScript with no dependency outside `node:`, so Node runs it with no + * `node_modules` present and the comment workflow needs no `npm ci`. bun runs it too, but bun and Node do + * not agree on gzip sizes, so every measurement records which one took it - see `MEASURED_WITH`. + */ +import {execSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import zlib from 'node:zlib'; + +type ChunkSizes = {raw: number; gzip: number; initial: boolean}; + +type BundleSizeReport = { + sha: string; + /** Optional because reports written before this field existed are still readable. */ + measuredWith?: string; + initialJsRaw: number; + initialJsGzip: number; + allJsRaw: number; + allJsGzip: number; + cssRaw: number; + cssGzip: number; + largestChunk: {name: string; raw: number; gzip: number}; + chunks: Record; +}; + +/** + * Which `main` measurement the head is being compared against, and how it was found. + * + * `merge-base` is the intended case. `ancestor` is the honest degradation: the merge base itself has no + * measurement, so the nearest `main` commit that does stands in, and the comment says which and why. + * `missing` is no baseline at all, which renders the head's own sizes rather than inventing a delta. + */ +type Baseline = {kind: 'merge-base'; report: BundleSizeReport} | {kind: 'ancestor'; report: BundleSizeReport; mergeBaseSha: string} | {kind: 'missing'}; + +const GZIP_LEVEL = 9; + +/** + * Hidden HTML comment that identifies the comment this script's output belongs in, so the workflow edits + * one comment on every push instead of appending a new one. It lives here rather than in the workflow so + * that the renderer and the poster cannot disagree about it; `--marker` prints it for the workflow to read. + */ +const STICKY_MARKER = ''; + +/** Git's default abbreviation in this repository. Long enough to be unambiguous, short enough to read. */ +const SHORT_SHA_LENGTH = 11; + +/** + * Which runtime compressed the bytes, recorded in every report because the answer changes the numbers. + * + * Measured on one `dist/` of 94 chunks: raw sizes are identical, and every gzip size differs. bun and Node + * ship different zlib implementations, and at level 9 they disagree by up to 3,266 B on `main` and 12,617 B + * across all JavaScript - well above the 1,024 B at which this script promotes a per-chunk row. A baseline + * measured by one runtime and a head measured by the other would therefore report thousands of bytes of + * tooling difference as if a pull request had added them, so comparing across runtimes is refused outright. + * + * Everything that produces a comparable measurement runs `node`. bun would work equally well as the choice, + * but Node runs this file with no `node_modules` present, which is what lets the comment workflow skip + * `npm ci` entirely. + */ +const MEASURED_WITH = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.version}`; + +/** `node v26.5.0` -> `node`. The runtime is what decides the bytes; the version is recorded for a reader. */ +function runtimeName(measuredWith: string): string { + return measuredWith.split(' ').at(0) ?? measuredWith; +} + +/** + * Refuses a comparison that would report a difference in compressors as a difference in code. A report + * written before this field existed cannot be checked, so it is allowed through with a warning rather than + * failing every comparison against an artifact that is already on disk. + */ +function assertComparable(a: BundleSizeReport, b: BundleSizeReport): void { + if (!a.measuredWith || !b.measuredWith) { + process.stderr.write('One of these measurements does not record which runtime measured it, so gzip sizes cannot be confirmed comparable.\n'); + return; + } + if (runtimeName(a.measuredWith) !== runtimeName(b.measuredWith)) { + throw new Error( + `These measurements were taken by different runtimes (${a.measuredWith} and ${b.measuredWith}), which compress the same bytes to different sizes. ` + + 'Comparing them would report the compressor as a code change. Re-measure both sides with the same runtime.', + ); + } +} + +/** + * A per-chunk row is promoted out of the collapsed block only above this. The aggregates are always shown, + * whatever they moved by. + */ +const CHUNK_HEADLINE_FLOOR_BYTES = 1024; + +/** + * Every chunk a real build emits under a name a reader knows: the entry chunk `main`, plus the named + * cache groups `config/rsbuild/rsbuild.common.ts` splits out. + * + * Chunk names come from the emitted filenames, so a rename does not fail anything by itself: the chunk + * simply stops appearing in the measurement, and every later comparison reads "no change" for a chunk that + * no longer exists. That is a wrong answer rather than a missing one, so a name that has gone missing fails + * the measurement here, where the build is, rather than being rendered as if nothing had happened. + * + * `main` comes from `filename.js: '[name]-[contenthash].bundle.js'` rather than from a cache group. + * `lottiePlayer` is configured as a cache group but no build emits a chunk under that name, so it is not + * required. Add a name here when a new one starts being emitted. + */ +const EXPECTED_CHUNK_NAMES = ['main', 'vendors', 'heicTo', 'expensifyIcons', 'illustrations']; + +/** + * A measurement is written by a build of the pull request's own code and read by a job holding a write + * token, so everything in it is untrusted input. These bound what may reach the comment. + */ +const CHUNK_NAME_PATTERN = /^[A-Za-z0-9_@./+-]+$/; +/** A chunk named by numeric id, which moves whenever the module graph moves and names nothing to a reader. */ +const NUMERIC_CHUNK_NAME = /^\d+$/; +const SHA_PATTERN = /^[0-9a-f]{7,40}$/; +const MEASURED_WITH_PATTERN = /^[A-Za-z0-9_. +-]{1,64}$/; +const MAX_NAME_LENGTH = 512; + +/** 94 chunks today. The cap is a ceiling on how long a fork can make the comment, not a fit to the build. */ +const MAX_CHUNKS = 1000; + +/** Matches every debug id `sentry-webpack-plugin` injects, and captures the UUIDs themselves. */ +const SENTRY_DEBUG_ID = /_sentryDebugIds\[[A-Za-z_$\d]+\]="([\da-f-]{36})"/g; + +const SENTRY_DEBUG_ID_PLACEHOLDER = '00000000-0000-0000-0000-000000000000'; + +/** The content hash of the source map, in the `sourceMappingURL` comment at the end of every chunk. */ +const SOURCE_MAP_HASH = /(sourceMappingURL=\S*?-)([\da-f]{8,})(\.bundle\.js\.map)/; + +/** + * Two builds of one commit emit the same 94 filenames with different bytes inside them, from two causes + * that are both per-build identifiers rather than app code: + * + * - `sentry-webpack-plugin` injects a fresh random UUID into every chunk, after content hashing. + * - The source map's own content hash moves with that UUID, and every chunk embeds the map's filename in + * its `sourceMappingURL` comment. + * + * Both replacements are per-build noise of a fixed length, so raw sizes never saw either one. Gzip did: + * random hex compresses differently, and measured across all 94 chunks of one build, substituting random + * debug ids moves a single chunk by up to 10 B and the all-JS gzip total by up to 352 B. Masking both makes + * gzip reproducible, at the cost of reading a few tens of bytes per chunk below the shipped bytes - a + * constant offset that is identical on both sides of a comparison, so it cannot move a delta. + * + * Only the captured UUID is replaced, so the RFC 4122 namespace constants that appear in vendored UUID + * libraries (`6ba7b810-9dad-11d1-80b4-00c04fd430c8` and friends) are left alone. + * + * latin1 round-trips arbitrary bytes unchanged, and both replacements keep the original length, so the + * buffer handed to gzip differs from the file in exactly those runs and nowhere else. + */ +function withStableBuildIds(buffer: Buffer): Buffer { + const text = buffer.toString('latin1'); + // Every id in the chunk rather than the first one: a single random id left in place is enough to make + // gzip non-reproducible for that chunk, which is the invariant the rest of this rests on. + let masked = text; + for (const debugId of new Set([...text.matchAll(SENTRY_DEBUG_ID)].map((match) => match[1]))) { + masked = masked.replaceAll(debugId, SENTRY_DEBUG_ID_PLACEHOLDER); + } + // The map's own hash changes with the debug id it contains, and every chunk embeds that filename in its + // `sourceMappingURL` comment. Same length, so raw sizes never saw it, but gzip did. + return Buffer.from( + masked.replace(SOURCE_MAP_HASH, (full, prefix: string, hash: string, suffix: string) => `${prefix}${'0'.repeat(hash.length)}${suffix}`), + 'latin1', + ); +} + +function gzipSize(buffer: Buffer): number { + return zlib.gzipSync(withStableBuildIds(buffer), {level: GZIP_LEVEL}).length; +} + +/** + * `main-a1b2c3d4.bundle.js` -> `main`, `4821-a1b2c3d4.bundle.js` -> `4821`. Six cache groups have stable + * names; every other chunk is named by numeric id, which moves whenever the module graph moves. + */ +function chunkName(file: string): string { + return file.replace(/-[0-9a-f]{8,}\.bundle\.js$/, '').replace(/\.bundle\.js$/, ''); +} + +/** The scripts `index.html` loads, which is what a browser parses before it can render anything. */ +function readInitialScripts(distDir: string): Set { + const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8'); + const initial = new Set(); + for (const match of html.matchAll(/]+src="([^"]+\.js)"/g)) { + initial.add(path.basename(match[1])); + } + return initial; +} + +function measure(distDir: string, sha: string): BundleSizeReport { + // A missing index.html means the build did not emit. Measuring an empty `dist` as 0 B would render as a + // large improvement, so this is fatal rather than a warning. + if (!fs.existsSync(path.join(distDir, 'index.html'))) { + throw new Error(`No index.html in ${distDir}: nothing was built, so there is nothing to measure.`); + } + + const initialScripts = readInitialScripts(distDir); + const chunks: Record = {}; + let initialJsRaw = 0; + let initialJsGzip = 0; + let allJsRaw = 0; + let allJsGzip = 0; + let cssRaw = 0; + let cssGzip = 0; + let largestChunk = {name: '', raw: 0, gzip: 0}; + + for (const file of fs.readdirSync(distDir)) { + const full = path.join(distDir, file); + if (!fs.statSync(full).isFile()) { + continue; + } + if (file.endsWith('.css')) { + const buffer = fs.readFileSync(full); + cssRaw += buffer.length; + cssGzip += gzipSize(buffer); + continue; + } + if (!file.endsWith('.bundle.js')) { + continue; + } + + const buffer = fs.readFileSync(full); + const raw = buffer.length; + const gzip = gzipSize(buffer); + const initial = initialScripts.has(file); + const name = chunkName(file); + // The totals add every file, but a per-chunk entry can only hold one of them, so a collision would + // leave the rows and the totals disagreeing with nothing said about it. `cleanDistPath: true` makes + // this unreachable in CI; a local run over a stale `dist/` is what reaches it. + if (chunks[name]) { + throw new Error(`Two files in ${distDir} both measure as chunk ${name}. Is dist/ stale? The totals would count both and the rows only one.`); + } + chunks[name] = {raw, gzip, initial}; + + allJsRaw += raw; + allJsGzip += gzip; + if (initial) { + initialJsRaw += raw; + initialJsGzip += gzip; + } + if (raw > largestChunk.raw) { + largestChunk = {name, raw, gzip}; + } + } + + if (initialJsRaw === 0) { + throw new Error(`index.html in ${distDir} references no chunk that exists on disk.`); + } + + const missingNames = EXPECTED_CHUNK_NAMES.filter((name) => !chunks[name]); + if (missingNames.length > 0) { + throw new Error( + `The build emitted no chunk named ${missingNames.join(', ')}. The entry name or a cache group in config/rsbuild/rsbuild.common.ts has been renamed ` + + 'or removed, and measuring around it would report "no change" for a chunk that no longer exists. Update EXPECTED_CHUNK_NAMES in this file to match.', + ); + } + + return {sha, measuredWith: MEASURED_WITH, initialJsRaw, initialJsGzip, allJsRaw, allJsGzip, cssRaw, cssGzip, largestChunk, chunks}; +} + +/** + * A measurement that cannot be trusted enough to render. Kept separate from every other error because the + * caller answers it differently: a comment saying the results could not be read, rather than a crash that + * leaves the pull request with no comment and no explanation. + */ +class ReportValidationError extends Error {} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function checkedName(value: unknown, what: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_NAME_LENGTH || !CHUNK_NAME_PATTERN.test(value) || value.includes('..') || value.startsWith('/')) { + throw new ReportValidationError(`${what} is not a usable name: ${JSON.stringify(value)}`); + } + return value; +} + +function checkedSize(value: unknown, what: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new ReportValidationError(`${what} is not a size: ${JSON.stringify(value)}`); + } + return value; +} + +/** + * Checks every field of a measurement, and returns a report built only from the fields that passed. + * + * A pull request from a fork runs its own copy of the measure workflow, so it decides what this file + * contains: the cache-group names come from its `config/`, and the workflow that writes the file is the copy + * on its own branch. The renderer runs from the default branch with a write token, which makes this file the + * one input a contributor controls and the bot repeats. Rebuilding the object rather than narrowing it also + * means nothing that was never checked can reach the comment. + */ +function parseReport(value: unknown, source: string): BundleSizeReport { + if (!isRecord(value)) { + throw new ReportValidationError(`${source} is not an object.`); + } + + if (typeof value.sha !== 'string' || !SHA_PATTERN.test(value.sha)) { + throw new ReportValidationError(`${source} carries no commit SHA: ${JSON.stringify(value.sha)}`); + } + if (value.measuredWith !== undefined && (typeof value.measuredWith !== 'string' || !MEASURED_WITH_PATTERN.test(value.measuredWith))) { + throw new ReportValidationError(`${source} records an unusable runtime: ${JSON.stringify(value.measuredWith)}`); + } + if (!isRecord(value.largestChunk)) { + throw new ReportValidationError(`${source} has no largest chunk.`); + } + if (!isRecord(value.chunks)) { + throw new ReportValidationError(`${source} has no chunks.`); + } + + const names = Object.keys(value.chunks); + if (names.length > MAX_CHUNKS) { + throw new ReportValidationError(`${source} reports ${names.length} chunks, past the ${MAX_CHUNKS} this renders.`); + } + // Collected as entries and rebuilt with `Object.fromEntries` rather than assigned key by key, because + // `CHUNK_NAME_PATTERN` allows `__proto__` and assigning that key hits the prototype setter instead of + // defining a property: the chunk would be neither accepted nor rejected, which is the wrong outcome for + // the one input a contributor controls. `fromEntries` defines it, so it survives as an ordinary key. + const entries: Array<[string, ChunkSizes]> = []; + for (const name of names) { + const chunk = value.chunks[name]; + if (!isRecord(chunk)) { + throw new ReportValidationError(`${source}: chunk ${JSON.stringify(name)} is not an object.`); + } + if (typeof chunk.initial !== 'boolean') { + throw new ReportValidationError(`${source}: chunk ${JSON.stringify(name)} does not say whether it is on the initial path.`); + } + entries.push([ + checkedName(name, `${source}: chunk name`), + { + raw: checkedSize(chunk.raw, `${source}: chunk ${name} raw`), + gzip: checkedSize(chunk.gzip, `${source}: chunk ${name} gzip`), + initial: chunk.initial, + }, + ]); + } + const chunks = Object.fromEntries(entries); + + return { + sha: value.sha, + measuredWith: value.measuredWith, + initialJsRaw: checkedSize(value.initialJsRaw, `${source}: initialJsRaw`), + initialJsGzip: checkedSize(value.initialJsGzip, `${source}: initialJsGzip`), + allJsRaw: checkedSize(value.allJsRaw, `${source}: allJsRaw`), + allJsGzip: checkedSize(value.allJsGzip, `${source}: allJsGzip`), + cssRaw: checkedSize(value.cssRaw, `${source}: cssRaw`), + cssGzip: checkedSize(value.cssGzip, `${source}: cssGzip`), + largestChunk: { + name: checkedName(value.largestChunk.name, `${source}: largestChunk.name`), + raw: checkedSize(value.largestChunk.raw, `${source}: largestChunk.raw`), + gzip: checkedSize(value.largestChunk.gzip, `${source}: largestChunk.gzip`), + }, + chunks, + }; +} + +/** `JSON.parse` returns `any`, and the file is untrusted, so validate before trusting the shape. */ +function readReport(filePath: string): BundleSizeReport { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + } catch (error) { + throw new ReportValidationError(`${filePath} is not readable JSON: ${error instanceof Error ? error.message : String(error)}`); + } + return parseReport(parsed, filePath); +} + +/** + * Sizes as a reader thinks about them, scaled to the unit that suits the number. + * + * Anything under a kilobyte keeps its exact byte count, because that is the range the noise floor lives in: + * the one gzip wobble ever measured is 1 B, and a row reading `+16 B` says something a row reading `+0.02 kB` + * does not. Above that, two decimals are finer than any difference worth arguing about - 10 B on a megabyte + * chunk - and the percentage carries the shape of the change anyway. + * + * Decimal units, so `kB` means 1,000 bytes. That is what it means, and it is what the network tooling a + * reader would compare this against reports. + */ +const BYTES_IN_KB = 1000; +const BYTES_IN_MB = BYTES_IN_KB * 1000; + +function bytes(value: number): string { + const magnitude = Math.abs(value); + if (magnitude < BYTES_IN_KB) { + return `${value.toLocaleString('en-US')} B`; + } + if (magnitude < BYTES_IN_MB) { + return `${(value / BYTES_IN_KB).toFixed(2)} kB`; + } + return `${(value / BYTES_IN_MB).toFixed(2)} MB`; +} + +/** The floor is a stated threshold, so it is quoted exactly rather than scaled to `1.02 kB`. */ +function exactBytes(value: number): string { + return `${value.toLocaleString('en-US')} B`; +} + +/** A side that does not exist is `null`, never 0: a chunk this pull request added has no baseline size. */ +function delta(base: number | null, head: number | null): string { + if (base === null && head === null) { + return 'not measured'; + } + if (base === null) { + return 'New file'; + } + if (head === null) { + return 'Deleted'; + } + const diff = head - base; + if (diff === 0) { + return 'no change'; + } + const sign = diff > 0 ? '+' : ''; + // Nothing to take a percentage of. `Infinity` survives `toFixed`, so this would otherwise render + // `+Infinity%` whenever one side measured a key as 0 - a build that emits no root CSS, for instance. + if (base === 0) { + return `${sign}${bytes(diff)}`; + } + return `${sign}${bytes(diff)} (${sign}${((diff / base) * 100).toFixed(2)}%)`; +} + +/** With no baseline the row is one value wide, so the comment cannot read as a delta of zero. */ +function row(label: string, head: number | null, base?: number | null): string { + const headCell = head === null ? '-' : bytes(head); + if (base === undefined) { + return `| ${label} | ${headCell} |`; + } + return `| ${label} | ${headCell} | ${base === null ? '-' : bytes(base)} | ${delta(base, head)} |`; +} + +function shortSha(sha: string): string { + return sha.slice(0, SHORT_SHA_LENGTH); +} + +/** + * States what was measured and what it was measured against, including when that is not the merge base. + * + * `branch` is the branch the pull request merges into, which is where baselines come from. It is `main` for + * every ordinary pull request, but naming it rather than hardcoding it keeps the sentence true for a pull + * request that targets anything else. + */ +function provenance(head: BundleSizeReport, baseline: Baseline, branch: string): string { + if (baseline.kind === 'merge-base') { + return `Measured at \`${shortSha(head.sha)}\`, against \`${branch}\` at \`${shortSha(baseline.report.sha)}\`, this pull request's merge base.`; + } + if (baseline.kind === 'ancestor') { + return ( + `Measured at \`${shortSha(head.sha)}\`, against \`${branch}\` at \`${shortSha(baseline.report.sha)}\`. ` + + `This pull request's merge base \`${shortSha(baseline.mergeBaseSha)}\` has no measurement, so the comparison uses the nearest \`${branch}\` commit that does, ` + + `and the change column also carries whatever landed on \`${branch}\` between those two commits.` + ); + } + return `Measured at \`${shortSha(head.sha)}\`. No \`${branch}\` measurement resolved, so these are this pull request's own sizes with nothing to compare them against.`; +} + +function render(head: BundleSizeReport, baseline: Baseline, branch: string): string { + const base = baseline.kind === 'missing' ? undefined : baseline.report; + // The union of both sides, so a chunk this pull request added and a chunk it deleted are both rows. + // Taking head's keys alone hides an added chunk entirely and reports a deleted one as nothing at all. + const stable = [...new Set([...Object.keys(head.chunks), ...Object.keys(base?.chunks ?? {})])].filter((name) => !NUMERIC_CHUNK_NAME.test(name)); + // Raw first: it is what the JavaScript engine parses on every load, cached or not, and it is emitted + // identically by every rebuild. Gzip is what crosses the network on the loads that are not cache hits. + const headline: string[] = [ + row('initial JS (raw)', head.initialJsRaw, base?.initialJsRaw), + row('initial JS (gzip)', head.initialJsGzip, base?.initialJsGzip), + row('all JS (raw)', head.allJsRaw, base?.allJsRaw), + row('all JS (gzip)', head.allJsGzip, base?.allJsGzip), + ]; + // Nothing to promote without a baseline: every row would be a bare number the headline already carries. + if (base) { + for (const name of stable) { + const headChunk = head.chunks[name]; + const baseChunk = base.chunks[name]; + // A chunk that exists on one side only is a structural change, so it earns the headline on the + // same terms as one that moved: above the floor, and either on the initial path or newly there. + const appeared = !headChunk || !baseChunk; + const moved = (headChunk?.gzip ?? 0) - (baseChunk?.gzip ?? 0); + const onInitialPath = headChunk?.initial ?? baseChunk?.initial ?? false; + if ((onInitialPath || appeared) && Math.abs(moved) >= CHUNK_HEADLINE_FLOOR_BYTES) { + headline.push(row(`${name} (gzip)`, headChunk?.gzip ?? null, baseChunk?.gzip ?? null)); + } + } + } + + const detail: string[] = []; + for (const name of stable) { + detail.push(row(`${name} (gzip)`, head.chunks[name]?.gzip ?? null, base ? (base.chunks[name]?.gzip ?? null) : undefined)); + } + detail.push(row('emitted CSS (gzip)', head.cssGzip, base?.cssGzip)); + detail.push(row('largest chunk (raw)', head.largestChunk.raw, base?.largestChunk.raw)); + + const notes: string[] = []; + if (base) { + const mainMoved = base.chunks.main && head.chunks.main && Math.abs(head.chunks.main.gzip - base.chunks.main.gzip) >= CHUNK_HEADLINE_FLOOR_BYTES; + const vendorsMoved = base.chunks.vendors && head.chunks.vendors && Math.abs(head.chunks.vendors.gzip - base.chunks.vendors.gzip) >= CHUNK_HEADLINE_FLOOR_BYTES; + if (vendorsMoved && !mainMoved) { + notes.push('`vendors` grew while `main` did not, which usually means a dependency changed.'); + } + if (base.measuredWith && head.measuredWith && base.measuredWith !== head.measuredWith) { + notes.push( + `These two builds were measured under different versions (\`${base.measuredWith}\` and \`${head.measuredWith}\`), so a small unexplained gzip move may be the compressor rather than the diff.`, + ); + } + // `largestChunk` is picked over every chunk, numerically-named ones included. Naming those in the + // note would read as `4821 -> 7233`, which says nothing to a reader and flips on any module-graph + // move, so the note is only worth printing when both sides are names someone knows. + const namedBothSides = !NUMERIC_CHUNK_NAME.test(base.largestChunk.name) && !NUMERIC_CHUNK_NAME.test(head.largestChunk.name); + if (base.largestChunk.name !== head.largestChunk.name && namedBothSides) { + notes.push(`The largest chunk changed identity (\`${base.largestChunk.name}\` -> \`${head.largestChunk.name}\`), so the largest-chunk row compares two different chunks.`); + } + } + + const columns = base ? [`| | this PR | \`${branch}\` | change |`, '|---|---|---|---|'] : ['| | this PR |', '|---|---|']; + const detailColumns = base ? [`| key | this PR | \`${branch}\` | change |`, '| --- | --- | --- | --- |'] : ['| key | this PR |', '| --- | --- |']; + + return [ + STICKY_MARKER, + '## Bundle size', + '', + provenance(head, baseline, branch), + '', + ...columns, + ...headline, + '', + ...(notes.length ? [notes.join('\n'), ''] : []), + '
', + 'All named chunks', + '', + ...detailColumns, + ...detail, + '', + `Measured with \`npm run build\`, gzip level ${GZIP_LEVEL} under ${head.measuredWith ?? 'an unrecorded runtime'}, with the per-build identifiers held constant so gzip is reproducible.`, + '', + `A per-chunk row reaches the table above only when it moved by at least ${exactBytes(CHUNK_HEADLINE_FLOOR_BYTES)} AND is either on the initial path or present on one side only. A chunk loaded on demand can therefore grow by a lot and stay in this block, while the \`all JS\` rows above still move. Chunks named by numeric id are counted in the totals but never listed: those names move whenever the module graph does. \`emitted CSS\` is the CSS the build emits at the root of \`dist/\`; assets copied in wholesale (\`css/\`, \`fonts/\`, \`sounds/\`, \`pdfs/\`) are outside the measurement.`, + '', + '
', + ].join('\n'); +} + +/** + * Compares two measurements of one commit, ignoring only the SHA. + * + * Raw and gzip are held to different standards, because the build meets different standards on each. Raw + * bytes are emitted identically by every build measured so far - cold cache against warm, and two separate + * runners - so any raw difference is a real finding and fails. + * + * Gzip carries an irreducible wobble of a byte or two. With the per-build identifiers masked, what is left + * is rspack emitting a star re-export's name list in a different order between builds: the same strings, + * the same total length, so raw cannot see it, but the permutation compresses differently. That is real + * emitted content, so the script reports it rather than hiding it. A per-chunk gzip move below the floor is + * listed and tolerated, because a row that small never leaves the collapsed block anyway. The six aggregates + * are held exactly: the comment prints them whatever they moved by, so a wobble in one of them IS something + * an author reads. + * + * Nothing in CI runs this. It is a manual check to reach for after an rspack, zlib or `sentry-webpack-plugin` + * upgrade, or whenever a comment shows a delta the diff does not explain: build one commit twice into two + * `dist/` directories, measure each, and compare the two reports. + */ +function assertSame(aPath: string, bPath: string): void { + const a = readReport(aPath); + const b = readReport(bPath); + assertComparable(a, b); + const failures: string[] = []; + const withinFloor: string[] = []; + + /** + * The floor is the threshold a PER-CHUNK row has to clear to be promoted, so it is only a "cannot change + * what the comment says" argument for those rows. The six aggregates are printed unconditionally, so any + * move in one of them reaches an author and none of it is allowed through here. + */ + const record = (line: string, difference: number, floorApplies: boolean) => { + if (floorApplies && Math.abs(difference) < CHUNK_HEADLINE_FLOOR_BYTES) { + withinFloor.push(line); + return; + } + failures.push(line); + }; + + const scalars = ['initialJsRaw', 'initialJsGzip', 'allJsRaw', 'allJsGzip', 'cssRaw', 'cssGzip'] as const; + for (const key of scalars) { + if (a[key] === b[key]) { + continue; + } + const difference = b[key] - a[key]; + record(`${key}: ${bytes(a[key])} -> ${bytes(b[key])} (${difference > 0 ? '+' : ''}${difference} B)`, difference, false); + } + if (a.largestChunk.name !== b.largestChunk.name) { + failures.push(`largestChunk.name: ${a.largestChunk.name} -> ${b.largestChunk.name}`); + } + + for (const name of new Set([...Object.keys(a.chunks), ...Object.keys(b.chunks)])) { + const left = a.chunks[name]; + const right = b.chunks[name]; + if (!left || !right) { + failures.push(`chunk ${name}: ${left ? 'only in first' : 'only in second'}`); + continue; + } + if (left.raw !== right.raw) { + failures.push(`chunk ${name}: raw ${left.raw} -> ${right.raw}`); + } + if (left.gzip !== right.gzip) { + record(`chunk ${name}: gzip ${left.gzip} -> ${right.gzip} (${right.gzip - left.gzip > 0 ? '+' : ''}${right.gzip - left.gzip} B)`, right.gzip - left.gzip, true); + } + } + + if (withinFloor.length > 0) { + process.stdout.write( + `gzip floor, ${withinFloor.length} key(s) below the ${exactBytes(CHUNK_HEADLINE_FLOOR_BYTES)} reporting threshold:\n${withinFloor.map((line) => ` ${line}`).join('\n')}\n`, + ); + } + if (failures.length === 0) { + process.stdout.write('raw bytes are identical in every measured key, and no gzip key moved past the reporting threshold.\n'); + return; + } + process.stdout.write(`FAILED, ${failures.length} key(s) outside the floor:\n${failures.map((line) => ` ${line}`).join('\n')}\n`); + process.exitCode = 1; +} + +/** + * The comment to post when the measurement cannot be trusted. + * + * The reason goes to the log, not into the comment: it quotes the file that failed validation, and the file + * is the input a fork controls. A missing comment and a broken bot look identical to a reader, so this says + * plainly that the results could not be read. + */ +function unreadable(error: unknown): string { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return [ + STICKY_MARKER, + '## Bundle size', + '', + 'The size results for this commit could not be read, so there is nothing to compare. This is a problem with the measurement rather than with this pull request.', + ].join('\n'); +} + +function main(): void { + const argv = process.argv.slice(2); + // A flag with an empty value counts as absent, so a workflow expression that resolved to nothing falls + // back to the default rather than rendering the empty string. + const flag = (name: string): string | undefined => { + const at = argv.indexOf(name); + const value = at === -1 ? undefined : argv.at(at + 1); + if (!value || value.startsWith('--')) { + return undefined; + } + return value; + }; + + if (argv.includes('--marker')) { + process.stdout.write(`${STICKY_MARKER}\n`); + return; + } + + const assertAt = argv.indexOf('--assert-same'); + if (assertAt !== -1) { + const [aPath, bPath] = argv.slice(assertAt + 1, assertAt + 3); + if (!aPath || !bPath) { + throw new Error('--assert-same needs two JSON paths'); + } + assertSame(aPath, bPath); + return; + } + + // The branch baselines come from. Defaulted rather than required, because `main` is the answer for + // every ordinary pull request and a local comparison should not have to say so. + const baselineBranch = flag('--baseline-branch') ?? 'main'; + + const headOnlyPath = flag('--no-baseline'); + if (headOnlyPath) { + try { + process.stdout.write(`${render(readReport(headOnlyPath), {kind: 'missing'}, baselineBranch)}\n`); + } catch (error) { + if (!(error instanceof ReportValidationError)) { + throw error; + } + process.stdout.write(`${unreadable(error)}\n`); + } + return; + } + + const compareAt = argv.indexOf('--compare'); + if (compareAt !== -1) { + const [basePath, headPath] = argv.slice(compareAt + 1, compareAt + 3); + if (!basePath || !headPath) { + throw new Error('--compare needs two JSON paths: '); + } + let baseReport: BundleSizeReport; + let headReport: BundleSizeReport; + try { + baseReport = readReport(basePath); + headReport = readReport(headPath); + } catch (error) { + if (!(error instanceof ReportValidationError)) { + throw error; + } + process.stdout.write(`${unreadable(error)}\n`); + return; + } + assertComparable(baseReport, headReport); + // The caller knows the merge base; this script only decides how to describe the baseline it was + // handed. Omitting the flag asserts that the baseline IS the merge base, which is what a local + // comparison of two deliberate builds means. + const mergeBaseSha = flag('--merge-base-sha'); + const baseline: Baseline = !mergeBaseSha || mergeBaseSha === baseReport.sha ? {kind: 'merge-base', report: baseReport} : {kind: 'ancestor', report: baseReport, mergeBaseSha}; + process.stdout.write(`${render(headReport, baseline, baselineBranch)}\n`); + return; + } + + const distDir = flag('--dist') ?? 'dist'; + const outPath = flag('--out') ?? 'bundle-size.json'; + const sha = flag('--sha') ?? execSync('git rev-parse HEAD', {encoding: 'utf8'}).trim(); + + const report = measure(distDir, sha); + fs.writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write( + `${outPath}: initial JS ${bytes(report.initialJsGzip)} gzip / ${bytes(report.initialJsRaw)} raw, ` + + `all JS ${bytes(report.allJsGzip)} gzip, largest chunk ${report.largestChunk.name} ${bytes(report.largestChunk.raw)} raw\n`, + ); +} + +export type {BundleSizeReport, Baseline}; +export {assertComparable, measure, parseReport, render, ReportValidationError}; + +// Only when this file is what was run. The tooling tests import the functions above directly, and importing +// a module must not start reading `dist/` or writing a comment to stdout. +const entrypoint = process.argv.at(1); +if (entrypoint && path.resolve(entrypoint) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/tests/tooling/bundleSize.test.ts b/tests/tooling/bundleSize.test.ts new file mode 100644 index 000000000000..5adde915f433 --- /dev/null +++ b/tests/tooling/bundleSize.test.ts @@ -0,0 +1,296 @@ +import {afterEach, describe, expect, it} from 'bun:test'; + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type {BundleSizeReport} from '../../scripts/bundleSize'; + +import {assertComparable, measure, parseReport, render, ReportValidationError} from '../../scripts/bundleSize'; + +/** Mirrors the cap in `scripts/bundleSize.ts`, which is not exported because nothing else needs it. */ +const MAX_CHUNKS = 1000; + +const BASE_SHA = 'f864be0d0a1aaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const HEAD_SHA = 'c9ddb071902bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +function chunk(raw: number, gzip: number, initial = false) { + return {raw, gzip, initial}; +} + +function report(overrides: Partial = {}): BundleSizeReport { + return { + sha: BASE_SHA, + measuredWith: 'node v26.5.0', + initialJsRaw: 15_013_707, + initialJsGzip: 3_956_687, + allJsRaw: 40_446_752, + allJsGzip: 11_294_052, + cssRaw: 40_000, + cssGzip: 9_096, + largestChunk: {name: 'vendors', raw: 9_726_213, gzip: 1_862_657}, + chunks: { + main: chunk(6_000_000, 1_476_017, true), + vendors: chunk(9_726_213, 1_862_657, true), + heicTo: chunk(2_000_000, 618_013, true), + illustrations: chunk(3_000_000, 793_177), + expensifyIcons: chunk(400_000, 94_273), + }, + ...overrides, + }; +} + +describe('render', () => { + it('reports a chunk the pull request added rather than leaving it out', () => { + const base = report(); + const head = report({sha: HEAD_SHA, chunks: {...base.chunks, newFeature: chunk(800_000, 250_000, true)}}); + + const comment = render(head, {kind: 'merge-base', report: base}, 'main'); + + expect(comment).toContain('| newFeature (gzip) | 250.00 kB | - | New file |'); + // Once in the headline table and once in the collapsed block: an added chunk is not a detail. + expect(comment.split('| newFeature (gzip) | 250.00 kB | - | New file |')).toHaveLength(3); + }); + + it('reports a chunk the pull request deleted, which head alone cannot see', () => { + const base = report(); + const head = report({sha: HEAD_SHA, chunks: {main: base.chunks.main, vendors: base.chunks.vendors, heicTo: base.chunks.heicTo, expensifyIcons: base.chunks.expensifyIcons}}); + + const comment = render(head, {kind: 'merge-base', report: base}, 'main'); + + expect(comment).toContain('| illustrations (gzip) | - | 793.18 kB | Deleted |'); + expect(comment).not.toContain('| illustrations (gzip) | 793.18 kB | 793.18 kB | no change |'); + }); + + it('never renders an absent side as a size of zero', () => { + const base = report(); + const head = report({sha: HEAD_SHA, chunks: {...base.chunks, newFeature: chunk(800_000, 250_000, true)}}); + + const comment = render(head, {kind: 'merge-base', report: base}, 'main'); + + expect(comment).not.toContain('| newFeature (gzip) | 250.00 kB | 0 B |'); + }); + + it('keeps a below-floor chunk out of the headline and in the collapsed block', () => { + const base = report(); + const head = report({sha: HEAD_SHA, chunks: {...base.chunks, main: chunk(6_000_100, 1_476_117, true)}}); + + const [headline, collapsed] = render(head, {kind: 'merge-base', report: base}, 'main').split('
'); + + expect(headline).not.toContain('main (gzip)'); + expect(collapsed).toContain('| main (gzip) | 1.48 MB | 1.48 MB | +100 B'); + }); + + it('promotes a chunk that moved past the floor', () => { + const base = report(); + const head = report({sha: HEAD_SHA, chunks: {...base.chunks, main: chunk(6_100_000, 1_500_000, true)}}); + + const [headline] = render(head, {kind: 'merge-base', report: base}, 'main').split('
'); + + expect(headline).toContain('| main (gzip) | 1.50 MB | 1.48 MB | +23.98 kB (+1.62%) |'); + }); + + it('names the merge base it compared against', () => { + const comment = render(report({sha: HEAD_SHA}), {kind: 'merge-base', report: report()}, 'main'); + + expect(comment).toContain(`against \`main\` at \`${BASE_SHA.slice(0, 11)}\`, this pull request's merge base.`); + }); + + it('says which commit stood in when the merge base itself was not measured', () => { + const mergeBaseSha = 'ab12cd34ef5ccccccccccccccccccccccccccccc'; + const comment = render(report({sha: HEAD_SHA}), {kind: 'ancestor', report: report(), mergeBaseSha}, 'main'); + + expect(comment).toContain(`merge base \`${mergeBaseSha.slice(0, 11)}\` has no measurement`); + expect(comment).toContain('carries whatever landed on `main` between those two commits'); + }); + + it('drops the percentage when the baseline side is zero, rather than rendering Infinity', () => { + const base = report({cssGzip: 0}); + const head = report({sha: HEAD_SHA, cssGzip: 2_000}); + + const comment = render(head, {kind: 'merge-base', report: base}, 'main'); + + expect(comment).toContain('| emitted CSS (gzip) | 2.00 kB | 0 B | +2.00 kB |'); + expect(comment).not.toContain('Infinity'); + }); + + it('does not report a largest-chunk identity change between two numeric chunk ids', () => { + const base = report({largestChunk: {name: '4821', raw: 9_726_213, gzip: 1_862_657}}); + const head = report({sha: HEAD_SHA, largestChunk: {name: '7233', raw: 9_726_213, gzip: 1_862_657}}); + + expect(render(head, {kind: 'merge-base', report: base}, 'main')).not.toContain('changed identity'); + }); + + it('reports a largest-chunk identity change between two names a reader knows', () => { + const head = report({sha: HEAD_SHA, largestChunk: {name: 'main', raw: 9_726_213, gzip: 1_862_657}}); + + expect(render(head, {kind: 'merge-base', report: report()}, 'main')).toContain('changed identity (`vendors` -> `main`)'); + }); + + it('renders one-value rows with no baseline, so nothing reads as a delta of zero', () => { + const comment = render(report({sha: HEAD_SHA}), {kind: 'missing'}, 'main'); + + expect(comment).toContain('No `main` measurement resolved'); + expect(comment).not.toContain('no change'); + }); +}); + +describe('size formatting', () => { + function changeFor(headGzip: number, baseGzip: number): string { + const base = report(); + const head = report({sha: HEAD_SHA, chunks: {...base.chunks, main: chunk(1, headGzip, true)}}); + const line = render(head, {kind: 'merge-base', report: {...base, chunks: {...base.chunks, main: chunk(1, baseGzip, true)}}}, 'main') + .split('\n') + .find((row) => row.startsWith('| main (gzip)')); + return line ?? ''; + } + + it('keeps a sub-kilobyte change in bytes, which is the range the noise floor lives in', () => { + expect(changeFor(1_000_016, 1_000_000)).toContain('| +16 B '); + }); + + it('scales a kilobyte-sized change to kB', () => { + expect(changeFor(1_320_939, 1_000_000)).toContain('| +320.94 kB '); + }); + + it('scales a megabyte-sized value to MB', () => { + expect(changeFor(15_334_646, 1_000)).toContain('| 15.33 MB |'); + }); + + it('scales a negative change too', () => { + expect(changeFor(1_000_000, 1_320_939)).toContain('| -320.94 kB '); + }); +}); + +describe('assertComparable', () => { + it('refuses a bun measurement against a node one, which would read the compressor as a code change', () => { + expect(() => assertComparable(report({measuredWith: 'bun 1.3.14'}), report({sha: HEAD_SHA}))).toThrow(/different runtimes/); + }); + + it('allows two measurements from the same runtime at different versions', () => { + expect(() => assertComparable(report({measuredWith: 'node v26.5.0'}), report({sha: HEAD_SHA, measuredWith: 'node v26.6.0'}))).not.toThrow(); + }); + + it('allows a report written before the runtime was recorded, rather than failing every old artifact', () => { + expect(() => assertComparable(report({measuredWith: undefined}), report({sha: HEAD_SHA}))).not.toThrow(); + }); +}); + +describe('parseReport', () => { + /** Deliberately typed as `unknown`: these are the shapes a build could write, not shapes the code allows. */ + function malformed(overrides: Record): unknown { + return {...report(), ...overrides}; + } + + function chunksNamed(name: string): Record { + return {chunks: Object.fromEntries([[name, chunk(1, 1)]])}; + } + + it('accepts a measurement the build produces', () => { + expect(() => parseReport(report(), 'head')).not.toThrow(); + }); + + it('returns only the fields it checked, so nothing unchecked can reach the comment', () => { + const parsed = parseReport(malformed({smuggled: ''], + ['main-aaaaaaaa.bundle.js', 'a'.repeat(2048)], + ['vendors-bbbbbbbb.bundle.js', 'b'.repeat(4096)], + ['heicTo-cccccccc.bundle.js', 'c'.repeat(512)], + ['illustrations-dddddddd.bundle.js', 'd'.repeat(512)], + ['expensifyIcons-eeeeeeee.bundle.js', 'e'.repeat(512)], + ]; + } + + function without(name: string): Array<[string, string]> { + return emitted().filter(([file]) => file !== name); + } + + afterEach(() => { + for (const dir of created.splice(0)) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('measures a build that emits every expected chunk name', () => { + const measured = measure(dist(emitted()), BASE_SHA); + + expect(Object.keys(measured.chunks).sort()).toEqual(['expensifyIcons', 'heicTo', 'illustrations', 'main', 'vendors']); + expect(measured.initialJsRaw).toBe(2048 + 4096); + }); + + it('classifies a chunk index.html does not load as off the initial path', () => { + const measured = measure(dist(emitted()), BASE_SHA); + + expect(measured.chunks.main.initial).toBe(true); + expect(measured.chunks.heicTo.initial).toBe(false); + }); + + it('picks the largest chunk by raw bytes, which is what the headline promotion rule reads', () => { + expect(measure(dist(emitted()), BASE_SHA).largestChunk.name).toBe('vendors'); + }); + + it('refuses a stale dist where two files measure as one chunk, which would count both and show one', () => { + const stale = [...emitted(), ['main-ffffffff.bundle.js', 'f'.repeat(1024)] as [string, string]]; + + expect(() => measure(dist(stale), BASE_SHA)).toThrow(/both measure as chunk main/); + }); + + it('fails when an expected chunk has been renamed away, rather than reporting no change for it', () => { + expect(() => measure(dist(without('illustrations-dddddddd.bundle.js')), BASE_SHA)).toThrow(/no chunk named illustrations/); + }); + + it('refuses to measure a dist with no index.html, which would read as a large improvement', () => { + expect(() => measure(dist(without('index.html')), BASE_SHA)).toThrow(/nothing was built/); + }); +});