From 1e4db1bd473361d670041d619d024ad07e5f2c2b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 13:40:49 +0200 Subject: [PATCH 01/14] ci: Check bundle size increases per PR Co-Authored-By: GPT-6 --- .github/workflows/build.yml | 2 + .github/workflows/bump-size-limits.yml | 106 -------- .github/workflows/size-check-label.yml | 90 +++++++ .size-limit.js | 50 ---- 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(+), 791 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 908dfc211966..8eb701e40192 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -310,6 +310,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 519274ad398c..000000000000 --- a/.github/workflows/bump-size-limits.yml +++ /dev/null @@ -1,106 +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 - -env: - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ${{ github.workspace }}/dev-packages/*/node_modules - ~/.cache/mongodb-binaries/ - -concurrency: - group: bump-size-limits - cancel-in-progress: false - -jobs: - bump: - name: Bump size-limit thresholds - runs-on: ubuntu-24.04 - 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: yarn 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 e682d8e1c8b7..a10cf475824f 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: '33 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: '57 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: '57 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: '60 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: '96 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: '85 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: '101 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: '114 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: '52 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: '45 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: '36 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: '36 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -196,7 +179,6 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '59 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: '42 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: '59 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'], }, // Browser CDN bundles @@ -230,63 +209,54 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '36 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '57 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: '59 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '79 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '95 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: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '101 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: '103 KB', disablePlugins: ['@size-limit/esbuild'], }, // browser CDN bundles (non-gzipped) @@ -295,7 +265,6 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -303,7 +272,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '159 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -311,7 +279,6 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '103 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -319,7 +286,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '165 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -327,7 +293,6 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '233 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -335,7 +300,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '279 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -343,7 +307,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '285 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -351,7 +314,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '293 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -359,7 +321,6 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '299 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) @@ -369,7 +330,6 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '61 KB', disablePlugins: ['@size-limit/esbuild'], }, // SvelteKit SDK (ESM) @@ -379,7 +339,6 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '57 KB', disablePlugins: ['@size-limit/esbuild'], }, // Core SDK subpath entry points (ESM) @@ -388,7 +347,6 @@ module.exports = [ path: 'packages/core/build/esm/server.js', import: '*', gzip: true, - limit: '45 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -396,7 +354,6 @@ module.exports = [ path: 'packages/core/build/esm/browser.js', import: '*', gzip: true, - limit: '19 KB', disablePlugins: ['@size-limit/esbuild'], }, // Node SDK (ESM) @@ -406,7 +363,6 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '139 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -414,7 +370,6 @@ module.exports = [ path: ['packages/server-runtime-injection/build/esm/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '88 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { // Both packages declare `sideEffects: false`, which lets webpack @@ -430,7 +385,6 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '96 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -452,7 +406,6 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '118 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -473,7 +426,6 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '104 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -484,7 +436,6 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '208 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -504,7 +455,6 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '507 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 7275274bb3c2..9b2963091ce1 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 bf2ab92909fd..000000000000 --- a/scripts/bump-size-limits.mjs +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Auto-bumper for .size-limit.js. - * - * - Reads `yarn 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 `yarn 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 yarn's `yarn run v…` header and `Done in …` footer, - // which would otherwise break JSON.parse on the captured stdout. - const { stdout } = await execFileAsync('yarn', ['--silent', 'size-limit', '--json'], { - cwd: REPO_ROOT, - maxBuffer: 16 * 1024 * 1024, - }); - raw = stdout; - } catch (err) { - // size-limit exits non-zero when entries fail their existing limit. We still want the JSON. - if (err && typeof err === 'object' && 'stdout' in err && err.stdout) { - raw = /** @type {string} */ (err.stdout); - } else { - throw err; - } - } - - const measurements = parseSizeLimitOutput(raw); - - // 2. Read .size-limit.js as text. NEVER require() it. - const src = await readFile(SIZE_LIMIT_FILE, 'utf8'); - - // 3. Compute changes. - const changes = []; - const summaryRows = []; - for (const m of measurements) { - const newBytes = computeNewLimit(m.size); - - const cur = extractCurrentLimit(src, m.name); - if (!cur) { - throw new Error(`size-limit reported entry '${m.name}' but it was not found in .size-limit.js`); - } - - const displayValue = bytesToDisplay(newBytes, cur.unit); - const newLimitStr = `${displayValue} ${cur.unit}`; - - if (newLimitStr === cur.raw) { - // After unit conversion the displayed value didn't move. Skip — avoids - // no-op edits caused by KiB rounding. - continue; - } - - changes.push({ name: m.name, newLimitKb: displayValue, unit: cur.unit }); - summaryRows.push({ - name: m.name, - oldLimit: cur.raw, - newLimit: newLimitStr, - delta: displayValue - cur.value, - unit: cur.unit, - }); - } - - // 4. Print summary regardless (workflow captures stdout). - process.stdout.write(renderSummary(summaryRows)); - - if (changes.length === 0) { - process.exit(2); - } - - // 5. Atomic write: temp file + rename. - const updated = rewriteSizeLimitFile(src, changes); - const tmpPath = `${SIZE_LIMIT_FILE}.tmp`; - await writeFile(tmpPath, updated, 'utf8'); - await rename(tmpPath, SIZE_LIMIT_FILE); - - process.exit(0); -} - -const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); -if (isMain) { - main().catch(err => { - // oxlint-disable-next-line no-console - console.error(err.stack || err.message || err); - process.exit(1); - }); -} diff --git a/scripts/bump-size-limits.test.ts b/scripts/bump-size-limits.test.ts deleted file mode 100644 index ee046ea9f619..000000000000 --- a/scripts/bump-size-limits.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { describe, expect, it } from 'vitest'; -// @ts-expect-error -- .mjs source has no declarations under `moduleResolution: "node"` -import * as bumpSizeLimits from './bump-size-limits.mjs'; - -const { - BYTES_PER_KB, - BYTES_PER_KIB, - computeNewLimit, - extractCurrentLimit, - HEADROOM_BYTES, - parseSizeLimitOutput, - renderSummary, - rewriteSizeLimitFile, - sanitizeMarkdownCell, -} = bumpSizeLimits; - -const FIXTURE_PATH = path.join(__dirname, '__fixtures__', 'size-limit-sample.js'); -function readFixture(): string { - return fs.readFileSync(FIXTURE_PATH, 'utf8'); -} - -describe('constants', () => { - it('exports the documented thresholds', () => { - expect(HEADROOM_BYTES).toBe(5000); - expect(BYTES_PER_KB).toBe(1000); - expect(BYTES_PER_KIB).toBe(1024); - }); -}); - -describe('computeNewLimit', () => { - it('always returns currentSize + 5 KB, rounded up to the next full KB', () => { - // current 27_500 → +5000 = 32_500 → ceil to 33_000 - expect(computeNewLimit(27_500)).toBe(33_000); - // current 21_000 → +5000 = 26_000 → already round → 26_000 - expect(computeNewLimit(21_000)).toBe(26_000); - }); - - it('rounds up to next full KB', () => { - // current 27_001 → +5000 = 32_001 → ceil to 33_000 - expect(computeNewLimit(27_001)).toBe(33_000); - // current 27_999 → +5000 = 32_999 → ceil to 33_000 - expect(computeNewLimit(27_999)).toBe(33_000); - // current 28_000 → +5000 = 33_000 → already round → 33_000 - expect(computeNewLimit(28_000)).toBe(33_000); - }); - - it('handles zero-size measurements safely', () => { - expect(computeNewLimit(0)).toBe(5_000); - }); -}); - -describe('parseSizeLimitOutput', () => { - it('accepts well-formed input and returns name/size/sizeLimit triples', () => { - const raw = JSON.stringify([ - { name: '@sentry/browser', size: 27_500, sizeLimit: 27_000, passed: false }, - { name: 'CDN Bundle', size: 28_000, sizeLimit: 29_000, passed: true }, - ]); - expect(parseSizeLimitOutput(raw)).toEqual([ - { name: '@sentry/browser', size: 27_500, sizeLimit: 27_000 }, - { name: 'CDN Bundle', size: 28_000, sizeLimit: 29_000 }, - ]); - }); - - it('rejects non-array root', () => { - expect(() => parseSizeLimitOutput('{}')).toThrow(/expected array/i); - expect(() => parseSizeLimitOutput('null')).toThrow(/expected array/i); - }); - - it('rejects malformed JSON', () => { - expect(() => parseSizeLimitOutput('not json')).toThrow(SyntaxError); - }); - - it('rejects entries missing required fields', () => { - expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 'x', size: 1 }]))).toThrow(/sizeLimit/); - expect(() => parseSizeLimitOutput(JSON.stringify([{ size: 1, sizeLimit: 2 }]))).toThrow(/name/); - }); - - it('rejects entries with non-string name', () => { - expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 42, size: 1, sizeLimit: 2 }]))).toThrow(/name/); - }); - - it('rejects entries with non-finite numbers', () => { - expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 'x', size: 'one', sizeLimit: 2 }]))).toThrow(/size/); - expect(() => parseSizeLimitOutput('[{"name":"x","size":1e500,"sizeLimit":2}]')).toThrow(/size/); - }); - - it('ignores extra fields without complaint', () => { - const raw = JSON.stringify([{ name: 'x', size: 1, sizeLimit: 2, passed: true, extra: 'ok' }]); - expect(parseSizeLimitOutput(raw)).toEqual([{ name: 'x', size: 1, sizeLimit: 2 }]); - }); -}); - -describe('sanitizeMarkdownCell', () => { - it('passes plain text through unchanged', () => { - expect(sanitizeMarkdownCell('@sentry/browser')).toBe('@sentry/browser'); - }); - - it('escapes pipes', () => { - expect(sanitizeMarkdownCell('a|b')).toBe('a\\|b'); - }); - - it('escapes backticks', () => { - expect(sanitizeMarkdownCell('a`b')).toBe('a\\`b'); - }); - - it('replaces newlines with spaces', () => { - expect(sanitizeMarkdownCell('a\nb')).toBe('a b'); - expect(sanitizeMarkdownCell('a\r\nb')).toBe('a b'); - }); - - it('preserves parentheses, commas, periods', () => { - expect(sanitizeMarkdownCell('CDN Bundle (incl. Tracing, Replay)')).toBe('CDN Bundle (incl. Tracing, Replay)'); - }); -}); - -describe('renderSummary', () => { - it('renders an empty header when there are no changes', () => { - const out = renderSummary([]); - expect(out).toContain('## Size limit auto-bump'); - expect(out).toContain('All size limits already provide ≄5 KB headroom. No changes needed.'); - }); - - it('renders a markdown table for one change', () => { - const out = renderSummary([ - { name: '@sentry/browser', oldLimit: '27 KB', newLimit: '28 KB', delta: 1, unit: 'KB' }, - ]); - expect(out).toContain('| Entry | Old limit | New limit | Ī” |'); - expect(out).toContain('| @sentry/browser | 27 KB | 28 KB | +1 KB |'); - }); - - it('formats negative deltas with a minus', () => { - const out = renderSummary([ - { name: '@sentry/node', oldLimit: '177 KB', newLimit: '175 KB', delta: -2, unit: 'KB' }, - ]); - expect(out).toContain('| @sentry/node | 177 KB | 175 KB | -2 KB |'); - }); - - it('uses the entry unit for the delta column (KiB)', () => { - const out = renderSummary([ - { - name: '@sentry/cloudflare (withSentry)', - oldLimit: '420 KiB', - newLimit: '425 KiB', - delta: 5, - unit: 'KiB', - }, - ]); - expect(out).toContain('| @sentry/cloudflare (withSentry) | 420 KiB | 425 KiB | +5 KiB |'); - }); - - it('escapes pipes in entry names', () => { - const out = renderSummary([{ name: 'evil|name', oldLimit: '1 KB', newLimit: '2 KB', delta: 1, unit: 'KB' }]); - expect(out).toContain('evil\\|name'); - }); -}); - -describe('rewriteSizeLimitFile', () => { - it('updates a single entry, preserving KB unit', () => { - const src = readFixture(); - const out = rewriteSizeLimitFile(src, [{ name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }]); - expect(out).toMatch(/name: '@sentry\/browser',[\s\S]*?limit: '28 KB',/); - expect(out).toMatch(/name: '@sentry\/browser - with treeshaking flags',[\s\S]*?limit: '25 KB',/); - }); - - it('updates entries with name-prefix collision correctly', () => { - const src = readFixture(); - const out = rewriteSizeLimitFile(src, [ - { name: '@sentry/browser - with treeshaking flags', newLimitKb: 30, unit: 'KB' }, - ]); - expect(out).toMatch(/name: '@sentry\/browser',[\s\S]*?limit: '27 KB',/); - expect(out).toMatch(/name: '@sentry\/browser - with treeshaking flags',[\s\S]*?limit: '30 KB',/); - }); - - it('preserves KiB unit', () => { - const src = readFixture(); - const out = rewriteSizeLimitFile(src, [{ name: '@sentry/cloudflare (withSentry)', newLimitKb: 425, unit: 'KiB' }]); - expect(out).toMatch(/name: '@sentry\/cloudflare \(withSentry\)',[\s\S]*?limit: '425 KiB',/); - }); - - it('handles names with parentheses and decimals in original limit', () => { - const src = readFixture(); - const out = rewriteSizeLimitFile(src, [{ name: 'CDN Bundle (incl. Tracing)', newLimitKb: 50, unit: 'KB' }]); - expect(out).toMatch(/name: 'CDN Bundle \(incl\. Tracing\)',[\s\S]*?limit: '50 KB',/); - expect(out).not.toContain("limit: '46.5 KB'"); - }); - - it('applies multiple changes', () => { - const src = readFixture(); - const out = rewriteSizeLimitFile(src, [ - { name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }, - { name: 'CDN Bundle (incl. Tracing)', newLimitKb: 50, unit: 'KB' }, - ]); - expect(out).toContain("limit: '28 KB'"); - expect(out).toContain("limit: '50 KB'"); - }); - - it('throws if a name does not match any entry', () => { - const src = readFixture(); - expect(() => rewriteSizeLimitFile(src, [{ name: '@sentry/nonexistent', newLimitKb: 1, unit: 'KB' }])).toThrow( - /@sentry\/nonexistent/, - ); - }); - - it('returns unchanged source when changes is empty', () => { - const src = readFixture(); - expect(rewriteSizeLimitFile(src, [])).toBe(src); - }); - - it('does not modify the input string in-place', () => { - const src = readFixture(); - const before = src; - rewriteSizeLimitFile(src, [{ name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }]); - expect(src).toBe(before); - }); -}); - -describe('extractCurrentLimit', () => { - const FIXTURE_SRC = `module.exports = [ - { name: '@sentry/browser', limit: '27 KB' }, - { name: '@sentry/cloudflare (withSentry)', limit: '420 KiB' }, -];`; - - it('extracts the limit value and unit by name', () => { - expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/browser')).toEqual({ - value: 27, - unit: 'KB', - raw: '27 KB', - }); - expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/cloudflare (withSentry)')).toEqual({ - value: 420, - unit: 'KiB', - raw: '420 KiB', - }); - }); - - it('returns null when the name is not present', () => { - expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/missing')).toBeNull(); - }); -}); diff --git a/scripts/size-limit-action.test.ts b/scripts/size-limit-action.test.ts new file mode 100644 index 000000000000..595d23a7e2e2 --- /dev/null +++ b/scripts/size-limit-action.test.ts @@ -0,0 +1,195 @@ +import * as path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getInput: vi.fn(), + setFailed: vi.fn(), + summary: { addRaw: vi.fn(), write: vi.fn() }, + exec: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + uploadArtifact: vi.fn(), + getArtifacts: vi.fn(), + context: { + repo: { owner: 'getsentry', repo: 'sentry-javascript' }, + payload: { pull_request: { number: 21813, labels: [] as { name: string }[] } }, + }, + octokit: { + rest: { + pulls: { get: vi.fn() }, + issues: { listComments: vi.fn(), createComment: vi.fn(), updateComment: vi.fn() }, + actions: { downloadArtifact: vi.fn() }, + }, + }, +})); + +vi.mock('node:fs', () => ({ promises: { readFile: mocks.readFile, writeFile: mocks.writeFile } })); +vi.mock('@actions/core', () => ({ + getInput: mocks.getInput, + setFailed: mocks.setFailed, + summary: mocks.summary, + startGroup: vi.fn(), + endGroup: vi.fn(), + info: vi.fn(), + error: vi.fn(), + warning: vi.fn(), +})); +vi.mock('@actions/github', () => ({ context: mocks.context, getOctokit: () => mocks.octokit })); +vi.mock('@actions/exec', () => ({ exec: mocks.exec })); +vi.mock('@actions/io', () => ({ mkdirP: vi.fn() })); +vi.mock('@actions/glob', () => ({ create: () => ({ glob: () => ['size-limit-results.json'] }) })); +vi.mock('@actions/artifact', () => ({ + DefaultArtifactClient: class { + uploadArtifact = mocks.uploadArtifact; + }, +})); +vi.mock('../dev-packages/size-limit-gh-action/utils/getArtifactsForBranchAndWorkflow.mjs', () => ({ + getArtifactsForBranchAndWorkflow: mocks.getArtifacts, +})); + +const overrideLabel = { name: 'Accept Bundlesize Increase' }; +const baseline = { '@sentry/browser': { name: '@sentry/browser', size: 30_000, passed: true, sizeLimit: 34_000 } }; + +function measure(size: number, status = 0): void { + mocks.exec.mockImplementation(async (command, _args, options) => { + if (command === 'yarn run --silent size-limit --json') { + options.listeners.stdout(Buffer.from(JSON.stringify([{ name: '@sentry/browser', size }]))); + return status; + } + return 0; + }); +} + +async function runAction(): Promise { + await import('../dev-packages/size-limit-gh-action/index.mjs'); +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + vi.resetModules(); + mocks.context.payload.pull_request.labels = []; + mocks.getInput.mockImplementation(name => (name === 'comparison_branch' ? 'develop' : '')); + mocks.readFile.mockResolvedValue(JSON.stringify(baseline)); + mocks.summary.addRaw.mockReturnValue(mocks.summary); + mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [] } }); + mocks.octokit.rest.issues.listComments.mockResolvedValue({ data: [] }); + mocks.octokit.rest.actions.downloadArtifact.mockResolvedValue({ url: 'https://example.com/baseline.zip' }); + mocks.getArtifacts.mockResolvedValue({ + artifact: { id: 1 }, + workflowRun: { html_url: 'https://github.com/getsentry/sentry-javascript/actions/runs/1' }, + isLatest: true, + }); + measure(30_501); +}); + +describe('size check action', () => { + it('fails excessive growth and reports the bundle and override label', async () => { + await runAction(); + + expect(mocks.setFailed).toHaveBeenCalledWith( + 'Gzipped bundles increased by more than 500 bytes:\n@sentry/browser: +501 bytes\n' + + 'Apply "Accept Bundlesize Increase" to acknowledge the increase.', + ); + expect(mocks.octokit.rest.issues.createComment).toHaveBeenCalledOnce(); + expect(mocks.summary.write).toHaveBeenCalledOnce(); + }); + + it('accepts a label added after the original event and still posts the report', async () => { + mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [overrideLabel] } }); + + await runAction(); + + expect(mocks.octokit.rest.pulls.get).toHaveBeenCalledWith({ + owner: 'getsentry', + repo: 'sentry-javascript', + pull_number: 21813, + }); + expect(mocks.setFailed).not.toHaveBeenCalled(); + expect(mocks.summary.addRaw).toHaveBeenCalledWith( + expect.stringContaining('Bundle size increase acknowledged by "Accept Bundlesize Increase".'), + ); + expect(mocks.octokit.rest.issues.createComment).toHaveBeenCalledOnce(); + }); + + it('enforces growth again when the label has been removed since the original event', async () => { + mocks.context.payload.pull_request.labels = [overrideLabel]; + + await runAction(); + + expect(mocks.setFailed).toHaveBeenCalledOnce(); + }); + + it('updates the report even when bundle sizes have not changed', async () => { + measure(30_000); + mocks.octokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 2, body: '## size-limit report šŸ“¦ previous report' }], + }); + + await runAction(); + + expect(mocks.setFailed).not.toHaveBeenCalled(); + expect(mocks.octokit.rest.issues.updateComment).toHaveBeenCalledOnce(); + expect(mocks.octokit.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it('fails missing baselines even with the override label and still reports measured sizes', async () => { + mocks.getArtifacts.mockResolvedValue(null); + mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [overrideLabel] } }); + + await runAction(); + + expect(mocks.setFailed).toHaveBeenCalledWith( + 'No baseline size measurements found. Re-run after the base build completes.', + ); + expect(mocks.octokit.rest.issues.createComment).toHaveBeenCalledOnce(); + }); + + it('does not allow the label to hide measurement failures', async () => { + measure(30_501, 1); + mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [overrideLabel] } }); + + await runAction(); + + expect(mocks.setFailed).toHaveBeenCalledWith('Bundle size measurement failed.'); + }); + + it('keeps enforcing growth if a fork cannot post a comment', async () => { + mocks.octokit.rest.issues.createComment.mockRejectedValue(new Error('Forbidden')); + + await runAction(); + + expect(mocks.setFailed).toHaveBeenCalledWith(expect.stringContaining('@sentry/browser: +501 bytes')); + expect(mocks.summary.write).toHaveBeenCalledOnce(); + }); + + it('saves absolute measurements for baseline comparisons and release reports', async () => { + mocks.getInput.mockReturnValue(''); + const artifactDirectory = path.resolve(__dirname, '../dev-packages/size-limit-gh-action'); + + await runAction(); + + expect(mocks.writeFile).toHaveBeenCalledWith( + path.join(artifactDirectory, 'size-limit-results.json'), + JSON.stringify({ '@sentry/browser': { name: '@sentry/browser', size: 30_501 } }), + 'utf8', + ); + expect(mocks.uploadArtifact).toHaveBeenCalledWith( + 'size-limit-action', + ['size-limit-results.json'], + artifactDirectory, + ); + expect(mocks.setFailed).not.toHaveBeenCalled(); + expect(mocks.octokit.rest.pulls.get).not.toHaveBeenCalled(); + }); + + it('does not upload a baseline when measurement fails', async () => { + mocks.getInput.mockReturnValue(''); + measure(30_501, 1); + + await runAction(); + + expect(mocks.setFailed).toHaveBeenCalledWith('Bundle size measurement failed.'); + expect(mocks.uploadArtifact).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/size-limit-formatter.test.ts b/scripts/size-limit-formatter.test.ts new file mode 100644 index 000000000000..b154d9c17f89 --- /dev/null +++ b/scripts/size-limit-formatter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_INCREASE_BYTES, + SizeLimitFormatter, +} from '../dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs'; + +const formatter = new SizeLimitFormatter(); + +describe('bundle size comparison', () => { + it.each([-1_000, 0, MAX_INCREASE_BYTES - 1, MAX_INCREASE_BYTES])('accepts an increase of %i bytes', increase => { + const base = { browser: { size: 30_000 } }; + const current = { browser: { size: 30_000 + increase } }; + + expect(formatter.getSizeIncreases(base, current, [{ name: 'browser', gzip: true }])).toEqual([]); + }); + + it('checks each gzipped bundle independently and ignores uncompressed growth', () => { + const base = { browser: { size: 30_000 }, tracing: { size: 50_000 }, uncompressed: { size: 100_000 } }; + const current = { + browser: { size: 30_000 + MAX_INCREASE_BYTES + 1 }, + tracing: { size: 40_000 }, + uncompressed: { size: 200_000 }, + }; + const config = [ + { name: 'browser', gzip: true }, + { name: 'tracing', gzip: true }, + { name: 'uncompressed', gzip: false }, + ]; + + expect(formatter.getSizeIncreases(base, current, config)).toEqual([ + { name: 'browser', increase: MAX_INCREASE_BYTES + 1 }, + ]); + }); + + it('ignores added and removed scenarios but compares zero-byte baselines', () => { + const base = { removed: { size: 30_000 }, empty: { size: 0 } }; + const current = { added: { size: 30_000 }, empty: { size: MAX_INCREASE_BYTES + 1 } }; + const config = [ + { name: 'added', gzip: true }, + { name: 'empty', gzip: true }, + ]; + + expect(formatter.getSizeIncreases(base, current, config)).toEqual([ + { name: 'empty', increase: MAX_INCREASE_BYTES + 1 }, + ]); + }); + + it('parses measurements without absolute limits and ignores legacy budget fields', () => { + const output = JSON.stringify([ + { name: 'browser', size: 30_000 }, + { name: 'tracing', size: 50_000, passed: false, sizeLimit: 40_000 }, + ]); + + expect(formatter.parseResults(output)).toEqual({ + browser: { name: 'browser', size: 30_000 }, + tracing: { name: 'tracing', size: 50_000 }, + }); + }); + + it.each(['{}', '[]', '[null]', '[{"name":"browser"}]', '[{"name":"browser","size":-1}]'])( + 'rejects invalid measurements: %s', + output => { + expect(() => formatter.parseResults(output)).toThrow(); + }, + ); + + it('reports additions and removals without absolute-limit failure markers', () => { + const base = { removed: { name: 'removed', size: 1024 } }; + const current = { added: { name: 'added', size: 2048 } }; + + expect(formatter.formatResults(base, current)).toEqual([ + ['Path', 'Size', '% Change', 'Change'], + ['removed', '0 B', 'removed', 'removed'], + ['added', '2.05 kB', 'added', 'added'], + ]); + }); +}); diff --git a/scripts/size-limit-rerun.test.ts b/scripts/size-limit-rerun.test.ts new file mode 100644 index 000000000000..b271ddf94ddf --- /dev/null +++ b/scripts/size-limit-rerun.test.ts @@ -0,0 +1,138 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { load } from 'js-yaml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const workflow = load(readFileSync('.github/workflows/size-check-label.yml', 'utf8')) as { + jobs: { rerun: { steps: { with: { script: string } }[] } }; +}; +const script = workflow.jobs.rerun.steps[0]!.with.script; +const repo = { owner: 'getsentry', repo: 'sentry-javascript' }; +const context = { + repo, + payload: { + pull_request: { + number: 21813, + head: { sha: 'current-head', ref: 'feat/bundle-size', repo: { full_name: 'contributor/sentry-javascript' } }, + }, + }, +}; +const github = { + paginate: vi.fn(), + rest: { + pulls: { get: vi.fn() }, + actions: { + listWorkflowRuns: vi.fn(), + listJobsForWorkflowRun: vi.fn(), + getWorkflowRun: vi.fn(), + reRunJobForWorkflowRun: vi.fn(), + }, + }, +}; +const core = { info: vi.fn() }; +const run = { + id: 10, + head_repository: { full_name: 'contributor/sentry-javascript' }, + head_branch: 'feat/bundle-size', +}; + +async function trigger(): Promise { + await runInNewContext(`(async () => { ${script} })()`, { github, context, core, setTimeout }); +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + github.paginate + .mockResolvedValueOnce([run]) + .mockResolvedValueOnce([{ id: 20, name: 'Size Check', conclusion: 'failure' }]); + github.rest.actions.getWorkflowRun.mockResolvedValue({ data: { status: 'completed' } }); + github.rest.pulls.get.mockResolvedValue({ data: { state: 'open', head: { sha: 'current-head' } } }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('label-triggered size check', () => { + it('reruns the size job for the current PR head, including fork PRs', async () => { + await trigger(); + + expect(github.paginate).toHaveBeenCalledWith(github.rest.actions.listWorkflowRuns, { + ...repo, + workflow_id: 'build.yml', + event: 'pull_request', + head_sha: 'current-head', + per_page: 100, + }); + expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 20 }); + }); + + it('chooses the newest matching run and excludes other repositories and branches', async () => { + github.paginate.mockReset(); + github.paginate + .mockResolvedValueOnce([ + run, + { ...run, id: 11 }, + { ...run, id: 12, head_repository: { full_name: 'someone-else/sentry-javascript' } }, + { ...run, id: 13, head_branch: 'feat/other' }, + ]) + .mockResolvedValueOnce([{ id: 21, name: 'Size Check', conclusion: 'failure' }]); + + await trigger(); + + expect(github.rest.actions.getWorkflowRun).toHaveBeenCalledWith({ ...repo, run_id: 11 }); + expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 21 }); + }); + + it('reruns a successful size job too, so label removal restores enforcement', async () => { + github.paginate.mockReset(); + github.paginate + .mockResolvedValueOnce([run]) + .mockResolvedValueOnce([{ id: 20, name: 'Size Check', conclusion: 'success' }]); + + await trigger(); + + expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 20 }); + }); + + it('waits for the existing workflow to finish before requesting a rerun', async () => { + github.rest.actions.getWorkflowRun.mockResolvedValueOnce({ data: { status: 'in_progress' } }); + vi.useFakeTimers(); + + const result = trigger(); + await vi.advanceTimersByTimeAsync(30_000); + await result; + + expect(github.rest.actions.getWorkflowRun).toHaveBeenCalledTimes(2); + expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 20 }); + }); + + it('does not rerun another branch when no matching run exists', async () => { + github.paginate.mockReset(); + github.paginate.mockResolvedValueOnce([]); + + await trigger(); + + expect(github.rest.actions.reRunJobForWorkflowRun).not.toHaveBeenCalled(); + }); + + it('does not rerun a skipped size check', async () => { + github.paginate.mockReset(); + github.paginate + .mockResolvedValueOnce([run]) + .mockResolvedValueOnce([{ id: 20, name: 'Size Check', conclusion: 'skipped' }]); + + await trigger(); + + expect(github.rest.actions.reRunJobForWorkflowRun).not.toHaveBeenCalled(); + }); + + it('does not rerun an old commit after a new push while waiting', async () => { + github.rest.pulls.get.mockResolvedValue({ data: { state: 'open', head: { sha: 'new-head' } } }); + + await trigger(); + + expect(github.rest.actions.reRunJobForWorkflowRun).not.toHaveBeenCalled(); + }); +}); From cf9b39e5c970605f9e4694f022757c18d55241e9 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 13:42:58 +0200 Subject: [PATCH 02/14] ci: Remove size-check action tests Co-Authored-By: GPT-6 --- .github/workflows/build.yml | 2 - scripts/size-limit-action.test.ts | 195 --------------------------- scripts/size-limit-formatter.test.ts | 77 ----------- scripts/size-limit-rerun.test.ts | 138 ------------------- 4 files changed, 412 deletions(-) delete mode 100644 scripts/size-limit-action.test.ts delete mode 100644 scripts/size-limit-formatter.test.ts delete mode 100644 scripts/size-limit-rerun.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8eb701e40192..908dfc211966 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -310,8 +310,6 @@ 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/scripts/size-limit-action.test.ts b/scripts/size-limit-action.test.ts deleted file mode 100644 index 595d23a7e2e2..000000000000 --- a/scripts/size-limit-action.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import * as path from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - getInput: vi.fn(), - setFailed: vi.fn(), - summary: { addRaw: vi.fn(), write: vi.fn() }, - exec: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - uploadArtifact: vi.fn(), - getArtifacts: vi.fn(), - context: { - repo: { owner: 'getsentry', repo: 'sentry-javascript' }, - payload: { pull_request: { number: 21813, labels: [] as { name: string }[] } }, - }, - octokit: { - rest: { - pulls: { get: vi.fn() }, - issues: { listComments: vi.fn(), createComment: vi.fn(), updateComment: vi.fn() }, - actions: { downloadArtifact: vi.fn() }, - }, - }, -})); - -vi.mock('node:fs', () => ({ promises: { readFile: mocks.readFile, writeFile: mocks.writeFile } })); -vi.mock('@actions/core', () => ({ - getInput: mocks.getInput, - setFailed: mocks.setFailed, - summary: mocks.summary, - startGroup: vi.fn(), - endGroup: vi.fn(), - info: vi.fn(), - error: vi.fn(), - warning: vi.fn(), -})); -vi.mock('@actions/github', () => ({ context: mocks.context, getOctokit: () => mocks.octokit })); -vi.mock('@actions/exec', () => ({ exec: mocks.exec })); -vi.mock('@actions/io', () => ({ mkdirP: vi.fn() })); -vi.mock('@actions/glob', () => ({ create: () => ({ glob: () => ['size-limit-results.json'] }) })); -vi.mock('@actions/artifact', () => ({ - DefaultArtifactClient: class { - uploadArtifact = mocks.uploadArtifact; - }, -})); -vi.mock('../dev-packages/size-limit-gh-action/utils/getArtifactsForBranchAndWorkflow.mjs', () => ({ - getArtifactsForBranchAndWorkflow: mocks.getArtifacts, -})); - -const overrideLabel = { name: 'Accept Bundlesize Increase' }; -const baseline = { '@sentry/browser': { name: '@sentry/browser', size: 30_000, passed: true, sizeLimit: 34_000 } }; - -function measure(size: number, status = 0): void { - mocks.exec.mockImplementation(async (command, _args, options) => { - if (command === 'yarn run --silent size-limit --json') { - options.listeners.stdout(Buffer.from(JSON.stringify([{ name: '@sentry/browser', size }]))); - return status; - } - return 0; - }); -} - -async function runAction(): Promise { - await import('../dev-packages/size-limit-gh-action/index.mjs'); -} - -beforeEach(() => { - vi.restoreAllMocks(); - vi.resetAllMocks(); - vi.resetModules(); - mocks.context.payload.pull_request.labels = []; - mocks.getInput.mockImplementation(name => (name === 'comparison_branch' ? 'develop' : '')); - mocks.readFile.mockResolvedValue(JSON.stringify(baseline)); - mocks.summary.addRaw.mockReturnValue(mocks.summary); - mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [] } }); - mocks.octokit.rest.issues.listComments.mockResolvedValue({ data: [] }); - mocks.octokit.rest.actions.downloadArtifact.mockResolvedValue({ url: 'https://example.com/baseline.zip' }); - mocks.getArtifacts.mockResolvedValue({ - artifact: { id: 1 }, - workflowRun: { html_url: 'https://github.com/getsentry/sentry-javascript/actions/runs/1' }, - isLatest: true, - }); - measure(30_501); -}); - -describe('size check action', () => { - it('fails excessive growth and reports the bundle and override label', async () => { - await runAction(); - - expect(mocks.setFailed).toHaveBeenCalledWith( - 'Gzipped bundles increased by more than 500 bytes:\n@sentry/browser: +501 bytes\n' + - 'Apply "Accept Bundlesize Increase" to acknowledge the increase.', - ); - expect(mocks.octokit.rest.issues.createComment).toHaveBeenCalledOnce(); - expect(mocks.summary.write).toHaveBeenCalledOnce(); - }); - - it('accepts a label added after the original event and still posts the report', async () => { - mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [overrideLabel] } }); - - await runAction(); - - expect(mocks.octokit.rest.pulls.get).toHaveBeenCalledWith({ - owner: 'getsentry', - repo: 'sentry-javascript', - pull_number: 21813, - }); - expect(mocks.setFailed).not.toHaveBeenCalled(); - expect(mocks.summary.addRaw).toHaveBeenCalledWith( - expect.stringContaining('Bundle size increase acknowledged by "Accept Bundlesize Increase".'), - ); - expect(mocks.octokit.rest.issues.createComment).toHaveBeenCalledOnce(); - }); - - it('enforces growth again when the label has been removed since the original event', async () => { - mocks.context.payload.pull_request.labels = [overrideLabel]; - - await runAction(); - - expect(mocks.setFailed).toHaveBeenCalledOnce(); - }); - - it('updates the report even when bundle sizes have not changed', async () => { - measure(30_000); - mocks.octokit.rest.issues.listComments.mockResolvedValue({ - data: [{ id: 2, body: '## size-limit report šŸ“¦ previous report' }], - }); - - await runAction(); - - expect(mocks.setFailed).not.toHaveBeenCalled(); - expect(mocks.octokit.rest.issues.updateComment).toHaveBeenCalledOnce(); - expect(mocks.octokit.rest.issues.createComment).not.toHaveBeenCalled(); - }); - - it('fails missing baselines even with the override label and still reports measured sizes', async () => { - mocks.getArtifacts.mockResolvedValue(null); - mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [overrideLabel] } }); - - await runAction(); - - expect(mocks.setFailed).toHaveBeenCalledWith( - 'No baseline size measurements found. Re-run after the base build completes.', - ); - expect(mocks.octokit.rest.issues.createComment).toHaveBeenCalledOnce(); - }); - - it('does not allow the label to hide measurement failures', async () => { - measure(30_501, 1); - mocks.octokit.rest.pulls.get.mockResolvedValue({ data: { labels: [overrideLabel] } }); - - await runAction(); - - expect(mocks.setFailed).toHaveBeenCalledWith('Bundle size measurement failed.'); - }); - - it('keeps enforcing growth if a fork cannot post a comment', async () => { - mocks.octokit.rest.issues.createComment.mockRejectedValue(new Error('Forbidden')); - - await runAction(); - - expect(mocks.setFailed).toHaveBeenCalledWith(expect.stringContaining('@sentry/browser: +501 bytes')); - expect(mocks.summary.write).toHaveBeenCalledOnce(); - }); - - it('saves absolute measurements for baseline comparisons and release reports', async () => { - mocks.getInput.mockReturnValue(''); - const artifactDirectory = path.resolve(__dirname, '../dev-packages/size-limit-gh-action'); - - await runAction(); - - expect(mocks.writeFile).toHaveBeenCalledWith( - path.join(artifactDirectory, 'size-limit-results.json'), - JSON.stringify({ '@sentry/browser': { name: '@sentry/browser', size: 30_501 } }), - 'utf8', - ); - expect(mocks.uploadArtifact).toHaveBeenCalledWith( - 'size-limit-action', - ['size-limit-results.json'], - artifactDirectory, - ); - expect(mocks.setFailed).not.toHaveBeenCalled(); - expect(mocks.octokit.rest.pulls.get).not.toHaveBeenCalled(); - }); - - it('does not upload a baseline when measurement fails', async () => { - mocks.getInput.mockReturnValue(''); - measure(30_501, 1); - - await runAction(); - - expect(mocks.setFailed).toHaveBeenCalledWith('Bundle size measurement failed.'); - expect(mocks.uploadArtifact).not.toHaveBeenCalled(); - }); -}); diff --git a/scripts/size-limit-formatter.test.ts b/scripts/size-limit-formatter.test.ts deleted file mode 100644 index b154d9c17f89..000000000000 --- a/scripts/size-limit-formatter.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - MAX_INCREASE_BYTES, - SizeLimitFormatter, -} from '../dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs'; - -const formatter = new SizeLimitFormatter(); - -describe('bundle size comparison', () => { - it.each([-1_000, 0, MAX_INCREASE_BYTES - 1, MAX_INCREASE_BYTES])('accepts an increase of %i bytes', increase => { - const base = { browser: { size: 30_000 } }; - const current = { browser: { size: 30_000 + increase } }; - - expect(formatter.getSizeIncreases(base, current, [{ name: 'browser', gzip: true }])).toEqual([]); - }); - - it('checks each gzipped bundle independently and ignores uncompressed growth', () => { - const base = { browser: { size: 30_000 }, tracing: { size: 50_000 }, uncompressed: { size: 100_000 } }; - const current = { - browser: { size: 30_000 + MAX_INCREASE_BYTES + 1 }, - tracing: { size: 40_000 }, - uncompressed: { size: 200_000 }, - }; - const config = [ - { name: 'browser', gzip: true }, - { name: 'tracing', gzip: true }, - { name: 'uncompressed', gzip: false }, - ]; - - expect(formatter.getSizeIncreases(base, current, config)).toEqual([ - { name: 'browser', increase: MAX_INCREASE_BYTES + 1 }, - ]); - }); - - it('ignores added and removed scenarios but compares zero-byte baselines', () => { - const base = { removed: { size: 30_000 }, empty: { size: 0 } }; - const current = { added: { size: 30_000 }, empty: { size: MAX_INCREASE_BYTES + 1 } }; - const config = [ - { name: 'added', gzip: true }, - { name: 'empty', gzip: true }, - ]; - - expect(formatter.getSizeIncreases(base, current, config)).toEqual([ - { name: 'empty', increase: MAX_INCREASE_BYTES + 1 }, - ]); - }); - - it('parses measurements without absolute limits and ignores legacy budget fields', () => { - const output = JSON.stringify([ - { name: 'browser', size: 30_000 }, - { name: 'tracing', size: 50_000, passed: false, sizeLimit: 40_000 }, - ]); - - expect(formatter.parseResults(output)).toEqual({ - browser: { name: 'browser', size: 30_000 }, - tracing: { name: 'tracing', size: 50_000 }, - }); - }); - - it.each(['{}', '[]', '[null]', '[{"name":"browser"}]', '[{"name":"browser","size":-1}]'])( - 'rejects invalid measurements: %s', - output => { - expect(() => formatter.parseResults(output)).toThrow(); - }, - ); - - it('reports additions and removals without absolute-limit failure markers', () => { - const base = { removed: { name: 'removed', size: 1024 } }; - const current = { added: { name: 'added', size: 2048 } }; - - expect(formatter.formatResults(base, current)).toEqual([ - ['Path', 'Size', '% Change', 'Change'], - ['removed', '0 B', 'removed', 'removed'], - ['added', '2.05 kB', 'added', 'added'], - ]); - }); -}); diff --git a/scripts/size-limit-rerun.test.ts b/scripts/size-limit-rerun.test.ts deleted file mode 100644 index b271ddf94ddf..000000000000 --- a/scripts/size-limit-rerun.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import { load } from 'js-yaml'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const workflow = load(readFileSync('.github/workflows/size-check-label.yml', 'utf8')) as { - jobs: { rerun: { steps: { with: { script: string } }[] } }; -}; -const script = workflow.jobs.rerun.steps[0]!.with.script; -const repo = { owner: 'getsentry', repo: 'sentry-javascript' }; -const context = { - repo, - payload: { - pull_request: { - number: 21813, - head: { sha: 'current-head', ref: 'feat/bundle-size', repo: { full_name: 'contributor/sentry-javascript' } }, - }, - }, -}; -const github = { - paginate: vi.fn(), - rest: { - pulls: { get: vi.fn() }, - actions: { - listWorkflowRuns: vi.fn(), - listJobsForWorkflowRun: vi.fn(), - getWorkflowRun: vi.fn(), - reRunJobForWorkflowRun: vi.fn(), - }, - }, -}; -const core = { info: vi.fn() }; -const run = { - id: 10, - head_repository: { full_name: 'contributor/sentry-javascript' }, - head_branch: 'feat/bundle-size', -}; - -async function trigger(): Promise { - await runInNewContext(`(async () => { ${script} })()`, { github, context, core, setTimeout }); -} - -beforeEach(() => { - vi.restoreAllMocks(); - vi.resetAllMocks(); - github.paginate - .mockResolvedValueOnce([run]) - .mockResolvedValueOnce([{ id: 20, name: 'Size Check', conclusion: 'failure' }]); - github.rest.actions.getWorkflowRun.mockResolvedValue({ data: { status: 'completed' } }); - github.rest.pulls.get.mockResolvedValue({ data: { state: 'open', head: { sha: 'current-head' } } }); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('label-triggered size check', () => { - it('reruns the size job for the current PR head, including fork PRs', async () => { - await trigger(); - - expect(github.paginate).toHaveBeenCalledWith(github.rest.actions.listWorkflowRuns, { - ...repo, - workflow_id: 'build.yml', - event: 'pull_request', - head_sha: 'current-head', - per_page: 100, - }); - expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 20 }); - }); - - it('chooses the newest matching run and excludes other repositories and branches', async () => { - github.paginate.mockReset(); - github.paginate - .mockResolvedValueOnce([ - run, - { ...run, id: 11 }, - { ...run, id: 12, head_repository: { full_name: 'someone-else/sentry-javascript' } }, - { ...run, id: 13, head_branch: 'feat/other' }, - ]) - .mockResolvedValueOnce([{ id: 21, name: 'Size Check', conclusion: 'failure' }]); - - await trigger(); - - expect(github.rest.actions.getWorkflowRun).toHaveBeenCalledWith({ ...repo, run_id: 11 }); - expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 21 }); - }); - - it('reruns a successful size job too, so label removal restores enforcement', async () => { - github.paginate.mockReset(); - github.paginate - .mockResolvedValueOnce([run]) - .mockResolvedValueOnce([{ id: 20, name: 'Size Check', conclusion: 'success' }]); - - await trigger(); - - expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 20 }); - }); - - it('waits for the existing workflow to finish before requesting a rerun', async () => { - github.rest.actions.getWorkflowRun.mockResolvedValueOnce({ data: { status: 'in_progress' } }); - vi.useFakeTimers(); - - const result = trigger(); - await vi.advanceTimersByTimeAsync(30_000); - await result; - - expect(github.rest.actions.getWorkflowRun).toHaveBeenCalledTimes(2); - expect(github.rest.actions.reRunJobForWorkflowRun).toHaveBeenCalledWith({ ...repo, job_id: 20 }); - }); - - it('does not rerun another branch when no matching run exists', async () => { - github.paginate.mockReset(); - github.paginate.mockResolvedValueOnce([]); - - await trigger(); - - expect(github.rest.actions.reRunJobForWorkflowRun).not.toHaveBeenCalled(); - }); - - it('does not rerun a skipped size check', async () => { - github.paginate.mockReset(); - github.paginate - .mockResolvedValueOnce([run]) - .mockResolvedValueOnce([{ id: 20, name: 'Size Check', conclusion: 'skipped' }]); - - await trigger(); - - expect(github.rest.actions.reRunJobForWorkflowRun).not.toHaveBeenCalled(); - }); - - it('does not rerun an old commit after a new push while waiting', async () => { - github.rest.pulls.get.mockResolvedValue({ data: { state: 'open', head: { sha: 'new-head' } } }); - - await trigger(); - - expect(github.rest.actions.reRunJobForWorkflowRun).not.toHaveBeenCalled(); - }); -}); From a30aca01fa6198fc1e25e8116f94f6b5d3cf3fe8 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 14:27:53 +0200 Subject: [PATCH 03/14] ci: Simplify size-check action plumbing Co-Authored-By: GPT-6 --- dev-packages/size-limit-gh-action/index.mjs | 126 +++--------------- .../utils/SizeLimitFormatter.mjs | 4 - 2 files changed, 20 insertions(+), 110 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 9b2963091ce1..74b0d149354a 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -3,10 +3,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DefaultArtifactClient } from '@actions/artifact'; import * as core from '@actions/core'; -import { exec } from '@actions/exec'; +import { getExecOutput } from '@actions/exec'; 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'; @@ -15,12 +13,8 @@ import { MAX_INCREASE_BYTES, SizeLimitFormatter } from './utils/SizeLimitFormatt 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'; - -function getResultsFilePath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - return path.resolve(__dirname, RESULTS_FILE); -} +const ACTION_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const RESULTS_FILE_PATH = path.join(ACTION_DIRECTORY, 'size-limit-results.json'); const { getInput, setFailed } = core; @@ -35,29 +29,18 @@ async function fetchPreviousComment(octokit, repo, pr) { } async function execSizeLimit() { - let output = ''; - - const status = await exec('yarn run --silent size-limit --json', [], { - windowsVerbatimArguments: false, + const { exitCode, stdout } = await getExecOutput('yarn', ['run', '--silent', 'size-limit', '--json'], { ignoreReturnCode: true, - cwd: process.cwd(), - listeners: { - stdout: data => { - output += data.toString(); - }, - }, }); - if (status !== 0) { + if (exitCode !== 0) { throw new Error('Bundle size measurement failed.'); } - return output; + return stdout; } async function run() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - try { const { payload, repo } = context; const pr = payload.pull_request; @@ -71,16 +54,16 @@ async function run() { const octokit = getOctokit(githubToken); const limit = new SizeLimitFormatter(); - const resultsFilePath = getResultsFilePath(); + const artifactClient = new DefaultArtifactClient(); + const current = limit.parseResults(await execSizeLimit()); - // If we have no comparison branch, we just run size limit & store the result as artifact if (!comparisonBranch) { - return await runSizeLimitOnComparisonBranch(); + await fs.writeFile(RESULTS_FILE_PATH, JSON.stringify(current), 'utf8'); + await artifactClient.uploadArtifact(ARTIFACT_NAME, [RESULTS_FILE_PATH], ACTION_DIRECTORY); + return; } - // Else, we run size limit for the current branch, AND fetch it for the comparison branch let base; - let current; let baseIsNotLatest = false; let baseWorkflowRun; @@ -101,14 +84,17 @@ async function run() { baseWorkflowRun = artifacts.workflowRun; - await downloadOtherWorkflowArtifact(octokit, { - ...repo, - artifactName: ARTIFACT_NAME, - artifactId: artifacts.artifact.id, - downloadPath: __dirname, + await artifactClient.downloadArtifact(artifacts.artifact.id, { + path: ACTION_DIRECTORY, + findBy: { + token: githubToken, + workflowRunId: artifacts.workflowRun.id, + repositoryOwner: repo.owner, + repositoryName: repo.repo, + }, }); - base = JSON.parse(await fs.readFile(resultsFilePath, { encoding: 'utf8' })); + base = JSON.parse(await fs.readFile(RESULTS_FILE_PATH, { encoding: 'utf8' })); if (!artifacts.isLatest) { baseIsNotLatest = true; @@ -120,14 +106,6 @@ async function run() { core.endGroup(); } - const output = await execSizeLimit(); - try { - current = limit.parseResults(output); - } catch (error) { - core.error('Error parsing size-limit output. The output should be a json.'); - throw error; - } - const { data: currentPr } = await octokit.rest.pulls.get({ ...repo, pull_number: pr.number, @@ -194,68 +172,4 @@ async function run() { } } -async function runSizeLimitOnComparisonBranch() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const resultsFilePath = getResultsFilePath(); - - const limit = new SizeLimitFormatter(); - const artifactClient = new DefaultArtifactClient(); - - const baseOutput = await execSizeLimit(); - - try { - const base = limit.parseResults(baseOutput); - await fs.writeFile(resultsFilePath, JSON.stringify(base), 'utf8'); - } catch (error) { - core.error('Error parsing size-limit output. The output should be a json.'); - throw error; - } - - const globber = await glob.create(resultsFilePath, { - followSymbolicLinks: false, - }); - const files = await globber.glob(); - - await artifactClient.uploadArtifact(ARTIFACT_NAME, files, __dirname); -} - await run(); - -/** - * Use GitHub API to fetch artifact download url, then - * download and extract artifact to `downloadPath` - */ -async function downloadOtherWorkflowArtifact(octokit, { owner, repo, artifactId, artifactName, downloadPath }) { - const artifact = await octokit.rest.actions.downloadArtifact({ - owner, - repo, - artifact_id: artifactId, - archive_format: 'zip', - }); - - // Make sure output path exists - try { - await io.mkdirP(downloadPath); - } catch { - // ignore errors - } - - const downloadFile = path.resolve(downloadPath, `${artifactName}.zip`); - - await exec('wget', [ - '-nv', - '--retry-connrefused', - '--waitretry=1', - '--read-timeout=20', - '--timeout=15', - '-t', - '0', - '-O', - downloadFile, - artifact.url, - ]); - - await exec('unzip', ['-q', '-d', downloadPath, downloadFile], { - silent: true, - }); -} diff --git a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs index adb09862a9e3..ff1c6dacf504 100644 --- a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs +++ b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs @@ -60,10 +60,6 @@ export class SizeLimitFormatter { return `${formatted} šŸ”½`; } - formatLine(value, change) { - return `${value} (${change})`; - } - formatSizeResult(name, base, current) { return [ name, From b7457dbbf320985fb7c956a117d5cd02641027ef Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 14:42:07 +0200 Subject: [PATCH 04/14] ci: Rename size check workflow Co-Authored-By: GPT-6 --- .github/workflows/{size-check-label.yml => size-check.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{size-check-label.yml => size-check.yml} (98%) diff --git a/.github/workflows/size-check-label.yml b/.github/workflows/size-check.yml similarity index 98% rename from .github/workflows/size-check-label.yml rename to .github/workflows/size-check.yml index 39d49f559ad3..901613a8b3d2 100644 --- a/.github/workflows/size-check-label.yml +++ b/.github/workflows/size-check.yml @@ -1,4 +1,4 @@ -name: 'CI: Re-run Size Check' +name: 'CI: Size Check' on: pull_request_target: From b56efab40626057c1f0e06c1cfe385d3bb245312 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 14:45:15 +0200 Subject: [PATCH 05/14] ci: Remove redundant size-check validation Co-Authored-By: GPT-6 --- dev-packages/size-limit-gh-action/index.mjs | 6 +----- .../utils/SizeLimitFormatter.mjs | 19 ++++++------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 74b0d149354a..76adbc06606c 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -48,10 +48,6 @@ async function run() { const comparisonBranch = getInput('comparison_branch'); const githubToken = getInput('github_token'); - if (comparisonBranch && !pr) { - throw new Error('No PR found. Only pull_request workflows are supported.'); - } - const octokit = getOctokit(githubToken); const limit = new SizeLimitFormatter(); const artifactClient = new DefaultArtifactClient(); @@ -68,7 +64,7 @@ async function run() { let baseWorkflowRun; try { - const workflowName = `${process.env.GITHUB_WORKFLOW || ''}`; + const workflowName = process.env.GITHUB_WORKFLOW; core.startGroup(`getArtifactsForBranchAndWorkflow - workflow:"${workflowName}", branch:"${comparisonBranch}"`); const artifacts = await getArtifactsForBranchAndWorkflow(octokit, { ...repo, diff --git a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs index ff1c6dacf504..8e1449f2df7a 100644 --- a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs +++ b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs @@ -72,28 +72,21 @@ 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 { + return results.reduce( + (current, result) => ({ ...current, [result.name]: { name: result.name, size: result.size, }, - }; - }, {}); + }), + {}, + ); } getSizeIncreases(base, current, config) { return config - .filter(({ name, gzip }) => gzip === true && base[name] && current[name]) + .filter(({ name, gzip }) => gzip === true && base[name]) .map(({ name }) => ({ name, increase: current[name].size - base[name].size })) .filter(({ increase }) => increase > MAX_INCREASE_BYTES); } From a89739421a92adf0a2bafd5e26973afe6b88d1a9 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 19:43:37 +0200 Subject: [PATCH 06/14] some simplifications --- dev-packages/size-limit-gh-action/index.mjs | 24 ++++++++++----------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 76adbc06606c..a09e8f5bb2d8 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -28,37 +28,35 @@ async function fetchPreviousComment(octokit, repo, pr) { return !sizeLimitComment ? null : sizeLimitComment; } -async function execSizeLimit() { - const { exitCode, stdout } = await getExecOutput('yarn', ['run', '--silent', 'size-limit', '--json'], { - ignoreReturnCode: true, - }); - - if (exitCode !== 0) { - throw new Error('Bundle size measurement failed.'); - } - - return stdout; -} - async function run() { try { const { payload, repo } = context; const pr = payload.pull_request; + // The comparison branch is the base branch we are comparing against (in our case usually develop) const comparisonBranch = getInput('comparison_branch'); const githubToken = getInput('github_token'); + if (comparisonBranch && !pr) { + throw new Error('No PR found. Only pull_request workflows are supported.'); + } + const octokit = getOctokit(githubToken); const limit = new SizeLimitFormatter(); const artifactClient = new DefaultArtifactClient(); - const current = limit.parseResults(await execSizeLimit()); + // Build and measure each bundle defined in .size-limit.js for the current branch + const { stdout } = await getExecOutput('yarn', ['run', '--silent', 'size-limit', '--json']); + const current = limit.parseResults(stdout); + + // If we have no comparison branch, we only store the results as artifacts (likely running on develop) if (!comparisonBranch) { await fs.writeFile(RESULTS_FILE_PATH, JSON.stringify(current), 'utf8'); await artifactClient.uploadArtifact(ARTIFACT_NAME, [RESULTS_FILE_PATH], ACTION_DIRECTORY); return; } + // Else, we fetch the results for the comparison branch and compare them with the current branch (likely running on a PR) let base; let baseIsNotLatest = false; let baseWorkflowRun; From e27cc26af850390d7c57c159350636739bd0bc31 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 25 Sep 2026 19:49:42 +0200 Subject: [PATCH 07/14] ci: Extract size-check rerun script Co-Authored-By: GPT-6 --- .github/workflows/size-check.yml | 75 ++++---------------------------- scripts/rerun-size-check.mjs | 60 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 66 deletions(-) create mode 100644 scripts/rerun-size-check.mjs diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml index 901613a8b3d2..841e52669bce 100644 --- a/.github/workflows/size-check.yml +++ b/.github/workflows/size-check.yml @@ -5,6 +5,7 @@ on: types: [labeled, unlabeled] permissions: + contents: read actions: write pull-requests: read @@ -18,73 +19,15 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 steps: + - name: Check out base commit + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false - uses: actions/github-script@v9 with: script: | - const pr = context.payload.pull_request; - - const runs = await github.paginate( - github.rest.actions.listWorkflowRuns, - { - ...context.repo, - workflow_id: 'build.yml', - event: 'pull_request', - head_sha: pr.head.sha, - per_page: 100, - }, - ); - - const run = runs - .filter(run => - 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 { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/rerun-size-check.mjs` ); - - 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, - }); + await run({ github, context, core }); diff --git a/scripts/rerun-size-check.mjs b/scripts/rerun-size-check.mjs new file mode 100644 index 000000000000..a686f96faa95 --- /dev/null +++ b/scripts/rerun-size-check.mjs @@ -0,0 +1,60 @@ +export default async function rerunSizeCheck({ github, context, core }) { + const pr = context.payload.pull_request; + + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...context.repo, + workflow_id: 'build.yml', + event: 'pull_request', + head_sha: pr.head.sha, + per_page: 100, + }); + + const run = runs + .filter(run => 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, + }); +} From acd2a81772d1d8c525b9ec0be6322a446107a066 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 09:53:08 +0200 Subject: [PATCH 08/14] rename --- dev-packages/size-limit-gh-action/index.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index a09e8f5bb2d8..6b1edfe80f97 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -45,6 +45,15 @@ async function run() { const limit = new SizeLimitFormatter(); const artifactClient = new DefaultArtifactClient(); + if (comparisonBranch) { + const { data: currentPr } = await octokit.rest.pulls.get({ ...repo, pull_number: pr.number }); + if (currentPr.labels.some(label => label.name === OVERRIDE_LABEL)) { + core.info('Bundle size increase accepted.'); + return; + } + } + + // Build and measure each bundle defined in .size-limit.js for the current branch const { stdout } = await getExecOutput('yarn', ['run', '--silent', 'size-limit', '--json']); const current = limit.parseResults(stdout); @@ -100,12 +109,6 @@ async function run() { core.endGroup(); } - 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) { From 06ec469ea7c099277429be8c91f7d5b05103ef34 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 10:05:38 +0200 Subject: [PATCH 09/14] test(ci): Temporarily exercise bundle size failure and label reruns Co-Authored-By: GPT-6 --- .github/workflows/size-check.yml | 9 +++++---- packages/core/src/sdk.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml index 841e52669bce..5a32e90efce3 100644 --- a/.github/workflows/size-check.yml +++ b/.github/workflows/size-check.yml @@ -1,7 +1,8 @@ name: 'CI: Size Check' on: - pull_request_target: + # Temporary: exercise this workflow on PR #24737 before it exists on develop. + pull_request: types: [labeled, unlabeled] permissions: @@ -15,14 +16,14 @@ concurrency: jobs: rerun: - if: github.event.label.name == 'Accept Bundlesize Increase' + if: github.event.pull_request.number == 24737 && github.event.label.name == 'Accept Bundlesize Increase' runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - - name: Check out base commit + - name: Check out PR commit uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - uses: actions/github-script@v9 with: diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts index e7f20432b91a..dea2a1006607 100644 --- a/packages/core/src/sdk.ts +++ b/packages/core/src/sdk.ts @@ -33,6 +33,24 @@ export function initAndBind( scope.update(options.initialScope); const client = new clientClass(options); + // Temporary CI size-check probe: remove after verifying failure and the approval label. + Object.defineProperty(client, '__sentry_bundle_size_probe__', { + value: + 'A lighthouse keeper records the changing weather beside a rocky northern coastline. ' + + 'Several fishing boats return before sunset, carrying wooden crates and folded canvas sails. ' + + 'Beyond the harbor, a narrow railway crosses green fields toward an abandoned copper mine. ' + + 'An astronomer adjusts a brass telescope while distant clouds reveal a patch of winter stars. ' + + 'Inside the workshop, shelves hold ceramic bowls, leather notebooks, and unusual clockwork instruments. ' + + 'A gardener plants rosemary beneath the kitchen window and collects fallen apples in a wicker basket. ' + + 'Travelers consult a faded map before following the river through limestone caves and pine forests. ' + + 'The morning market offers fresh peaches, woven blankets, painted tiles, and jars of mountain honey. ' + + 'Across the square, musicians rehearse a quiet melody as children draw bright patterns on the pavement. ' + + 'A librarian discovers handwritten letters tucked between the pages of an illustrated botanical atlas. ' + + 'Engineers inspect a suspension bridge using carefully calibrated sensors and detailed maintenance records. ' + + 'After a sudden thunderstorm, sunlight reflects from puddles along the winding cobblestone streets. ' + + 'At the observatory, researchers compare photographs of distant galaxies and catalog unfamiliar constellations. ' + + 'The baker prepares orange pastries while a delivery bicycle rattles past the open courtyard gate.', + }); setCurrentClient(client); client.init(); return client; From 9df0d99108f675c0675ea849fa729c84fcb2548e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 10:11:13 +0200 Subject: [PATCH 10/14] fix(ci): Restore size comparison before applying approval label Co-Authored-By: GPT-6 --- dev-packages/size-limit-gh-action/index.mjs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 6b1edfe80f97..a09e8f5bb2d8 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -45,15 +45,6 @@ async function run() { const limit = new SizeLimitFormatter(); const artifactClient = new DefaultArtifactClient(); - if (comparisonBranch) { - const { data: currentPr } = await octokit.rest.pulls.get({ ...repo, pull_number: pr.number }); - if (currentPr.labels.some(label => label.name === OVERRIDE_LABEL)) { - core.info('Bundle size increase accepted.'); - return; - } - } - - // Build and measure each bundle defined in .size-limit.js for the current branch const { stdout } = await getExecOutput('yarn', ['run', '--silent', 'size-limit', '--json']); const current = limit.parseResults(stdout); @@ -109,6 +100,12 @@ async function run() { core.endGroup(); } + 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) { From 890046081d0d83a306229f18a9bfc26c87cd2690 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 10:29:22 +0200 Subject: [PATCH 11/14] ci: Post bundle size failures in a separate PR comment Co-Authored-By: GPT-6 --- dev-packages/size-limit-gh-action/index.mjs | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index a09e8f5bb2d8..83a3c916ae18 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -118,16 +118,10 @@ async function run() { 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); - } + } else if (increases.length > 0 && !approved) { + failure = + `One or more gzipped bundles increased by more than ${MAX_INCREASE_BYTES} bytes. ` + + `If this increase is intentional, add the **${OVERRIDE_LABEL}** label to this PR to rerun and accept the check.`; } bodyParts.push(markdownTable(limit.formatResults(base, current))); @@ -153,8 +147,16 @@ async function run() { body, }); } + + if (increases.length > 0 && !approved) { + await octokit.rest.issues.createComment({ + ...repo, + issue_number: pr.number, + body: failure, + }); + } } catch { - core.warning('Unable to update the PR comment. The size report is available in the job summary.'); + core.warning('Unable to update PR comments. The size report is available in the job summary.'); } if (failure) { From 7f736be6c82881e40b620003cddd43f0e04b0400 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 10:38:32 +0200 Subject: [PATCH 12/14] fix(ci): Avoid duplicate bundle size failure comments Co-Authored-By: GPT-6 --- dev-packages/size-limit-gh-action/index.mjs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 83a3c916ae18..5ac7492b03ba 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -18,16 +18,6 @@ const RESULTS_FILE_PATH = path.join(ACTION_DIRECTORY, 'size-limit-results.json') const { getInput, setFailed } = core; -async function fetchPreviousComment(octokit, repo, pr) { - const { data: commentList } = await octokit.rest.issues.listComments({ - ...repo, - issue_number: pr.number, - }); - - const sizeLimitComment = commentList.find(comment => comment.body.startsWith(SIZE_LIMIT_HEADING)); - return !sizeLimitComment ? null : sizeLimitComment; -} - async function run() { try { const { payload, repo } = context; @@ -133,7 +123,12 @@ async function run() { await core.summary.addRaw(body).write(); try { - const sizeLimitComment = await fetchPreviousComment(octokit, repo, pr); + const comments = await octokit.paginate(octokit.rest.issues.listComments, { + ...repo, + issue_number: pr.number, + per_page: 100, + }); + const sizeLimitComment = comments.find(comment => comment.body.startsWith(SIZE_LIMIT_HEADING)); if (sizeLimitComment) { await octokit.rest.issues.updateComment({ ...repo, @@ -148,7 +143,7 @@ async function run() { }); } - if (increases.length > 0 && !approved) { + if (increases.length > 0 && !approved && !comments.some(comment => comment.body === failure)) { await octokit.rest.issues.createComment({ ...repo, issue_number: pr.number, From da591f0829d71e4ab2116afb7995c2361f6db822 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 10:55:58 +0200 Subject: [PATCH 13/14] ci: Skip size measurement for approved bundle increases Co-Authored-By: GPT-6 --- dev-packages/size-limit-gh-action/index.mjs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/dev-packages/size-limit-gh-action/index.mjs b/dev-packages/size-limit-gh-action/index.mjs index 5ac7492b03ba..cc6db8da6233 100644 --- a/dev-packages/size-limit-gh-action/index.mjs +++ b/dev-packages/size-limit-gh-action/index.mjs @@ -32,6 +32,18 @@ async function run() { } const octokit = getOctokit(githubToken); + + if (comparisonBranch) { + const { data: currentPr } = await octokit.rest.pulls.get({ + ...repo, + pull_number: pr.number, + }); + if (currentPr.labels.some(label => label.name === OVERRIDE_LABEL)) { + core.info(`Bundle size increase acknowledged by "${OVERRIDE_LABEL}". Skipping size measurement.`); + return; + } + } + const limit = new SizeLimitFormatter(); const artifactClient = new DefaultArtifactClient(); @@ -90,11 +102,6 @@ async function run() { core.endGroup(); } - 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]; @@ -108,7 +115,7 @@ async function run() { if (!base) { failure = 'No baseline size measurements found. Re-run after the base build completes.'; bodyParts.push(failure); - } else if (increases.length > 0 && !approved) { + } else if (increases.length > 0) { failure = `One or more gzipped bundles increased by more than ${MAX_INCREASE_BYTES} bytes. ` + `If this increase is intentional, add the **${OVERRIDE_LABEL}** label to this PR to rerun and accept the check.`; @@ -143,7 +150,7 @@ async function run() { }); } - if (increases.length > 0 && !approved && !comments.some(comment => comment.body === failure)) { + if (increases.length > 0 && !comments.some(comment => comment.body === failure)) { await octokit.rest.issues.createComment({ ...repo, issue_number: pr.number, From 031c8f1ab1fc30b40c876580cb017dd6bed9598f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 26 Sep 2026 11:11:12 +0200 Subject: [PATCH 14/14] ci: Remove temporary bundle size test changes Co-Authored-By: GPT-6 --- .github/workflows/size-check.yml | 9 ++++----- packages/core/src/sdk.ts | 18 ------------------ 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml index 5a32e90efce3..841e52669bce 100644 --- a/.github/workflows/size-check.yml +++ b/.github/workflows/size-check.yml @@ -1,8 +1,7 @@ name: 'CI: Size Check' on: - # Temporary: exercise this workflow on PR #24737 before it exists on develop. - pull_request: + pull_request_target: types: [labeled, unlabeled] permissions: @@ -16,14 +15,14 @@ concurrency: jobs: rerun: - if: github.event.pull_request.number == 24737 && github.event.label.name == 'Accept Bundlesize Increase' + if: github.event.label.name == 'Accept Bundlesize Increase' runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - - name: Check out PR commit + - name: Check out base commit uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - uses: actions/github-script@v9 with: diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts index dea2a1006607..e7f20432b91a 100644 --- a/packages/core/src/sdk.ts +++ b/packages/core/src/sdk.ts @@ -33,24 +33,6 @@ export function initAndBind( scope.update(options.initialScope); const client = new clientClass(options); - // Temporary CI size-check probe: remove after verifying failure and the approval label. - Object.defineProperty(client, '__sentry_bundle_size_probe__', { - value: - 'A lighthouse keeper records the changing weather beside a rocky northern coastline. ' + - 'Several fishing boats return before sunset, carrying wooden crates and folded canvas sails. ' + - 'Beyond the harbor, a narrow railway crosses green fields toward an abandoned copper mine. ' + - 'An astronomer adjusts a brass telescope while distant clouds reveal a patch of winter stars. ' + - 'Inside the workshop, shelves hold ceramic bowls, leather notebooks, and unusual clockwork instruments. ' + - 'A gardener plants rosemary beneath the kitchen window and collects fallen apples in a wicker basket. ' + - 'Travelers consult a faded map before following the river through limestone caves and pine forests. ' + - 'The morning market offers fresh peaches, woven blankets, painted tiles, and jars of mountain honey. ' + - 'Across the square, musicians rehearse a quiet melody as children draw bright patterns on the pavement. ' + - 'A librarian discovers handwritten letters tucked between the pages of an illustrated botanical atlas. ' + - 'Engineers inspect a suspension bridge using carefully calibrated sensors and detailed maintenance records. ' + - 'After a sudden thunderstorm, sunlight reflects from puddles along the winding cobblestone streets. ' + - 'At the observatory, researchers compare photographs of distant galaxies and catalog unfamiliar constellations. ' + - 'The baker prepares orange pastries while a delivery bicycle rattles past the open courtyard gate.', - }); setCurrentClient(client); client.init(); return client;