Skip to content

Commit 124dfeb

Browse files
chargomeclaude
andauthored
chore(ci): Automatically bump size limit every week (#20531)
Adds a github workflow for automatically bumping `.size-limit.js` thresholds (setting the new limit to currentSize + 5 KB), opening a PR against develop. Triggered both manually or cron every friday. If the workflow fails we just open an issue. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd4766c commit 124dfeb

5 files changed

Lines changed: 620 additions & 1 deletion

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: 'Auto-bump size-limit thresholds'
2+
3+
on:
4+
schedule:
5+
- cron: '0 9 * * 5' # Friday 09:00 UTC
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
issues: write
12+
13+
concurrency:
14+
group: bump-size-limits
15+
cancel-in-progress: false
16+
17+
jobs:
18+
bump:
19+
name: Bump size-limit thresholds
20+
runs-on: ubuntu-24.04
21+
timeout-minutes: 25
22+
steps:
23+
- name: Generate GitHub App token
24+
id: app-token
25+
uses: actions/create-github-app-token@v2
26+
with:
27+
app-id: ${{ vars.GITFLOW_APP_ID }}
28+
private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }}
29+
30+
- name: Checkout develop
31+
uses: actions/checkout@v6
32+
with:
33+
ref: develop
34+
token: ${{ steps.app-token.outputs.token }}
35+
36+
- name: Set up Node
37+
uses: actions/setup-node@v6
38+
with:
39+
node-version-file: 'package.json'
40+
41+
- name: Install dependencies
42+
uses: ./.github/actions/install-dependencies
43+
44+
- name: Build packages
45+
run: yarn build
46+
47+
- name: Run bumper
48+
# Capture stdout AND exit code without failing the step on exit-2 (no-op).
49+
# The script writes .size-limit.js in place; create-pull-request handles
50+
# commit/branch/PR — if there's no diff, it skips opening a PR.
51+
run: |
52+
set +e
53+
node scripts/bump-size-limits.mjs > /tmp/bump-summary.md
54+
code=$?
55+
set -e
56+
if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then
57+
echo "::error::bump script failed with exit code $code"
58+
cat /tmp/bump-summary.md || true
59+
exit "$code"
60+
fi
61+
cat /tmp/bump-summary.md
62+
63+
- name: Create or update PR
64+
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0
65+
with:
66+
token: ${{ steps.app-token.outputs.token }}
67+
commit-message: 'chore(size-limit): auto-bump weekly drift'
68+
title: 'chore(size-limit): weekly auto-bump'
69+
body-path: /tmp/bump-summary.md
70+
branch: bot/bump-size-limits
71+
base: develop
72+
labels: 'Dev: CI'
73+
add-paths: '.size-limit.js'
74+
delete-branch: true
75+
76+
- name: Open or comment on failure issue
77+
if: failure()
78+
env:
79+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
80+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
81+
run: |
82+
title='Weekly size-limit auto-bump failure'
83+
existing=$(gh issue list --search "in:title \"$title\"" --state open --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)
84+
if [ -n "$existing" ]; then
85+
gh issue comment "$existing" --body "Auto-bump workflow failed again: $RUN_URL"
86+
else
87+
body=$(cat <<EOF
88+
The weekly size-limit auto-bump workflow failed.
89+
90+
Run: $RUN_URL
91+
92+
This issue will be commented on for repeat failures.
93+
EOF
94+
)
95+
gh issue create \
96+
--title "$title" \
97+
--body "$body" \
98+
--label "Dev: CI"
99+
fi

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"dedupe-deps:fix": "yarn-deduplicate yarn.lock",
3636
"postpublish": "nx run-many -t postpublish --parallel=1",
3737
"test": "nx run-many -t test --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"",
38-
"test:scripts": "vitest run scripts/bump-version.test.ts",
38+
"test:scripts": "vitest run scripts/*.test.ts",
3939
"test:unit": "nx run-many -t test:unit --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"",
4040
"test:update-snapshots": "nx run-many -t test:update-snapshots",
4141
"test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
module.exports = [
2+
{
3+
name: '@sentry/browser',
4+
path: 'packages/browser/build/npm/esm/prod/index.js',
5+
gzip: true,
6+
limit: '27 KB',
7+
},
8+
{
9+
name: '@sentry/browser - with treeshaking flags',
10+
path: 'packages/browser/build/npm/esm/prod/index.js',
11+
gzip: true,
12+
limit: '25 KB',
13+
},
14+
{
15+
name: 'CDN Bundle (incl. Tracing)',
16+
path: 'packages/browser/build/bundles/bundle.tracing.min.js',
17+
gzip: true,
18+
limit: '46.5 KB',
19+
},
20+
{
21+
name: '@sentry/cloudflare (withSentry)',
22+
path: 'packages/cloudflare/build/esm/index.js',
23+
gzip: false,
24+
brotli: false,
25+
limit: '420 KiB',
26+
},
27+
];

