Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 135 additions & 148 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,159 +27,146 @@ jobs:
- name: Install dependencies
run: npm install

- name: Build Android bundle
run: |
npx expo export --platform android --output-dir ./bundle-android
env:
EXPO_NO_DOTENV: 1

- name: Build iOS bundle
run: |
npx expo export --platform ios --output-dir ./bundle-ios
env:
EXPO_NO_DOTENV: 1

- name: Calculate bundle sizes
id: bundle_sizes
run: |
# Calculate Android bundle size
ANDROID_SIZE=$(du -sb ./bundle-android | cut -f1)
ANDROID_SIZE_MB=$(echo "scale=2; $ANDROID_SIZE / 1048576" | bc)

# Calculate iOS bundle size
IOS_SIZE=$(du -sb ./bundle-ios | cut -f1)
IOS_SIZE_MB=$(echo "scale=2; $IOS_SIZE / 1048576" | bc)

# Total size
TOTAL_SIZE=$((ANDROID_SIZE + IOS_SIZE))
TOTAL_SIZE_MB=$(echo "scale=2; $TOTAL_SIZE / 1048576" | bc)

echo "android_size=$ANDROID_SIZE" >> $GITHUB_OUTPUT
echo "android_size_mb=$ANDROID_SIZE_MB" >> $GITHUB_OUTPUT
echo "ios_size=$IOS_SIZE" >> $GITHUB_OUTPUT
echo "ios_size_mb=$IOS_SIZE_MB" >> $GITHUB_OUTPUT
echo "total_size=$TOTAL_SIZE" >> $GITHUB_OUTPUT
echo "total_size_mb=$TOTAL_SIZE_MB" >> $GITHUB_OUTPUT

echo "### 📦 Bundle Size Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Size (MB) | Size (bytes) |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-----------|--------------|" >> $GITHUB_STEP_SUMMARY
echo "| Android | ${ANDROID_SIZE_MB} | ${ANDROID_SIZE} |" >> $GITHUB_STEP_SUMMARY
echo "| iOS | ${IOS_SIZE_MB} | ${IOS_SIZE} |" >> $GITHUB_STEP_SUMMARY
echo "| **Total** | **${TOTAL_SIZE_MB}** | **${TOTAL_SIZE}** |" >> $GITHUB_STEP_SUMMARY

- name: Download previous bundle size
uses: actions/cache@v4
- name: Build web bundle with stats
run: npx expo export --platform web --output-dir ./dist --stats-output ./dist/stats.json

- name: Upload bundle stats artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
path: ./previous-bundle-size
key: bundle-size-${{ github.sha }}
restore-keys: |
bundle-size-

- name: Compare with previous bundle size
id: compare
run: |
CURRENT_TOTAL=${{ steps.bundle_sizes.outputs.total_size }}

if [ -f "./previous-bundle-size/total_size.txt" ]; then
PREVIOUS_TOTAL=$(cat ./previous-bundle-size/total_size.txt)
DIFF=$((CURRENT_TOTAL - PREVIOUS_TOTAL))
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $PREVIOUS_TOTAL" | bc)

echo "previous_size=$PREVIOUS_TOTAL" >> $GITHUB_OUTPUT
echo "diff=$DIFF" >> $GITHUB_OUTPUT
echo "diff_percent=$DIFF_PERCENT" >> $GITHUB_OUTPUT

echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📊 Comparison with Previous Build" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Previous Total**: $(echo "scale=2; $PREVIOUS_TOTAL / 1048576" | bc) MB" >> $GITHUB_STEP_SUMMARY
echo "- **Difference**: $(echo "scale=2; $DIFF / 1048576" | bc) MB ($DIFF_PERCENT%)" >> $GITHUB_STEP_SUMMARY

# Alert if size increased by more than 10%
if (( $(echo "$DIFF_PERCENT > 10" | bc -l) )); then
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **WARNING**: Bundle size increased by more than 10%!" >> $GITHUB_STEP_SUMMARY
echo "::warning::Bundle size increased by ${DIFF_PERCENT}% (from $(echo "scale=2; $PREVIOUS_TOTAL / 1048576" | bc) MB to $(echo "scale=2; $CURRENT_TOTAL / 1048576" | bc) MB)"
fi

