This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch ci/frontend-bundle-size in repository https://gitbox.apache.org/repos/asf/superset.git
commit 987c5ada541d6bab9fc04f635f4da9950bf3f0c0 Author: Evan Rusackas <[email protected]> AuthorDate: Tue Jul 28 00:05:49 2026 -0700 ci(frontend): track bundle size over time with benchmark-action Superset's frontend bundle size is a recurring complaint, but until now there's been no ongoing visibility into it -- only periodic manual cleanup efforts. This adds a `bundle-size` job to superset-frontend.yml that builds the real production bundle (npm run build), reduces its per-entrypoint sizes to a few headline numbers, and tracks them over time via benchmark-action/github-action-benchmark, posting a PR comment when a change regresses past 110% of the last recorded baseline. Reuses the already-built CI Docker image (same image sharded-jest-tests / lint-frontend / etc. already download), so the only new cost is the production webpack build itself -- and that's cheap after the first run: webpack's persistent filesystem cache (already configured in webpack.config.js) makes warm rebuilds ~20s locally vs several minutes cold, confirmed by hand against this repo's real build. webpack.config.js gains a BUNDLE_SIZE_STATS env-gated stats override (mirrors the existing BUNDLE_ANALYZER pattern). The default `stats: 'minimal'` omits per-asset sizes entirely; `--stats=normal` includes them but also serializes the full ~15k-module dependency graph, producing a 560+MB stats.json for this app -- large enough to exceed Node's max string length on a plain fs.readFileSync. The env-gated override requests just `{ assets: true, entrypoints: true }`, verified end-to-end against a minimal synthetic webpack project (same webpack/webpack-cli versions) and against real stats pulled from this repo's actual production build. History storage deliberately avoids the gh-pages branch (benchmark-action's usual default) since that branch is the live Helm chart index published by superset-helm-release.yml, not free real estate. Instead uses external-data-json-path with actions/cache: restored on every run (PR or push) so PRs get a same-baseline comparison and regression comment, but only saved back to the cache on push to master, so an unmerged PR's numbers never become the shared baseline. No gh-pages branch is touched in any code path, and no PAT/GitHub App is needed -- comment-on-alert only needs the default per-job GITHUB_TOKEN. --- .github/workflows/superset-frontend.yml | 72 ++++++++++++++++++++++ superset-frontend/scripts/bundle-size-summary.js | 78 ++++++++++++++++++++++++ superset-frontend/webpack.config.js | 10 +++ 3 files changed, 160 insertions(+) diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml index 46cb237c4d8..e828cc27276 100644 --- a/.github/workflows/superset-frontend.yml +++ b/.github/workflows/superset-frontend.yml @@ -195,3 +195,75 @@ jobs: run: | docker run --rm $TAG bash -c \ "npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci" + + bundle-size: + needs: frontend-build + if: needs.frontend-build.outputs.should-run == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download Docker Image Artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: docker-image + + - name: Load Docker Image + run: | + zstd -d < docker-image.tar.zst | docker load + + # Only ever pull the last recorded data point off the cache, keyed by + # run ID -- `restore-keys` prefix-matches the most recently created + # entry. Absent on the very first run ever; benchmark-action starts a + # fresh history in that case. + - name: Restore bundle size history + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: bundle-size-history.json + key: bundle-size-history-${{ github.run_id }} + restore-keys: | + bundle-size-history- + + - name: Build production bundle with stats + run: | + mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats + docker run \ + -v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \ + --rm $TAG \ + bash -c \ + "npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json" + + - name: Summarize bundle size + run: | + node superset-frontend/scripts/bundle-size-summary.js \ + superset-frontend/bundle-stats/stats.json > bundle-size-summary.json + rm -rf superset-frontend/bundle-stats + + - name: Track bundle size + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + with: + tool: customSmallerIsBetter + output-file-path: bundle-size-summary.json + external-data-json-path: bundle-size-history.json + github-token: ${{ secrets.GITHUB_TOKEN }} + comment-on-alert: true + alert-threshold: "110%" + fail-on-alert: false + summary-always: true + + # Only the trunk's own bundle size becomes the baseline everyone else + # is compared against -- an unmerged PR's numbers (including ones that + # regress on purpose to test something) never get persisted. + - name: Save bundle size history + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: bundle-size-history.json + key: bundle-size-history-${{ github.run_id }} diff --git a/superset-frontend/scripts/bundle-size-summary.js b/superset-frontend/scripts/bundle-size-summary.js new file mode 100644 index 00000000000..26561b1e1b2 --- /dev/null +++ b/superset-frontend/scripts/bundle-size-summary.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +// Reduces a webpack `--json` stats file down to the handful of headline +// numbers worth tracking over time, in the flat array format +// benchmark-action/github-action-benchmark expects for its +// "customSmallerIsBetter" tool. The full stats file also includes a +// `modules`/`chunks` graph across ~15k modules, which is enormous and not +// useful for this purpose, so we only ever read `entrypoints`. +// +// Usage: node scripts/bundle-size-summary.js <path-to-stats.json> + +const fs = require('fs'); + +// Entrypoints worth tracking: the two user-facing app shells. `menu`, +// `preamble`, `theme`, and `service-worker` are small, low-variance +// infrastructure chunks, not where bundle bloat actually shows up. +const TRACKED_ENTRYPOINTS = ['spa', 'embedded']; + +function entrypointSizeByExt(entrypoint, ext) { + return (entrypoint.assets || []) + .filter(asset => asset.name.endsWith(ext)) + .reduce((total, asset) => total + asset.size, 0); +} + +function main() { + const statsPath = process.argv[2]; + if (!statsPath) { + console.error('Usage: bundle-size-summary.js <path-to-stats.json>'); + process.exit(1); + } + + const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + const { entrypoints } = stats; + if (!entrypoints) { + console.error( + 'stats.json has no `entrypoints` key -- was it generated with ' + + '`--stats=normal` (or richer)? `minimal`/`errors-only` stats omit it.', + ); + process.exit(1); + } + + const results = []; + TRACKED_ENTRYPOINTS.forEach(name => { + const entrypoint = entrypoints[name]; + if (!entrypoint) return; + results.push({ + name: `${name} entrypoint (JS)`, + unit: 'bytes', + value: entrypointSizeByExt(entrypoint, '.js'), + }); + results.push({ + name: `${name} entrypoint (CSS)`, + unit: 'bytes', + value: entrypointSizeByExt(entrypoint, '.css'), + }); + }); + + console.log(JSON.stringify(results, null, 2)); +} + +main(); diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index e9043ab2004..169510ce468 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -738,4 +738,14 @@ const smp = new SpeedMeasurePlugin({ disable: !measure, }); +// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'` +// above omits both). Not `normal`/`detailed` stats: those also serialize the +// full ~15k-module dependency graph, which is hundreds of MB for this app -- +// large enough to exceed Node's max string length when read back with +// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI. +// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json +if (process.env.BUNDLE_SIZE_STATS) { + config.stats = { all: false, assets: true, entrypoints: true }; +} + module.exports = smp.wrap(config);
