From f2e7b7a2f260bfb0b274a221748bb0126ee851cc Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 13:40:49 +0200 Subject: [PATCH 01/15] ci: Check bundle size increases per PR Co-Authored-By: GPT-6 --- .github/workflows/build.yml | 2 + .github/workflows/bump-size-limits.yml | 99 ------- .github/workflows/size-check-label.yml | 90 +++++++ .size-limit.js | 52 ---- dev-packages/size-limit-gh-action/action.yml | 4 - dev-packages/size-limit-gh-action/index.mjs | 137 +++++----- .../utils/SizeLimitFormatter.mjs | 53 ++-- scripts/__fixtures__/size-limit-sample.js | 27 -- scripts/bump-size-limits.mjs | 252 ------------------ scripts/bump-size-limits.test.ts | 241 ----------------- scripts/size-limit-action.test.ts | 195 ++++++++++++++ scripts/size-limit-formatter.test.ts | 77 ++++++ scripts/size-limit-rerun.test.ts | 138 ++++++++++ 13 files changed, 581 insertions(+), 786 deletions(-) delete mode 100644 .github/workflows/bump-size-limits.yml create mode 100644 .github/workflows/size-check-label.yml delete mode 100644 scripts/__fixtures__/size-limit-sample.js delete mode 100644 scripts/bump-size-limits.mjs delete mode 100644 scripts/bump-size-limits.test.ts create mode 100644 scripts/size-limit-action.test.ts create mode 100644 scripts/size-limit-formatter.test.ts create mode 100644 scripts/size-limit-rerun.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59962d0d401f..29cf230faf9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -308,6 +308,8 @@ jobs: with: name: build-bundle-output path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + - name: Test size check + run: yarn vitest run scripts/size-limit-*.test.ts - name: Check bundle sizes uses: ./dev-packages/size-limit-gh-action with: diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml deleted file mode 100644 index 0e4c3d80d7b7..000000000000 --- a/.github/workflows/bump-size-limits.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: 'Auto-bump size-limit thresholds' - -on: - schedule: - - cron: '0 9 * * 5' # Friday 09:00 UTC - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - issues: write - -concurrency: - group: bump-size-limits - cancel-in-progress: false - -jobs: - bump: - name: Bump size-limit thresholds - runs-on: namespace-profile-sentry-javascript-large - timeout-minutes: 25 - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.GITFLOW_APP_ID }} - private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} - - - name: Checkout develop - uses: actions/checkout@v7 - with: - ref: develop - token: ${{ steps.app-token.outputs.token }} - - - name: Set up Node - uses: actions/setup-node@v7 - with: - node-version-file: 'package.json' - - - name: Install dependencies - uses: ./.github/actions/install-dependencies - - - name: Build packages - run: pnpm run build - - - name: Run bumper - # Capture stdout AND exit code without failing the step on exit-2 (no-op). - # The script writes .size-limit.js in place; create-pull-request handles - # commit/branch/PR โ€” if there's no diff, it skips opening a PR. - run: | - set +e - node scripts/bump-size-limits.mjs > /tmp/bump-summary.md - code=$? - set -e - if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then - echo "::error::bump script failed with exit code $code" - cat /tmp/bump-summary.md || true - exit "$code" - fi - cat /tmp/bump-summary.md - - - name: Create or update PR - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 - with: - token: ${{ steps.app-token.outputs.token }} - commit-message: 'chore(size-limit): auto-bump weekly drift' - title: 'chore(size-limit): weekly auto-bump' - body-path: /tmp/bump-summary.md - branch: bot/bump-size-limits - base: develop - labels: 'Dev: CI' - add-paths: '.size-limit.js' - delete-branch: true - - - name: Open or comment on failure issue - if: failure() - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - title='Weekly size-limit auto-bump failure' - existing=$(gh issue list --search "in:title \"$title\"" --state open --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1) - if [ -n "$existing" ]; then - gh issue comment "$existing" --body "Auto-bump workflow failed again: $RUN_URL" - else - body=$(cat < + run.head_repository?.full_name === pr.head.repo.full_name && + run.head_branch === pr.head.ref + ) + .sort((a, b) => b.id - a.id)[0]; + + if (!run) { + core.info('No CI run found for the current PR commit.'); + return; + } + + while (true) { + const { data } = await github.rest.actions.getWorkflowRun({ + ...context.repo, + run_id: run.id, + }); + + if (data.status === 'completed') break; + + await new Promise(resolve => setTimeout(resolve, 30_000)); + } + + const { data: currentPr } = await github.rest.pulls.get({ + ...context.repo, + pull_number: pr.number, + }); + + if (currentPr.state !== 'open' || currentPr.head.sha !== pr.head.sha) { + core.info('The PR has closed or its head changed while waiting.'); + return; + } + + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRun, + { + ...context.repo, + run_id: run.id, + filter: 'latest', + per_page: 100, + }, + ); + + const job = jobs.find(job => job.name === 'Size Check'); + + if (!job || job.conclusion === 'skipped') { + core.info('No executed Size Check job to re-run.'); + return; + } + + await github.rest.actions.reRunJobForWorkflowRun({ + ...context.repo, + job_id: job.id, + }); diff --git a/.size-limit.js b/.size-limit.js index f1fb707a00c6..b841667d9d89 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '35 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -16,7 +15,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '34 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -40,7 +38,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '33 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -65,7 +62,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '58 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -73,7 +69,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), gzip: true, - limit: '58 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -81,7 +76,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, - limit: '61 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -89,7 +83,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -97,7 +90,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '86 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -121,7 +113,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '102 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -129,7 +120,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '115 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -137,7 +127,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '53 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -145,7 +134,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '40 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -153,7 +141,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '46 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -161,7 +148,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics'), gzip: true, - limit: '36 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -169,7 +155,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'logger'), gzip: true, - limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -177,7 +162,6 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics', 'logger'), gzip: true, - limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, // React SDK (ESM) @@ -187,7 +171,6 @@ module.exports = [ import: createImport('init', 'ErrorBoundary'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -196,7 +179,6 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '60 KB', disablePlugins: ['@size-limit/esbuild'], }, // Vue SDK (ESM) @@ -205,7 +187,6 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '44 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -213,7 +194,6 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '61 KB', disablePlugins: ['@size-limit/esbuild'], }, // Svelte SDK (ESM) @@ -222,7 +202,6 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '35 KB', disablePlugins: ['@size-limit/esbuild'], }, // Remix 3 browser SDK (ESM) @@ -235,7 +214,6 @@ module.exports = [ path: 'packages/remix/build/esm/v3/client-bundle.js', import: '*', gzip: true, - limit: '63 KB', disablePlugins: ['@size-limit/esbuild'], }, // Browser CDN bundles @@ -243,63 +221,54 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '58 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, - limit: '39 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '60 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '80 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '96 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '98 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '102 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '104 KB', disablePlugins: ['@size-limit/esbuild'], }, // browser CDN bundles (non-gzipped) @@ -308,7 +277,6 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '99 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -316,7 +284,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '162 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -324,7 +291,6 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '105 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -332,7 +298,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '167 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -340,7 +305,6 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '235 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -348,7 +312,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '282 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -356,7 +319,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '288 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -364,7 +326,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '295 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -372,7 +333,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '301 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) @@ -382,7 +342,6 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '62 KB', disablePlugins: ['@size-limit/esbuild'], }, // SvelteKit SDK (ESM) @@ -392,7 +351,6 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '58 KB', disablePlugins: ['@size-limit/esbuild'], }, // Core SDK subpath entry points (ESM) @@ -401,7 +359,6 @@ module.exports = [ path: 'packages/core/build/esm/server.js', import: '*', gzip: true, - limit: '47 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -409,7 +366,6 @@ module.exports = [ path: 'packages/core/build/esm/browser.js', import: '*', gzip: true, - limit: '19 KB', disablePlugins: ['@size-limit/esbuild'], }, // Node SDK (ESM) @@ -419,7 +375,6 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '159 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -427,7 +382,6 @@ module.exports = [ path: ['packages/server-runtime-injection/build/esm/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '89 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { // Both packages declare `sideEffects: false`, which lets webpack @@ -443,7 +397,6 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '100 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -465,7 +418,6 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '138 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -486,7 +438,6 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '108 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -497,7 +448,6 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '211 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -517,7 +467,6 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '516 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -542,7 +491,6 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '229 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/size-limit-gh-action/action.yml b/dev-packages/size-limit-gh-action/action.yml index e97c8daaa24f..a0dda4d2d6e2 100644 --- a/dev-packages/size-limit-gh-action/action.yml +++ b/dev-packages/size-limit-gh-action/action.yml @@ -8,10 +8,6 @@ inputs: required: false default: '' description: 'If set, compare the current branch with this branch' - threshold: - required: false - default: '0.0125' - description: 'The percentage threshold for size changes before posting a comment' runs: using: 'node24' main: 'index.mjs' diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index df954982c658..ff08fb2ae7e5 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -1,4 +1,3 @@ -/* eslint-disable complexity */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,9 +8,11 @@ import { context, getOctokit } from '@actions/github'; import * as glob from '@actions/glob'; import * as io from '@actions/io'; import { markdownTable } from 'markdown-table'; +import sizeConfig from '../../.size-limit.js'; import { getArtifactsForBranchAndWorkflow } from './utils/getArtifactsForBranchAndWorkflow.mjs'; -import { SizeLimitFormatter } from './utils/SizeLimitFormatter.mjs'; +import { MAX_INCREASE_BYTES, SizeLimitFormatter } from './utils/SizeLimitFormatter.mjs'; +const OVERRIDE_LABEL = 'Accept Bundlesize Increase'; const SIZE_LIMIT_HEADING = '## size-limit report ๐Ÿ“ฆ '; const ARTIFACT_NAME = 'size-limit-action'; const RESULTS_FILE = 'size-limit-results.json'; @@ -47,7 +48,11 @@ async function execSizeLimit() { }, }); - return { status, output }; + if (status !== 0) { + throw new Error('Bundle size measurement failed.'); + } + + return output; } async function run() { @@ -59,7 +64,6 @@ async function run() { const comparisonBranch = getInput('comparison_branch'); const githubToken = getInput('github_token'); - const threshold = getInput('threshold') || 0.05; if (comparisonBranch && !pr) { throw new Error('No PR found. Only pull_request workflows are supported.'); @@ -116,7 +120,7 @@ async function run() { core.endGroup(); } - const { status, output } = await execSizeLimit(); + const output = await execSizeLimit(); try { current = limit.parseResults(output); } catch (error) { @@ -124,82 +128,65 @@ async function run() { throw error; } - const thresholdNumber = Number(threshold); - - const sizeLimitComment = await fetchPreviousComment(octokit, repo, pr); - - if (sizeLimitComment) { - core.debug('Found existing size limit comment, updating it instead of creating a new one...'); + const { data: currentPr } = await octokit.rest.pulls.get({ + ...repo, + pull_number: pr.number, + }); + const approved = currentPr.labels.some(label => label.name === OVERRIDE_LABEL); + const increases = base ? limit.getSizeIncreases(base, current, sizeConfig) : []; + const bodyParts = [SIZE_LIMIT_HEADING]; + + if (baseIsNotLatest) { + bodyParts.push( + 'โš ๏ธ **Warning:** The baseline is behind the target branch. Re-run after the latest base build completes for up-to-date results.', + ); } - const shouldComment = - isNaN(thresholdNumber) || limit.hasSizeChanges(base, current, thresholdNumber) || sizeLimitComment; - - if (shouldComment) { - const bodyParts = [SIZE_LIMIT_HEADING]; - - if (baseIsNotLatest) { - bodyParts.push( - 'โš ๏ธ **Warning:** Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.', - ); - } - try { - bodyParts.push(markdownTable(limit.formatResults(base, current))); - } catch (error) { - core.error('Error generating markdown table'); - core.error(error); - } - - if (baseWorkflowRun) { - bodyParts.push(''); - bodyParts.push(`[View base workflow run](${baseWorkflowRun.html_url})`); + let failure; + if (!base) { + failure = 'No baseline size measurements found. Re-run after the base build completes.'; + bodyParts.push(failure); + } else if (increases.length > 0) { + const details = increases.map(({ name, increase }) => `${name}: +${increase} bytes`).join('\n'); + if (approved) { + bodyParts.push(`Bundle size increase acknowledged by "${OVERRIDE_LABEL}".\n\n${details}`); + } else { + failure = + `Gzipped bundles increased by more than ${MAX_INCREASE_BYTES} bytes:\n${details}\n` + + `Apply "${OVERRIDE_LABEL}" to acknowledge the increase.`; + bodyParts.push(failure); } + } - const body = bodyParts.join('\r\n'); - - try { - if (!sizeLimitComment) { - await octokit.rest.issues.createComment({ - ...repo, - issue_number: pr.number, - body, - }); - } else { - await octokit.rest.issues.updateComment({ - ...repo, - comment_id: sizeLimitComment.id, - body, - }); - } - } catch { - core.error( - "Error updating comment. This can happen for PR's originating from a fork without write permissions.", - ); - } - } else { - core.debug('Skipping comment because there are no changes.'); + bodyParts.push(markdownTable(limit.formatResults(base, current))); + if (baseWorkflowRun) { + bodyParts.push(`[View base workflow run](${baseWorkflowRun.html_url})`); } - if (status > 0) { - try { - const results = limit.parseResults(output); - const failedResults = results - .filter(result => result.passed || false) - .map(result => ({ - name: result.name, - size: +result.size, - sizeLimit: +result.sizeLimit, - })); - - if (failedResults.length > 0) { - // eslint-disable-next-line no-console - console.log('Exceeded size-limits:', failedResults); - } - } catch { - // noop + const body = bodyParts.join('\n\n'); + await core.summary.addRaw(body).write(); + + try { + const sizeLimitComment = await fetchPreviousComment(octokit, repo, pr); + if (sizeLimitComment) { + await octokit.rest.issues.updateComment({ + ...repo, + comment_id: sizeLimitComment.id, + body, + }); + } else { + await octokit.rest.issues.createComment({ + ...repo, + issue_number: pr.number, + body, + }); } + } catch { + core.warning('Unable to update the PR comment. The size report is available in the job summary.'); + } - setFailed('Size limit has been exceeded.'); + if (failure) { + setFailed(failure); } } catch (error) { core.error(error); @@ -214,7 +201,7 @@ async function runSizeLimitOnComparisonBranch() { const limit = new SizeLimitFormatter(); const artifactClient = new DefaultArtifactClient(); - const { output: baseOutput } = await execSizeLimit(); + const baseOutput = await execSizeLimit(); try { const base = limit.parseResults(baseOutput); @@ -232,7 +219,7 @@ async function runSizeLimitOnComparisonBranch() { await artifactClient.uploadArtifact(ARTIFACT_NAME, files, __dirname); } -run(); +await run(); /** * Use GitHub API to fetch artifact download url, then diff --git a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs index ff1f40c6a716..adb09862a9e3 100644 --- a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs +++ b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs @@ -1,6 +1,7 @@ -import * as core from '@actions/core'; import bytes from 'bytes-iec'; +export const MAX_INCREASE_BYTES = 500; + const SIZE_RESULTS_HEADER = ['Path', 'Size', '% Change', 'Change']; const EmptyResult = { @@ -13,14 +14,6 @@ export class SizeLimitFormatter { return bytes.format(size, { unitSeparator: ' ' }); } - formatName(name, sizeLimit, passed) { - if (passed) { - return name; - } - - return `โ›”๏ธ ${name} (max: ${this.formatBytes(sizeLimit)})`; - } - formatPercentageChange(base = 0, current = 0) { if (base === 0) { return 'added'; @@ -72,14 +65,8 @@ export class SizeLimitFormatter { } formatSizeResult(name, base, current) { - if (!current.passed) { - core.debug( - `Size limit exceeded for ${name} - ${this.formatBytes(current.size)} > ${this.formatBytes(current.sizeLimit)}`, - ); - } - return [ - this.formatName(name, current.sizeLimit, current.passed), + name, this.formatBytes(current.size), this.formatPercentageChange(base.size, current.size), this.formatChange(base.size, current.size), @@ -89,36 +76,30 @@ export class SizeLimitFormatter { parseResults(output) { const results = JSON.parse(output); + if (!Array.isArray(results) || results.length === 0) { + throw new Error('Expected non-empty size-limit results.'); + } + return results.reduce((current, result) => { + if (!result || typeof result.name !== 'string' || !Number.isFinite(result.size) || result.size < 0) { + throw new Error('Invalid size-limit measurement.'); + } + return { ...current, [result.name]: { name: result.name, - size: +result.size, - sizeLimit: +result.sizeLimit, - passed: result.passed || false, + size: result.size, }, }; }, {}); } - hasSizeChanges(base, current, threshold = 0) { - if (!base || !current) { - return true; - } - - const names = [...new Set([...Object.keys(base), ...Object.keys(current)])]; - - return names.some(name => { - const baseResult = base[name] || EmptyResult; - const currentResult = current[name] || EmptyResult; - - if (!baseResult.size || !currentResult.size) { - return true; - } - - return Math.abs((currentResult.size - baseResult.size) / baseResult.size) * 100 > threshold; - }); + getSizeIncreases(base, current, config) { + return config + .filter(({ name, gzip }) => gzip === true && base[name] && current[name]) + .map(({ name }) => ({ name, increase: current[name].size - base[name].size })) + .filter(({ increase }) => increase > MAX_INCREASE_BYTES); } formatResults(base, current) { diff --git a/scripts/__fixtures__/size-limit-sample.js b/scripts/__fixtures__/size-limit-sample.js deleted file mode 100644 index 07bbccfd22e1..000000000000 --- a/scripts/__fixtures__/size-limit-sample.js +++ /dev/null @@ -1,27 +0,0 @@ -module.exports = [ - { - name: '@sentry/browser', - path: 'packages/browser/build/npm/esm/prod/index.js', - gzip: true, - limit: '27 KB', - }, - { - name: '@sentry/browser - with treeshaking flags', - path: 'packages/browser/build/npm/esm/prod/index.js', - gzip: true, - limit: '25 KB', - }, - { - name: 'CDN Bundle (incl. Tracing)', - path: 'packages/browser/build/bundles/bundle.tracing.min.js', - gzip: true, - limit: '46.5 KB', - }, - { - name: '@sentry/cloudflare (withSentry)', - path: 'packages/cloudflare/build/esm/index.js', - gzip: false, - brotli: false, - limit: '420 KiB', - }, -]; diff --git a/scripts/bump-size-limits.mjs b/scripts/bump-size-limits.mjs deleted file mode 100644 index a29891c93c94..000000000000 --- a/scripts/bump-size-limits.mjs +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Auto-bumper for .size-limit.js. - * - * - Reads `pnpm exec size-limit --json` output - * - For each entry, computes a new limit of roundUpToKB(currentSize + 5000) - * and applies it whenever the displayed value would change - * - Rewrites .size-limit.js as plain text (NEVER require()d โ€” the file contains - * user-defined webpack/esbuild config functions that we don't want executing) - * - * Exit codes: 0 = wrote changes, 2 = no-op, 1 = error. - */ - -import { execFile } from 'node:child_process'; -import { readFile, rename, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); -const SIZE_LIMIT_FILE = path.join(REPO_ROOT, '.size-limit.js'); - -export const HEADROOM_BYTES = 5000; -export const BYTES_PER_KB = 1000; -export const BYTES_PER_KIB = 1024; - -/** - * Compute the new size-limit in bytes for an entry: currentSize + 5KB, - * rounded up to the next full KB. Always returns a number โ€” the no-op - * check is done downstream by comparing the displayed (KB/KiB-rounded) - * value against the existing one. - * - * @param {number} currentBytes - measured size in bytes - * @returns {number} new limit in bytes, rounded up to the next KB - */ -export function computeNewLimit(currentBytes) { - const target = currentBytes + HEADROOM_BYTES; - return Math.ceil(target / BYTES_PER_KB) * BYTES_PER_KB; -} - -/** - * Parse and strict-validate the JSON output from `pnpm exec size-limit --json`. - * - * @param {string} raw - JSON string - * @returns {Array<{ name: string, size: number, sizeLimit: number }>} - * @throws {TypeError | SyntaxError} on malformed input - */ -export function parseSizeLimitOutput(raw) { - const data = JSON.parse(raw); - if (!Array.isArray(data)) { - throw new TypeError(`size-limit output: expected array, got ${typeof data}`); - } - return data.map((entry, i) => { - if (!entry || typeof entry !== 'object') { - throw new TypeError(`size-limit entry [${i}]: expected object`); - } - if (typeof entry.name !== 'string' || entry.name.length === 0) { - throw new TypeError(`size-limit entry [${i}]: 'name' must be a non-empty string`); - } - if (typeof entry.size !== 'number' || !Number.isFinite(entry.size)) { - throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'size' must be a finite number`); - } - if (typeof entry.sizeLimit !== 'number' || !Number.isFinite(entry.sizeLimit)) { - throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'sizeLimit' must be a finite number`); - } - return { name: entry.name, size: entry.size, sizeLimit: entry.sizeLimit }; - }); -} - -/** - * Escape a string for safe inclusion in a markdown table cell. - * Replaces newlines with spaces, escapes pipes and backticks. - * - * @param {unknown} value - * @returns {string} - */ -export function sanitizeMarkdownCell(value) { - return String(value) - .replace(/\r\n|\r|\n/g, ' ') - .replace(/[|`]/g, m => `\\${m}`); -} - -/** - * Escape a string for literal use inside a RegExp. - */ -function reEscape(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Inspect the source for the current limit string of a given entry. - * Returns null if no entry with that name is found. - * - * @param {string} src - * @param {string} name - * @returns {{ value: number, unit: 'KB' | 'KiB', raw: string } | null} - */ -export function extractCurrentLimit(src, name) { - const namePattern = `name:\\s*'${reEscape(name)}'`; - const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; - const re = new RegExp(`${namePattern}[^]*?${limitPattern}`); - const m = re.exec(src); - if (!m) return null; - return { value: Number(m[1]), unit: /** @type {'KB' | 'KiB'} */ (m[2]), raw: `${m[1]} ${m[2]}` }; -} - -/** - * Convert a numeric byte value into a whole-unit display value matching the - * entry's existing unit. KB uses 1000, KiB uses 1024. - * - * @param {number} newBytes - * @param {'KB' | 'KiB'} unit - * @returns {number} - */ -function bytesToDisplay(newBytes, unit) { - const divisor = unit === 'KiB' ? BYTES_PER_KIB : BYTES_PER_KB; - return Math.ceil(newBytes / divisor); -} - -/** - * Rewrite `.size-limit.js` source to apply a list of limit updates. - * Operates on plain text โ€” never executes the source. For each change, - * locates the entry by exact `name:` match and rewrites the next `limit:` - * line in that window. - * - * @param {string} src - contents of .size-limit.js - * @param {Array<{ name: string, newLimitKb: number, unit: 'KB' | 'KiB' }>} changes - * @returns {string} updated source - * @throws {Error} if any change's name doesn't match exactly one entry - */ -export function rewriteSizeLimitFile(src, changes) { - let out = src; - for (const { name, newLimitKb, unit } of changes) { - const namePattern = `name:\\s*'${reEscape(name)}'`; - const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; - const re = new RegExp(`(${namePattern}[^]*?)${limitPattern}`); - - let matchCount = 0; - const replaced = out.replace(re, (_full, prefix) => { - matchCount++; - return `${prefix}limit: '${newLimitKb} ${unit}'`; - }); - - if (matchCount === 0) { - throw new Error(`rewriteSizeLimitFile: no entry matched for name='${name}'`); - } - out = replaced; - } - return out; -} - -/** - * Render a markdown summary of size-limit changes for the PR body. - * - * @param {Array<{ name: string, oldLimit: string, newLimit: string, delta: number, unit: 'KB' | 'KiB' }>} changes - * @returns {string} - */ -export function renderSummary(changes) { - const header = '## Size limit auto-bump\n'; - if (changes.length === 0) { - return `${header}\nAll size limits already provide โ‰ฅ5 KB headroom. No changes needed.\n`; - } - const lines = [header, '| Entry | Old limit | New limit | ฮ” |', '| --- | --- | --- | --- |']; - for (const c of changes) { - const sign = c.delta >= 0 ? '+' : ''; - const delta = `${sign}${c.delta} ${c.unit}`; - lines.push(`| ${sanitizeMarkdownCell(c.name)} | ${c.oldLimit} | ${c.newLimit} | ${delta} |`); - } - return `${lines.join('\n')}\n`; -} - -// CLI entrypoint -async function main() { - // 1. Run size-limit. Capture JSON. execFile (no shell). - let raw; - try { - // `--silent` suppresses pnpm's `>