# Alert if size increased by more than 5MB
DIFF_MB=$(echo "scale=2; $DIFF / 1048576" | bc)
if (( $(echo "$DIFF_MB > 5" | bc -l) )); then
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **WARNING**: Bundle size increased by more than 5 MB!" >> $GITHUB_STEP_SUMMARY
echo "::warning::Bundle size increased by ${DIFF_MB} MB"
fi
else
echo "No previous bundle size found. This is the first measurement." >> $GITHUB_STEP_SUMMARY
fi

- name: Save current bundle size
run: |
mkdir -p ./previous-bundle-size
echo "${{ steps.bundle_sizes.outputs.total_size }}" > ./previous-bundle-size/total_size.txt
echo "${{ steps.bundle_sizes.outputs.android_size }}" > ./previous-bundle-size/android_size.txt
echo "${{ steps.bundle_sizes.outputs.ios_size }}" > ./previous-bundle-size/ios_size.txt
echo "${{ github.sha }}" > ./previous-bundle-size/sha.txt

- name: Check bundle size limits
run: |
TOTAL_SIZE_MB=${{ steps.bundle_sizes.outputs.total_size_mb }}

# Alert if total bundle size exceeds 50MB
if (( $(echo "$TOTAL_SIZE_MB > 50" | bc -l) )); then
echo "::warning::Total bundle size (${TOTAL_SIZE_MB} MB) exceeds 50 MB limit"
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **WARNING**: Total bundle size (${TOTAL_SIZE_MB} MB) exceeds 50 MB limit!" >> $GITHUB_STEP_SUMMARY
fi

# Alert if total bundle size exceeds 100MB (critical)
if (( $(echo "$TOTAL_SIZE_MB > 100" | bc -l) )); then
echo "::error::Total bundle size (${TOTAL_SIZE_MB} MB) exceeds 100 MB critical limit!"
echo "" >> $GITHUB_STEP_SUMMARY
echo "🚨 **CRITICAL**: Total bundle size (${TOTAL_SIZE_MB} MB) exceeds 100 MB critical limit!" >> $GITHUB_STEP_SUMMARY
exit 1
fi

- name: Comment PR with bundle size
name: bundle-stats-${{ github.sha }}
path: ./dist/stats.json
retention-days: 7

- name: Store bundle size history
if: github.ref == 'refs/heads/main'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

const owner = context.repo.owner;
const repo = context.repo.repo;
const historyFile = 'bundle-size-history.json';

let history = [];
try {
const { data: artifact } = await octokit.actions.listArtifactsForRepo({
owner,
repo,
name: 'bundle-size-history',
}).then(res => res.data.artifacts[0]);

if (artifact) {
const download = await octokit.actions.downloadArtifact({
owner,
repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
const AdmZip = require('adm-zip');
const zip = new AdmZip(Buffer.from(download.data));
history = JSON.parse(zip.readAsText(historyFile));
}
} catch (error) {
console.log('No existing history artifact found, creating a new one.');
}

const stats = JSON.parse(fs.readFileSync('./dist/stats.json', 'utf8'));
const totalSize = stats.assets.reduce((sum, a) => sum + a.size, 0);

history.push({
sha: context.sha,
date: new Date().toISOString(),
totalSize,
assets: stats.assets.map(a => ({ name: a.name, size: a.size })),
});

fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));

- name: Upload bundle size history
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: bundle-size-history
path: bundle-size-history.json
retention-days: 90

- name: Download base branch bundle size
if: github.event_name == 'pull_request'
uses: actions/download-artifact@v4
with:
name: bundle-stats-${{ github.event.pull_request.base.sha }}
path: ./base-bundle
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.pull_request.base.repo.id }}-${{ github.event.pull_request.base.sha }}


- name: Compare bundle sizes and comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const androidSize = '${{ steps.bundle_sizes.outputs.android_size_mb }}';
const iosSize = '${{ steps.bundle_sizes.outputs.ios_size_mb }}';
const totalSize = '${{ steps.bundle_sizes.outputs.total_size_mb }}';
const previousSize = '${{ steps.compare.outputs.previous_size }}';
const diff = '${{ steps.compare.outputs.diff }}';
const diffPercent = '${{ steps.compare.outputs.diff_percent }}';

const fs = require('fs');
const path = require('path');