scripts/bump-size-limits.mjs

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* Auto-bumper for .size-limit.js.
3+
*
4+
* - Reads `yarn size-limit --json` output
5+
* - For each entry, computes a new limit of roundUpToKB(currentSize + 5000)
6+
* and applies it whenever the displayed value would change
7+
* - Rewrites .size-limit.js as plain text (NEVER require()d — the file contains
8+
* user-defined webpack/esbuild config functions that we don't want executing)
9+
*
10+
* Exit codes: 0 = wrote changes, 2 = no-op, 1 = error.
11+
*/
12+
13+
import { execFile } from 'node:child_process';
14+
import { readFile, rename, writeFile } from 'node:fs/promises';
15+
import path from 'node:path';
16+
import { fileURLToPath } from 'node:url';
17+
import { promisify } from 'node:util';
18+
19+
const execFileAsync = promisify(execFile);
20+
21+
const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..');
22+
const SIZE_LIMIT_FILE = path.join(REPO_ROOT, '.size-limit.js');
23+
24+
export const HEADROOM_BYTES = 5000;
25+
export const BYTES_PER_KB = 1000;
26+
export const BYTES_PER_KIB = 1024;
27+
28+
/**
29+
* Compute the new size-limit in bytes for an entry: currentSize + 5KB,
30+
* rounded up to the next full KB. Always returns a number — the no-op
31+
* check is done downstream by comparing the displayed (KB/KiB-rounded)
32+
* value against the existing one.
33+
*
34+
* @param {number} currentBytes - measured size in bytes
35+
* @returns {number} new limit in bytes, rounded up to the next KB
36+
*/
37+
export function computeNewLimit(currentBytes) {
38+
const target = currentBytes + HEADROOM_BYTES;
39+
return Math.ceil(target / BYTES_PER_KB) * BYTES_PER_KB;
40+
}
41+
42+
/**
43+
* Parse and strict-validate the JSON output from `yarn size-limit --json`.
44+
*
45+
* @param {string} raw - JSON string
46+
* @returns {Array<{ name: string, size: number, sizeLimit: number }>}
47+
* @throws {TypeError | SyntaxError} on malformed input
48+
*/
49+
export function parseSizeLimitOutput(raw) {
50+
const data = JSON.parse(raw);
51+
if (!Array.isArray(data)) {
52+
throw new TypeError(`size-limit output: expected array, got ${typeof data}`);
53+
}
54+
return data.map((entry, i) => {
55+
if (!entry || typeof entry !== 'object') {
56+
throw new TypeError(`size-limit entry [${i}]: expected object`);
57+
}
58+
if (typeof entry.name !== 'string' || entry.name.length === 0) {
59+
throw new TypeError(`size-limit entry [${i}]: 'name' must be a non-empty string`);
60+
}
61+
if (typeof entry.size !== 'number' || !Number.isFinite(entry.size)) {
62+
throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'size' must be a finite number`);
63+
}
64+
if (typeof entry.sizeLimit !== 'number' || !Number.isFinite(entry.sizeLimit)) {
65+
throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'sizeLimit' must be a finite number`);
66+
}
67+
return { name: entry.name, size: entry.size, sizeLimit: entry.sizeLimit };
68+
});
69+
}
70+
71+
/**
72+
* Escape a string for safe inclusion in a markdown table cell.
73+
* Replaces newlines with spaces, escapes pipes and backticks.
74+
*
75+
* @param {unknown} value
76+
* @returns {string}
77+
*/
78+
export function sanitizeMarkdownCell(value) {
79+
return String(value)
80+
.replace(/\r\n|\r|\n/g, ' ')
81+
.replace(/[|`]/g, m => `\\${m}`);
82+
}
83+
84+
/**
85+
* Escape a string for literal use inside a RegExp.
86+
*/
87+
function reEscape(s) {
88+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89+
}
90+
91+
/**
92+
* Inspect the source for the current limit string of a given entry.
93+
* Returns null if no entry with that name is found.
94+
*
95+
* @param {string} src
96+
* @param {string} name
97+
* @returns {{ value: number, unit: 'KB' | 'KiB', raw: string } | null}
98+
*/
99+
export function extractCurrentLimit(src, name) {
100+
const namePattern = `name:\\s*'${reEscape(name)}'`;
101+
const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`;
102+
const re = new RegExp(`${namePattern}[^]*?${limitPattern}`);
103+
const m = re.exec(src);
104+
if (!m) return null;
105+
return { value: Number(m[1]), unit: /** @type {'KB' | 'KiB'} */ (m[2]), raw: `${m[1]} ${m[2]}` };
106+
}
107+
108+
/**
109+
* Convert a numeric byte value into a whole-unit display value matching the
110+
* entry's existing unit. KB uses 1000, KiB uses 1024.
111+
*
112+
* @param {number} newBytes
113+
* @param {'KB' | 'KiB'} unit
114+
* @returns {number}
115+
*/
116+
function bytesToDisplay(newBytes, unit) {
117+
const divisor = unit === 'KiB' ? BYTES_PER_KIB : BYTES_PER_KB;
118+
return Math.ceil(newBytes / divisor);
119+
}
120+
121+
/**
122+
* Rewrite `.size-limit.js` source to apply a list of limit updates.
123+
* Operates on plain text — never executes the source. For each change,
124+
* locates the entry by exact `name:` match and rewrites the next `limit:`
125+
* line in that window.
126+
*
127+
* @param {string} src - contents of .size-limit.js
128+
* @param {Array<{ name: string, newLimitKb: number, unit: 'KB' | 'KiB' }>} changes
129+
* @returns {string} updated source
130+
* @throws {Error} if any change's name doesn't match exactly one entry
131+
*/
132+
export function rewriteSizeLimitFile(src, changes) {
133+
let out = src;
134+
for (const { name, newLimitKb, unit } of changes) {
135+
const namePattern = `name:\\s*'${reEscape(name)}'`;
136+
const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`;
137+
const re = new RegExp(`(${namePattern}[^]*?)${limitPattern}`);
138+
139+
let matchCount = 0;
140+
const replaced = out.replace(re, (_full, prefix) => {
141+
matchCount++;
142+
return `${prefix}limit: '${newLimitKb} ${unit}'`;
143+
});
144+
145+
if (matchCount === 0) {
146+
throw new Error(`rewriteSizeLimitFile: no entry matched for name='${name}'`);
147+
}
148+
out = replaced;
149+
}
150+
return out;
151+
}
152+
153+
/**
154+
* Render a markdown summary of size-limit changes for the PR body.
155+
*
156+
* @param {Array<{ name: string, oldLimit: string, newLimit: string, delta: number, unit: 'KB' | 'KiB' }>} changes
157+
* @returns {string}
158+
*/
159+
export function renderSummary(changes) {
160+
const header = '## Size limit auto-bump\n';
161+
if (changes.length === 0) {
162+
return `${header}\nAll size limits already provide ≥5 KB headroom. No changes needed.\n`;
163+
}
164+
const lines = [header, '| Entry | Old limit | New limit | Δ |', '| --- | --- | --- | --- |'];
165+
for (const c of changes) {
166+
const sign = c.delta >= 0 ? '+' : '';
167+
const delta = `${sign}${c.delta} ${c.unit}`;
168+
lines.push(`| ${sanitizeMarkdownCell(c.name)} | ${c.oldLimit} | ${c.newLimit} | ${delta} |`);
169+
}
170+
return `${lines.join('\n')}\n`;
171+
}
172+
173+
// CLI entrypoint
174+
async function main() {
175+
// 1. Run size-limit. Capture JSON. execFile (no shell).
176+
let raw;
177+
try {
178+
// `--silent` suppresses yarn's `yarn run v…` header and `Done in …` footer,
179+
// which would otherwise break JSON.parse on the captured stdout.
180+
const { stdout } = await execFileAsync('yarn', ['--silent', 'size-limit', '--json'], {
181+
cwd: REPO_ROOT,
182+
maxBuffer: 16 * 1024 * 1024,
183+
});
184+
raw = stdout;
185+
} catch (err) {
186+
// size-limit exits non-zero when entries fail their existing limit. We still want the JSON.
187+
if (err && typeof err === 'object' && 'stdout' in err && err.stdout) {
188+
raw = /** @type {string} */ (err.stdout);
189+
} else {
190+
throw err;
191+
}
192+
}
193+
194+
const measurements = parseSizeLimitOutput(raw);
195+
196+
// 2. Read .size-limit.js as text. NEVER require() it.
197+
const src = await readFile(SIZE_LIMIT_FILE, 'utf8');
198+
199+
// 3. Compute changes.
200+
const changes = [];
201+
const summaryRows = [];
202+
for (const m of measurements) {
203+
const newBytes = computeNewLimit(m.size);
204+
205+
const cur = extractCurrentLimit(src, m.name);
206+
if (!cur) {
207+
throw new Error(`size-limit reported entry '${m.name}' but it was not found in .size-limit.js`);
208+
}
209+
210+
const displayValue = bytesToDisplay(newBytes, cur.unit);
211+
const newLimitStr = `${displayValue} ${cur.unit}`;
212+
213+
if (newLimitStr === cur.raw) {
214+
// After unit conversion the displayed value didn't move. Skip — avoids
215+
// no-op edits caused by KiB rounding.
216+
continue;
217+
}
218+
219+
changes.push({ name: m.name, newLimitKb: displayValue, unit: cur.unit });
220+
summaryRows.push({
221+
name: m.name,
222+
oldLimit: cur.raw,
223+
newLimit: newLimitStr,
224+
delta: displayValue - cur.value,
225+
unit: cur.unit,
226+
});
227+
}
228+
229+
// 4. Print summary regardless (workflow captures stdout).
230+
process.stdout.write(renderSummary(summaryRows));
231+
232+
if (changes.length === 0) {
233+
process.exit(2);
234+
}
235+
236+
// 5. Atomic write: temp file + rename.
237+
const updated = rewriteSizeLimitFile(src, changes);
238+
const tmpPath = `${SIZE_LIMIT_FILE}.tmp`;
239+
await writeFile(tmpPath, updated, 'utf8');
240+
await rename(tmpPath, SIZE_LIMIT_FILE);
241+
242+
process.exit(0);
243+
}
244+
245+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
246+
if (isMain) {
247+
main().catch(err => {
248+
// oxlint-disable-next-line no-console
249+
console.error(err.stack || err.message || err);
250+
process.exit(1);
251+
});
252+
}

0 commit comments

Comments
 (0)