Skip to content

Commit ca83c4d

Browse files
committed
ci: Re-add size limit action
1 parent b9b4cb2 commit ca83c4d

File tree

7 files changed

+1148
-81
lines changed

7 files changed

+1148
-81
lines changed

.github/workflows/build.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,36 @@ jobs:
278278
# `job_build` can't see `job_install_deps` and what it returned)
279279
dependency_cache_key: ${{ needs.job_install_deps.outputs.dependency_cache_key }}
280280

281+
job_size_check:
282+
name: Size Check
283+
needs: [job_get_metadata, job_build]
284+
timeout-minutes: 15
285+
runs-on: ubuntu-20.04
286+
if:
287+
github.event_name == 'pull_request' || needs.job_get_metadata.outputs.is_develop == 'true' ||
288+
needs.job_get_metadata.outputs.is_release == 'true'
289+
steps:
290+
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
291+
uses: actions/checkout@v4
292+
with:
293+
ref: ${{ env.HEAD_COMMIT }}
294+
- name: Set up Node
295+
uses: actions/setup-node@v4
296+
with:
297+
# The size limit action runs `yarn` and `yarn build` when this job is executed on
298+
# use Node 14 for now.
299+
node-version: '14'
300+
- name: Restore caches
301+
uses: ./.github/actions/restore-cache
302+
env:
303+
DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }}
304+
- name: Check bundle sizes
305+
uses: ./dev-packages/size-limit-gh-action
306+
with:
307+
github_token: ${{ secrets.GITHUB_TOKEN }}
308+
# Only run comparison against develop if this is a PR
309+
comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}}
310+
281311
job_lint:
282312
name: Lint
283313
# Even though the linter only checks source code, not built code, it needs the built code in order check that all
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
extends: ['../../.eslintrc.js'],
3+
parserOptions: {
4+
sourceType: 'module',
5+
ecmaVersion: 'latest',
6+
},
7+
8+
overrides: [
9+
{
10+
files: ['*.mjs'],
11+
extends: ['@sentry-internal/sdk/src/base'],
12+
},
13+
],
14+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: 'size-limit-gh-action'
2+
description: 'Run size-limit comparison'
3+
inputs:
4+
github_token:
5+
required: true
6+
description: 'a github access token'
7+
comparison_branch:
8+
required: false
9+
default: ""
10+
description: "If set, compare the current branch with this branch"
11+
threshold:
12+
required: false
13+
default: "0.0125"
14+
description: "The percentage threshold for size changes before posting a comment"
15+
runs:
16+
using: 'node20'
17+
main: 'index.mjs'
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
4+
import * as artifact from '@actions/artifact';
5+
import * as core from '@actions/core';
6+
import exec from '@actions/exec';
7+
import { context, getOctokit } from '@actions/github';
8+
import * as glob from '@actions/glob';
9+
import bytes from 'bytes';
10+
import { markdownTable } from 'markdown-table';
11+
12+
import download from 'github-fetch-workflow-artifact';
13+
14+
const SIZE_LIMIT_HEADING = '## size-limit report 📦 ';
15+
const ARTIFACT_NAME = 'size-limit-action';
16+
const RESULTS_FILE = 'size-limit-results.json';
17+
18+
async function fetchPreviousComment(octokit, repo, pr) {
19+
const { data: commentList } = await octokit.rest.issues.listComments({
20+
...repo,
21+
issue_number: pr.number,
22+
});
23+
24+
const sizeLimitComment = commentList.find(comment => comment.body.startsWith(SIZE_LIMIT_HEADING));
25+
return !sizeLimitComment ? null : sizeLimitComment;
26+
}
27+
28+
async function run() {
29+
const { getInput, setFailed } = core;
30+
31+
try {
32+
const { payload, repo } = context;
33+
const pr = payload.pull_request;
34+
35+
const comparisonBranch = getInput('comparison_branch');
36+
const githubToken = getInput('github_token');
37+
const threshold = getInput('threshold');
38+
39+
if (!comparisonBranch && !pr) {
40+
throw new Error('No PR found. Only pull_request workflows are supported.');
41+
}
42+
43+
const octokit = getOctokit(githubToken);
44+
const limit = new SizeLimit();
45+
const artifactClient = artifact.create();
46+
const resultsFilePath = path.resolve(__dirname, RESULTS_FILE);
47+
48+
// If we have no comparison branch, we just run size limit & store the result as artifact
49+
if (!comparisonBranch) {
50+
let base;
51+
const { output: baseOutput } = await execSizeLimit();
52+
53+
try {
54+
base = limit.parseResults(baseOutput);
55+
} catch (error) {
56+
core.error('Error parsing size-limit output. The output should be a json.');
57+
throw error;
58+
}
59+
60+
try {
61+
await fs.writeFile(resultsFilePath, JSON.stringify(base), 'utf8');
62+
} catch (err) {
63+
core.error(err);
64+
}
65+
const globber = await glob.create(resultsFilePath, {
66+
followSymbolicLinks: false,
67+
});
68+
const files = await globber.glob();
69+
70+
await artifactClient.uploadArtifact(ARTIFACT_NAME, files, __dirname);
71+
72+
return;
73+
}
74+
75+
// Else, we run size limit for the current branch, AND fetch it for the comparison branch
76+
let base;
77+
let current;
78+
79+
try {
80+
// Ignore failures here as it is likely that this only happens when introducing size-limit
81+
// and this has not been run on the main branch yet
82+
await download(octokit, {
83+
...repo,
84+
artifactName: ARTIFACT_NAME,
85+
branch: comparisonBranch,
86+
downloadPath: __dirname,
87+
workflowEvent: 'push',
88+
workflowName: `${process.env.GITHUB_WORKFLOW || ''}`,
89+
});
90+
base = JSON.parse(await fs.readFile(resultsFilePath, { encoding: 'utf8' }));
91+
} catch (error) {
92+
core.startGroup('Warning, unable to find base results');
93+
core.debug(error);
94+
core.endGroup();
95+
}
96+
97+
const { status, output } = await execSizeLimit();
98+
try {
99+
current = limit.parseResults(output);
100+
} catch (error) {
101+
core.error('Error parsing size-limit output. The output should be a json.');
102+
throw error;
103+
}
104+
105+
const thresholdNumber = Number(threshold);
106+
107+
// @ts-ignore
108+
const sizeLimitComment = await fetchPreviousComment(octokit, repo, pr);
109+
110+
const shouldComment =
111+
isNaN(thresholdNumber) || limit.hasSizeChanges(base, current, thresholdNumber) || sizeLimitComment;
112+
113+
if (shouldComment) {
114+
const body = [SIZE_LIMIT_HEADING, markdownTable(limit.formatResults(base, current))].join('\r\n');
115+
116+
try {
117+
if (!sizeLimitComment) {
118+
await octokit.rest.issues.createComment({
119+
...repo,
120+
// eslint-disable-next-line camelcase
121+
issue_number: pr.number,
122+
body,
123+
});
124+
} else {
125+
await octokit.rest.issues.updateComment({
126+
...repo,
127+
// eslint-disable-next-line camelcase
128+
comment_id: sizeLimitComment.id,
129+
body,
130+
});
131+
}
132+
} catch (error) {
133+
core.error(
134+
"Error updating comment. This can happen for PR's originating from a fork without write permissions.",
135+
);
136+
}
137+
}
138+
139+
if (status > 0) {
140+
setFailed('Size limit has been exceeded.');
141+
}
142+
} catch (error) {
143+
core.error(error);
144+
setFailed(error.message);
145+
}
146+
}
147+
148+
run();
149+
150+
async function execSizeLimit() {
151+
let output = '';
152+
153+
const status = await exec('yarn', ['size-limit', '--json'], {
154+
ignoreReturnCode: true,
155+
listeners: {
156+
stdout: data => {
157+
output += data.toString();
158+
},
159+
},
160+
});
161+
162+
return { status, output };
163+
}
164+
165+
const SIZE_RESULTS_HEADER = ['Path', 'Size'];
166+
const TIME_RESULTS_HEADER = ['Path', 'Size', 'Loading time (3g)', 'Running time (snapdragon)', 'Total time'];
167+
168+
class SizeLimit {
169+
formatBytes(size) {
170+
return bytes.format(size, { unitSeparator: ' ' });
171+
}
172+
173+
formatTime(seconds) {
174+
if (seconds >= 1) {
175+
return `${Math.ceil(seconds * 10) / 10} s`;
176+
}
177+
178+
return `${Math.ceil(seconds * 1000)} ms`;
179+
}
180+
181+
formatChange(base = 0, current = 0) {
182+
if (base === 0) {
183+
return 'added';
184+
}
185+
186+
if (current === 0) {
187+
return 'removed';
188+
}
189+
190+
const value = ((current - base) / base) * 100;
191+
const formatted = (Math.sign(value) * Math.ceil(Math.abs(value) * 100)) / 100;
192+
193+
if (value > 0) {
194+
return `+${formatted}% 🔺`;
195+
}
196+
197+
if (value === 0) {
198+
return `${formatted}%`;
199+
}
200+
201+
return `${formatted}% 🔽`;
202+
}
203+
204+
formatLine(value, change) {
205+
return `${value} (${change})`;
206+
}
207+
208+
formatSizeResult(name, base, current) {
209+
return [name, this.formatLine(this.formatBytes(current.size), this.formatChange(base.size, current.size))];
210+
}
211+
212+
formatTimeResult(name, base, current) {
213+
return [
214+
name,
215+
this.formatLine(this.formatBytes(current.size), this.formatChange(base.size, current.size)),
216+
this.formatLine(this.formatTime(current.loading), this.formatChange(base.loading, current.loading)),
217+
this.formatLine(this.formatTime(current.running), this.formatChange(base.running, current.running)),
218+
this.formatTime(current.total),
219+
];
220+
}
221+
222+
parseResults(output) {
223+
const results = JSON.parse(output);
224+
225+
return results.reduce((current, result) => {
226+
let time = {};
227+
228+
if (result.loading !== undefined && result.running !== undefined) {
229+
const loading = +result.loading;
230+
const running = +result.running;
231+
232+
time = {
233+
running,
234+
loading,
235+
total: loading + running,
236+
};
237+
}
238+
239+
return {
240+
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
241+
...current,
242+
[result.name]: {
243+
name: result.name,
244+
size: +result.size,
245+
...time,
246+
},
247+
};
248+
}, {});
249+
}
250+
251+
hasSizeChanges(base, current, threshold = 0) {
252+
const names = [...new Set([...(base ? Object.keys(base) : []), ...Object.keys(current)])];
253+
const isSize = names.some(name => current[name] && current[name].total === undefined);
254+
255+
// Always return true if time results are present
256+
if (!isSize) {
257+
return true;
258+
}
259+
260+
return !!names.find(name => {
261+
const baseResult = base?.[name] || EmptyResult;
262+
const currentResult = current[name] || EmptyResult;
263+
264+
if (baseResult.size === 0 && currentResult.size === 0) {
265+
return true;
266+
}
267+
268+
return Math.abs((currentResult.size - baseResult.size) / baseResult.size) * 100 > threshold;
269+
});
270+
}
271+
272+
formatResults(base, current) {
273+
const names = [...new Set([...(base ? Object.keys(base) : []), ...Object.keys(current)])];
274+
const isSize = names.some(name => current[name] && current[name].total === undefined);
275+
const header = isSize ? SIZE_RESULTS_HEADER : TIME_RESULTS_HEADER;
276+
const fields = names.map(name => {
277+
const baseResult = base?.[name] || EmptyResult;
278+
const currentResult = current[name] || EmptyResult;
279+
280+
if (isSize) {
281+
return this.formatSizeResult(name, baseResult, currentResult);
282+
}
283+
return this.formatTimeResult(name, baseResult, currentResult);
284+
});
285+
286+
return [header, ...fields];
287+
}
288+
}
289+
290+
const EmptyResult = {
291+
name: '-',
292+
size: 0,
293+
running: 0,
294+
loading: 0,
295+
total: 0,
296+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@sentry-internal/size-limit-gh-action",
3+
"version": "8.0.0-alpha.2",
4+
"license": "MIT",
5+
"engines": {
6+
"node": ">=14.18"
7+
},
8+
"private": true,
9+
"main": "index.mjs",
10+
"type": "module",
11+
"scripts": {
12+
"lint": "eslint . --format stylish",
13+
"fix": "eslint . --format stylish --fix"
14+
},
15+
"dependencies": {
16+
"@actions/core": "1.10.1",
17+
"@actions/exec": "1.1.1",
18+
"@actions/github": "6.0.0",
19+
"@actions/artifact": "2.1.4",
20+
"@actions/glob": "0.4.0",
21+
"markdown-table": "3.0.3",
22+
"github-fetch-workflow-artifact": "2.0.0",
23+
"bytes": "3.1.2"
24+
},
25+
"volta": {
26+
"extends": "../../package.json"
27+
}
28+
}

0 commit comments

Comments
 (0)