function parseStats(filePath) {
if (!fs.existsSync(filePath)) return null;
const stats = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const mainBundle = stats.assets.find(a => a.name === 'main.js');
return {
totalSize: stats.assets.reduce((sum, a) => sum + a.size, 0),
mainBundleSize: mainBundle ? mainBundle.size : 0,
assets: stats.assets,
};
}

const baseStats = parseStats('./base-bundle/stats.json');
const headStats = parseStats('./dist/stats.json');

if (!headStats) {
console.log('Could not find head bundle stats. Skipping comparison.');
return;
}

let body = `## 📦 Bundle Size Report\n\n`;
body += `| Platform | Size (MB) |\n`;
body += `|----------|-----------|\n`;
body += `| Android | ${androidSize} |\n`;
body += `| iOS | ${iosSize} |\n`;
body += `| **Total** | **${totalSize}** |\n`;

if (previousSize && diff) {
const diffMB = (parseFloat(diff) / 1048576).toFixed(2);
const diffPercentVal = parseFloat(diffPercent).toFixed(2);
const emoji = diff > 0 ? '📈' : '📉';

body += `\n### ${emoji} Comparison with Base Branch\n\n`;
body += `- **Change**: ${diffMB} MB (${diffPercentVal}%)\n`;

if (parseFloat(diffPercent) > 10) {
body += `\n⚠️ **Warning**: Bundle size increased by more than 10%!\n`;
body += `| Asset | Size (KB) |\n`;
body += `|---|---|\n`;
headStats.assets.forEach(asset => {
body += `| ${asset.name} | ${(asset.size / 1024).toFixed(2)} |\n`;
});
body += `| **Total** | **${(headStats.totalSize / 1024).toFixed(2)}** |\n`;

if (baseStats) {
const totalDiff = headStats.totalSize - baseStats.totalSize;
const mainDiff = headStats.mainBundleSize - baseStats.mainBundleSize;
const totalDiffPercent = (totalDiff / baseStats.totalSize * 100).toFixed(2);
const mainDiffPercent = (mainDiff / baseStats.mainBundleSize * 100).toFixed(2);

const emoji = totalDiff > 0 ? '📈' : '📉';

body += `\n### ${emoji} Comparison with base branch\n\n`;
body += `| Asset | Base (KB) | Head (KB) | Diff (KB) | Diff (%) |\n`;
body += `|---|---|---|---|---|\n`;
body += `| main.js | ${(baseStats.mainBundleSize / 1024).toFixed(2)} | ${(headStats.mainBundleSize / 1024).toFixed(2)} | ${(mainDiff / 1024).toFixed(2)} | ${mainDiffPercent}% |\n`;
body += `| **Total** | **${(baseStats.totalSize / 1024).toFixed(2)}** | **${(headStats.totalSize / 1024).toFixed(2)}** | **${(totalDiff / 1024).toFixed(2)}** | **${totalDiffPercent}%** |\n`;

if (Math.abs(mainDiff) > 50 * 1024) {
core.setFailed(`Main bundle size changed by more than 50KB.`);
body += `\n\n**Error:** Main bundle size changed by more than 50KB.`;
}
if (Math.abs(totalDiff) > 100 * 1024) {
core.setFailed(`Total bundle size changed by more than 100KB.`);
body += `\n\n**Error:** Total bundle size changed by more than 100KB.`;
}
}
try {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
} catch (error) {
console.warn('Skipping PR commenting due to permission limits (e.g. fork PRs):', error.message);
}

await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ jobs:
${{ runner.os }}-eslint-

- name: Lint
run: npm run lint -- --max-warnings=250
run: npm run lint -- --cache --cache-location .eslintcache

- name: Format check
Expand Down
10 changes: 5 additions & 5 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ module.exports = defineConfig([
'import/no-unresolved': 'off',

// Prevent inline component definitions that defeat memoization
'react/no-unstable-nested-components': ['warn', { allowAsProps: false }],
'react/no-unstable-nested-components': ['error', { allowAsProps: false }],

'jsx-a11y/alt-text': 'warn',
'jsx-a11y/aria-props': 'warn',
'jsx-a11y/aria-proptypes': 'warn',
'jsx-a11y/aria-unsupported-elements': 'warn',
'jsx-a11y/alt-text': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-proptypes': 'error',
'jsx-a11y/aria-unsupported-elements': 'error',
},
},
]);