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);

Reply via email to