diff --git a/.github/workflows/frontend-bundle-size-nightly.yml b/.github/workflows/frontend-bundle-size-nightly.yml new file mode 100644 index 00000000000..e56e1c5d3a8 --- /dev/null +++ b/.github/workflows/frontend-bundle-size-nightly.yml @@ -0,0 +1,135 @@ +name: Frontend bundle size (nightly baseline + analyzer) + +# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size` +# job compares PRs against, and publishes a browsable bundle-analyzer treemap +# report of the same build. Deliberately NOT triggered on every push to +# master: a day-old baseline/report is fine for catching relative +# regressions on PRs and for browsing what's actually in the bundle, and +# building the production bundle on every one of the many pushes master +# gets per day would burn CI time for no benefit a nightly refresh doesn't +# already cover. +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +env: + TAG: apache/superset:bundle-size-nightly-${{ github.run_id }} + +permissions: + contents: read + +jobs: + refresh-baseline: + runs-on: ubuntu-26.04 + timeout-minutes: 30 + env: + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }} + steps: + - name: "Checkout master" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: master + + - name: Build Docker Image + run: | + docker buildx build \ + -t $TAG \ + --cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \ + --target superset-node-ci \ + . + + # Same cache the PR-time bundle-size job restores/writes -- webpack's + # persistent filesystem cache turns a warm production build into ~20s + # instead of several minutes. See superset-frontend.yml for the + # matching restore step and why it's keyed this way. + - name: Restore webpack build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: superset-frontend/.temp_cache + key: >- + webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json', + 'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json', + 'superset-frontend/webpack.config.js') }} + + # 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- + + # BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS -- + # they're independent env-gated additions in webpack.config.js (one + # sets `config.stats`, the other pushes plugins), so one production + # build produces both the numeric stats.json and the analyzer's + # report.html. Only report.html is mounted out, not + # BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is + # documented in webpack.config.js as routinely exceeding 100MB for + # this app (it's .gitignore'd for exactly that reason), too large to + # publish as a static site page. + - name: Build production bundle with stats and analyzer report + run: | + mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats + mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache + mkdir -p ${{ github.workspace }}/superset/static/assets + docker run \ + -v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \ + -v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \ + -v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \ + --rm $TAG \ + bash -c \ + "npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=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 + + # No PR to comment on here, so comment-on-alert is off -- the job + # summary (summary-always) is the only surface for this run. + - name: Update bundle size baseline + 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 + fail-on-alert: false + summary-always: true + + - name: Save bundle size history + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: bundle-size-history.json + key: bundle-size-history-${{ github.run_id }} + + # Publishes the treemap to Netlify (the same host already used for + # superset-storybook.netlify.app and docs previews, reusing the + # existing NETLIFY_AUTH_TOKEN). Skipped until + # NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free) + # Netlify site named superset-bundle-analyzer and add its site ID as + # that secret to turn this on; nothing else in this workflow depends + # on it. + - name: Publish bundle analyzer report to Netlify + if: ${{ env.NETLIFY_SITE_ID != '' }} + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + run: | + mkdir -p netlify-publish + cp superset/static/assets/report.html netlify-publish/index.html + # zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy + # tool, not an application dependency; a global/npx install has no + # lockfile context. Version pinned above the floor set by other + # ad-hoc installs in this repo (bump deliberately when upgrading). + npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml index f32b0be1ee1..0c0031fe382 100644 --- a/.github/workflows/superset-frontend.yml +++ b/.github/workflows/superset-frontend.yml @@ -212,3 +212,100 @@ jobs: - uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0 with: expand-composite-actions: true + + # Compares a PR's own bundle size against the last nightly-recorded + # baseline (see frontend-bundle-size-nightly.yml, which owns actually + # persisting new baselines). PR-only: a push to master doesn't need this + # check re-run against itself, and re-persisting the baseline on every + # push to master -- which happens many times a day -- would burn a full + # production build for no benefit nightly refresh doesn't already cover. + bundle-size: + needs: frontend-build + if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request' + runs-on: ubuntu-26.04 + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - 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 + + # webpack's persistent filesystem cache (superset-frontend/webpack.config.js) + # turns a warm production build into ~20s instead of several minutes, + # but GH-hosted runners are fresh VMs with nothing carried over between + # jobs -- without restoring it explicitly, every single PR would pay + # the full cold-build cost. Keyed on the same files webpack's own + # `buildDependencies` invalidates on, so a stale cache is never used. + - name: Restore webpack build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: superset-frontend/.temp_cache + key: >- + webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json', + 'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json', + 'superset-frontend/webpack.config.js') }} + + # Only ever pull the last recorded data point off the cache, keyed by + # run ID -- `restore-keys` prefix-matches the most recently created + # entry, which is always the latest nightly run. Absent before the + # first nightly run ever happens; 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 + mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache + docker run \ + -v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \ + -v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \ + --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 + + # Comparison + alert only -- this job never persists. See + # frontend-bundle-size-nightly.yml for why. + # + # comment-on-alert is gated to same-repo PRs: on a fork PR, + # GITHUB_TOKEN is forced read-only regardless of the `permissions` + # block above, so once the alert threshold is crossed the action's + # `pulls.createReview` call 403s. That error isn't gated by + # fail-on-alert (which only governs the deliberate alert-threshold + # failure) -- it propagates and fails the job outright. Fork PRs + # still get the comparison via the job summary (summary-always). + - name: Compare bundle size against nightly baseline + 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: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + alert-threshold: "110%" + fail-on-alert: false + summary-always: true diff --git a/README.md b/README.md index d70729321ab..961886df64d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ under the License. [![Open PRs](https://img.shields.io/github/issues-pr/apache/superset)](https://github.com/apache/superset/pulls) [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://bit.ly/join-superset-slack) [![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.apache.org) +[![Storybook](https://img.shields.io/badge/storybook-live-ff4785.svg)](https://superset-storybook.netlify.app) +[![Bundle Analyzer](https://img.shields.io/badge/bundle%20analyzer-nightly-8dd6f9.svg)](https://superset-bundle-analyzer.netlify.app) + +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 '); + 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 built with ' + + '`BUNDLE_SIZE_STATS=true` set? Without it, webpack.config.js uses ' + + '`stats: "minimal"`, which omits `entrypoints`.', + ); + process.exit(1); + } + + const results = []; + TRACKED_ENTRYPOINTS.forEach(name => { + const entrypoint = entrypoints[name]; + if (!entrypoint) { + console.error(`stats.json is missing the "${name}" entrypoint`); + process.exit(1); + } + 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)); +} + +if (require.main === module) { + main(); +} + +module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS }; diff --git a/superset-frontend/spec/scripts/bundle-size-summary.test.js b/superset-frontend/spec/scripts/bundle-size-summary.test.js new file mode 100644 index 00000000000..fafd1984294 --- /dev/null +++ b/superset-frontend/spec/scripts/bundle-size-summary.test.js @@ -0,0 +1,110 @@ +/** + * 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. + */ +const fs = require('fs'); +const { + entrypointSizeByExt, + main, +} = require('../../scripts/bundle-size-summary'); + +function mockStats(entrypoints) { + jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify({ entrypoints })); +} + +function mockExit() { + return jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); +} + +const originalArgv = process.argv; + +afterEach(() => { + jest.restoreAllMocks(); + process.argv = originalArgv; +}); + +test('entrypointSizeByExt sums only assets matching the given extension', () => { + const entrypoint = { + assets: [ + { name: 'spa.entry.js', size: 100 }, + { name: 'spa.entry.js.map', size: 500 }, + { name: 'spa.entry.css', size: 20 }, + ], + }; + expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100); + expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20); +}); + +test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => { + expect(entrypointSizeByExt({}, '.js')).toBe(0); +}); + +test('main prints byte totals for every tracked entrypoint', () => { + mockStats({ + spa: { assets: [{ name: 'spa.js', size: 100 }] }, + embedded: { assets: [{ name: 'embedded.js', size: 50 }] }, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + process.argv = ['node', 'bundle-size-summary.js', 'stats.json']; + + main(); + + const printed = JSON.parse(logSpy.mock.calls[0][0]); + expect(printed).toEqual([ + { name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 }, + { name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 }, + { name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 }, + { name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 }, + ]); +}); + +test('main exits with an error when a tracked entrypoint is missing from stats.json', () => { + mockStats({ spa: { assets: [] } }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockExit(); + process.argv = ['node', 'bundle-size-summary.js', 'stats.json']; + + expect(main).toThrow('process.exit called'); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('missing the "embedded" entrypoint'), + ); +}); + +test('main exits with an error when stats.json has no `entrypoints` key', () => { + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({})); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockExit(); + process.argv = ['node', 'bundle-size-summary.js', 'stats.json']; + + expect(main).toThrow('process.exit called'); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('no `entrypoints` key'), + ); +}); + +test('main prints a usage message and exits when no stats path is given', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockExit(); + process.argv = ['node', 'bundle-size-summary.js']; + + expect(main).toThrow('process.exit called'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:')); +}); diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 020391c0f3a..84bb13d7b5d 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);