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 < comment.body.startsWith(SIZE_LIMIT_HEADING)); - return !sizeLimitComment ? null : sizeLimitComment; -} - -async function execSizeLimit() { - let output = ''; - - const status = await exec('yarn run --silent size-limit --json', [], { - windowsVerbatimArguments: false, - ignoreReturnCode: true, - cwd: process.cwd(), - listeners: { - stdout: data => { - output += data.toString(); - }, - }, - }); - - return { status, output }; -} - async function run() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - 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'); - const threshold = getInput('threshold') || 0.05; if (comparisonBranch && !pr) { throw new Error('No PR found. Only pull_request workflows are supported.'); } 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 resultsFilePath = getResultsFilePath(); + const artifactClient = new DefaultArtifactClient(); - // If we have no comparison branch, we just run size limit & store the result as artifact + // 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) { - 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 + // Else, we fetch the results for the comparison branch and compare them with the current branch (likely running on a PR) let base; - let current; let baseIsNotLatest = false; 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, @@ -97,14 +80,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; @@ -116,90 +102,67 @@ async function run() { core.endGroup(); } - const { status, 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 thresholdNumber = Number(threshold); + const increases = base ? limit.getSizeIncreases(base, current, sizeConfig) : []; + const bodyParts = [SIZE_LIMIT_HEADING]; - const sizeLimitComment = await fetchPreviousComment(octokit, repo, pr); + 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.', + ); + } - if (sizeLimitComment) { - core.debug('Found existing size limit comment, updating it instead of creating a new one...'); + 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) { + 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.`; } - const shouldComment = - isNaN(thresholdNumber) || limit.hasSizeChanges(base, current, thresholdNumber) || sizeLimitComment; + bodyParts.push(markdownTable(limit.formatResults(base, current))); + if (baseWorkflowRun) { + bodyParts.push(`[View base workflow run](${baseWorkflowRun.html_url})`); + } - if (shouldComment) { - const bodyParts = [SIZE_LIMIT_HEADING]; + const body = bodyParts.join('\n\n'); + await core.summary.addRaw(body).write(); - 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})`); + try { + 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, + comment_id: sizeLimitComment.id, + body, + }); + } else { + await octokit.rest.issues.createComment({ + ...repo, + issue_number: pr.number, + body, + }); } - 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.", - ); + if (increases.length > 0 && !comments.some(comment => comment.body === failure)) { + await octokit.rest.issues.createComment({ + ...repo, + issue_number: pr.number, + body: failure, + }); } - } else { - core.debug('Skipping comment because there are no changes.'); + } catch { + core.warning('Unable to update PR comments. The size report is available in the job summary.'); } - 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 - } - - setFailed('Size limit has been exceeded.'); + if (failure) { + setFailed(failure); } } catch (error) { core.error(error); @@ -207,68 +170,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 { output: 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); -} - -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, - }); -} +await run(); diff --git a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs index ff1f40c6a716..8e1449f2df7a 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'; @@ -67,19 +60,9 @@ export class SizeLimitFormatter { return `${formatted} šŸ”½`; } - formatLine(value, change) { - return `${value} (${change})`; - } - 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 +72,23 @@ export class SizeLimitFormatter { parseResults(output) { const results = JSON.parse(output); - return results.reduce((current, result) => { - return { + return results.reduce( + (current, result) => ({ ...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]) + .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/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, + }); +}