From 7c0db7a0c59859c662b51c9c3820e458ae399f7b Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Sat, 29 Aug 2026 16:58:59 +0100 Subject: [PATCH] ci: refactor pipelines, add bundle size report Extract shared build/publish steps into reusable workflows, replace archived release actions, least-privilege permissions everywhere, SHA-pin third-party actions, and report gzip bundle size diffs on PRs. Signed-off-by: Pedro Lamas --- .github/workflows/_build.yml | 80 ++++++ .github/workflows/_publish-docker.yml | 101 +++++++ .github/workflows/build.yml | 291 +++++++++++--------- .github/workflows/bundle-comment.yml | 44 +++ .github/workflows/docs.yml | 30 ++ .github/workflows/release.yml | 143 +++++----- .github/workflows/scorecard.yml | 53 ++++ .github/workflows/semantic_pull_request.yml | 12 +- .gitignore | 1 + package.json | 2 + pnpm-lock.yaml | 190 +++++++++++++ tools/bundle-size.mjs | 165 +++++++++++ vite.config.ts | 12 +- 13 files changed, 913 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/_build.yml create mode 100644 .github/workflows/_publish-docker.yml create mode 100644 .github/workflows/bundle-comment.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 tools/bundle-size.mjs diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml new file mode 100644 index 0000000000..ed16af421f --- /dev/null +++ b/.github/workflows/_build.yml @@ -0,0 +1,80 @@ +name: _BUILD + +on: + workflow_call: + inputs: + artifact-name: + description: Name for the uploaded dist artifact + required: true + type: string + upload-artifact: + description: Whether to upload the dist artifact + required: false + type: boolean + default: true + +permissions: {} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + run_install: false + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm i --frozen-lockfile + + - name: Run lint + run: | + pnpm run lint --no-fix + + - name: Run type check + if: ${{ !cancelled() }} + run: | + pnpm run type-check + + - name: Run tests + if: ${{ !cancelled() }} + run: | + pnpm run test:unit + + - name: Run circular references check + if: ${{ !cancelled() }} + run: | + pnpm run circular-check + + - name: Build + run: | + pnpm run build + + - name: Upload dist + if: inputs.upload-artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name }} + path: ./dist + retention-days: 7 diff --git a/.github/workflows/_publish-docker.yml b/.github/workflows/_publish-docker.yml new file mode 100644 index 0000000000..8a4066315c --- /dev/null +++ b/.github/workflows/_publish-docker.yml @@ -0,0 +1,101 @@ +name: _PUBLISH DOCKER + +on: + workflow_call: + inputs: + artifact-name: + description: Name of the dist artifact to publish + required: true + type: string + +permissions: {} + +jobs: + publish-docker: + name: Publish ${{ matrix.type }} Image + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - type: Docker + image-name: ${{ github.repository }} + base-image: nginx:alpine-slim + port: 80 + - type: Docker Unprivileged + image-name: ${{ github.repository }}-unprivileged + base-image: nginxinc/nginx-unprivileged:alpine-slim + port: 8080 + permissions: + id-token: write + contents: read + attestations: write + packages: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download dist + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact-name }} + path: ./dist + + - name: Prepare Docker image metadata + id: docker_meta + uses: docker/metadata-action@v6 + with: + images: | + ghcr.io/${{ matrix.image-name }} + tags: | + type=semver,pattern={{raw}} + type=sha,format=long + type=raw,value=latest-develop,enable=${{ github.ref == 'refs/heads/develop' }} + type=raw,value=latest-master,enable=${{ github.ref == 'refs/heads/master' }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + id: docker_push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 + build-args: | + BASE_IMAGE=${{ matrix.base-image }} + PORT=${{ matrix.port }} + push: true + sbom: true + provenance: true + cache-from: type=gha,scope=${{ matrix.type }} + cache-to: type=gha,mode=min,scope=${{ matrix.type }} + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + annotations: ${{ steps.docker_meta.outputs.annotations }} + + - name: Attest Docker image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/${{ matrix.image-name }} + subject-digest: ${{ steps.docker_push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 037332376e..c9a82ab077 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,184 +5,219 @@ on: branches: - develop - master - tags: - - 'v*' pull_request: branches: - develop - master + merge_group: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: {} + jobs: build: name: Build + uses: ./.github/workflows/_build.yml + with: + artifact-name: fluidd-${{ github.sha }} + permissions: + contents: read + + publish-docker: + name: Publish + needs: build + if: ${{ github.event_name != 'pull_request' }} + uses: ./.github/workflows/_publish-docker.yml + with: + artifact-name: fluidd-${{ github.sha }} + permissions: + id-token: write + contents: read + attestations: write + packages: write + + publish-web: + name: Deploy to Host + needs: build + if: ${{ github.repository == 'fluidd-core/fluidd' && github.ref == 'refs/heads/develop' }} runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + id-token: write + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@v7 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 with: - run_install: false + persist-credentials: false - - name: Setup Node - uses: actions/setup-node@v7 + - name: Download fluidd dist + uses: actions/download-artifact@v8 with: - node-version: 24 - cache: 'pnpm' - - - name: Install dependencies - run: | - pnpm i --frozen-lockfile - - - name: Run lint - run: | - pnpm run lint --no-fix - - - name: Run type check - run: | - pnpm run type-check + name: fluidd-${{ github.sha }} + path: ./dist - - name: Run tests + - name: Prepare Deploy run: | - pnpm run test:unit + cp ./server/config.json ./dist/config.json - - name: Run circular references check - run: | - pnpm run circular-check + - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + aws-region: us-east-1 + role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE }} - - name: Build + - name: Deploy to Host + env: + BUCKET: ${{ secrets.DEV_S3_BUCKET_NAME }} + DIST_ID: ${{ secrets.DEV_CF_DISTRIBUTION_ID }} run: | - pnpm run build + aws s3 sync ./dist "s3://${BUCKET}" --delete --cache-control no-cache + aws cloudfront create-invalidation --distribution-id "${DIST_ID}" --paths '/*' - - name: Upload fluidd.zip - uses: actions/upload-artifact@v7 - with: - name: fluidd-${{ github.sha }}.zip - path: ./dist - - publish-docker: - name: Publish ${{ matrix.type }} Image + bundle-baseline: + name: Save Bundle Size Baseline needs: build - if: ${{ github.event_name != 'pull_request' }} + if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') }} runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - type: Docker - image-name: ${{ github.repository }} - base-image: nginx:alpine-slim - port: 80 - - type: Docker Unprivileged - image-name: ${{ github.repository }}-unprivileged - base-image: nginxinc/nginx-unprivileged:alpine-slim - port: 8080 + timeout-minutes: 10 permissions: - id-token: write contents: read - attestations: write - packages: write steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@v7 + with: + persist-credentials: false + sparse-checkout: tools - - name: Download fluidd.zip + - name: Download dist uses: actions/download-artifact@v8 with: - name: fluidd-${{ github.sha }}.zip + name: fluidd-${{ github.sha }} path: ./dist - - name: Prepare Docker image metadata - id: docker_meta - uses: docker/metadata-action@v6 - with: - images: | - ghcr.io/${{ matrix.image-name }} - tags: | - type=semver,pattern={{raw}} - type=ref,event=pr - type=sha,format=long - type=raw,value=latest-develop,enable=${{ github.ref == 'refs/heads/develop' }} - type=raw,value=latest-master,enable=${{ github.ref == 'refs/heads/master' }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to the Container registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push Docker image - id: docker_push - uses: docker/build-push-action@v7 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 - build-args: | - BASE_IMAGE=${{ matrix.base-image }} - PORT=${{ matrix.port }} - push: true - sbom: true - provenance: true - cache-from: type=gha - cache-to: type=gha,mode=max - tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.docker_meta.outputs.labels }} - annotations: ${{ steps.docker_meta.outputs.annotations }} - - - name: Attest Docker image - uses: actions/attest-build-provenance@v4 - with: - subject-name: ghcr.io/${{ matrix.image-name }} - subject-digest: ${{ steps.docker_push.outputs.digest }} - push-to-registry: true + - name: Generate bundle size manifest + run: node tools/bundle-size.mjs generate dist bundle-size.json - publish-web: - name: Deploy to Host + - name: Cache bundle size manifest + uses: actions/cache/save@v4 + with: + path: bundle-size.json + key: bundle-size-${{ github.sha }} + + bundle-size: + name: Bundle Size Report needs: build - if: ${{ github.repository == 'fluidd-core/fluidd' && github.ref == 'refs/heads/develop' }} + if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest + timeout-minutes: 20 permissions: - id-token: write contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 - - name: Download fluidd.zip + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + run_install: false + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version-file: .node-version + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm i --frozen-lockfile + + - name: Resolve merge-base + id: mb + env: + BASE_REF: ${{ github.base_ref }} + run: | + git fetch --no-tags origin "$BASE_REF" + echo "sha=$(git merge-base "origin/$BASE_REF" HEAD)" >> "$GITHUB_OUTPUT" + + - name: Restore bundle size baseline + id: cache + uses: actions/cache/restore@v4 + with: + path: bundle-size.base.json + key: bundle-size-${{ steps.mb.outputs.sha }} + + - name: Build merge-base + if: ${{ steps.cache.outputs.cache-hit != 'true' }} + env: + MERGE_BASE: ${{ steps.mb.outputs.sha }} + run: | + git worktree add ../base "$MERGE_BASE" + cd ../base + pnpm i --frozen-lockfile + pnpm run build + node "$GITHUB_WORKSPACE/tools/bundle-size.mjs" generate dist "$GITHUB_WORKSPACE/bundle-size.base.json" + + - name: Save bundle size baseline + if: ${{ steps.cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: bundle-size.base.json + key: bundle-size-${{ steps.mb.outputs.sha }} + + - name: Download PR dist uses: actions/download-artifact@v8 with: - name: fluidd-${{ github.sha }}.zip + name: fluidd-${{ github.sha }} path: ./dist - - name: Prepare Deploy + - name: Generate PR bundle size manifest run: | - cp ./server/config.json ./dist/config.json + node tools/bundle-size.mjs generate dist bundle-size.head.json - - uses: aws-actions/configure-aws-credentials@v6 - with: - aws-region: us-east-1 - role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE }} + - name: Compare bundle sizes + run: | + node tools/bundle-size.mjs compare bundle-size.base.json bundle-size.head.json | tee bundle-size-report.md >> "$GITHUB_STEP_SUMMARY" - - name: Deploy to Host - uses: reggionick/s3-deploy@v4 - with: - folder: dist - bucket: ${{ secrets.DEV_S3_BUCKET_NAME }} - bucket-region: us-east-1 - dist-id: ${{ secrets.DEV_CF_DISTRIBUTION_ID }} - delete-removed: true - no-cache: true - private: true + - name: Write PR number + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + echo "$PR_NUMBER" > pr-number.txt + + - name: Build treemap + continue-on-error: true + run: | + pnpm run build:analyze + + - name: Upload bundle size report + uses: actions/upload-artifact@v7 + with: + name: bundle-size-report + path: | + bundle-size-report.md + pr-number.txt + .analyze/stats.html + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/bundle-comment.yml b/.github/workflows/bundle-comment.yml new file mode 100644 index 0000000000..3146dc3d23 --- /dev/null +++ b/.github/workflows/bundle-comment.yml @@ -0,0 +1,44 @@ +name: BUNDLE COMMENT + +on: + workflow_run: + workflows: ['BUILD'] + types: + - completed + +permissions: {} + +jobs: + comment: + name: Post Bundle Size Comment + if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write + actions: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Download bundle size report + uses: actions/download-artifact@v8 + with: + name: bundle-size-report + path: ./report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read PR number + id: pr + run: | + echo "number=$(cat ./report/pr-number.txt)" >> "$GITHUB_OUTPUT" + + - name: Comment on PR + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + number_force: ${{ steps.pr.outputs.number }} + header: bundle-size + path: ./report/bundle-size-report.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a71a42660b..ed644771c3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,30 +6,53 @@ on: - master paths: - 'docs/**' + - '.github/workflows/docs.yml' pull_request: branches: - master - develop paths: - 'docs/**' + - '.github/workflows/docs.yml' + merge_group: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: {} + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: actions/setup-python@v7 with: python-version: '3.x' + cache: pip + cache-dependency-path: docs/requirements.txt - name: Install docs dependencies run: pip install -r docs/requirements.txt + - name: Lint docs + run: | + codespell docs/docs/ + npx --yes markdownlint-cli@0.49.1 --config docs/.markdownlint.json docs/docs/ + - name: Build docs run: zensical build --clean working-directory: docs @@ -43,12 +66,19 @@ jobs: needs: build if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-latest + timeout-minutes: 10 permissions: pages: write id-token: write + contents: read environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - id: deployment uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f41336a218..353c3fcd2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,53 +4,59 @@ on: push: tags: - 'v*' + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + +permissions: {} jobs: build: name: Build + uses: ./.github/workflows/_build.yml + with: + artifact-name: fluidd-${{ github.sha }} + permissions: + contents: read + + publish-docker: + name: Publish + needs: build + uses: ./.github/workflows/_publish-docker.yml + with: + artifact-name: fluidd-${{ github.sha }} + permissions: + id-token: write + contents: read + attestations: write + packages: write + + release: + name: Release + needs: build runs-on: ubuntu-latest + timeout-minutes: 15 permissions: id-token: write contents: write attestations: write steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@v7 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 with: - run_install: false + persist-credentials: false - - name: Setup Node - uses: actions/setup-node@v7 + - name: Download fluidd dist + uses: actions/download-artifact@v8 with: - node-version: 24 - cache: 'pnpm' - - - name: Install dependencies - run: | - pnpm i --frozen-lockfile - - - name: Run lint - run: | - pnpm run lint --no-fix - - - name: Run type check - run: | - pnpm run type-check - - - name: Run tests - run: | - pnpm run test:unit - - - name: Run circular references check - run: | - pnpm run circular-check - - - name: Build - run: | - pnpm run build + name: fluidd-${{ github.sha }} + path: ./dist - name: Create Artifact run: | @@ -65,10 +71,10 @@ jobs: - name: Get version from tag id: tag_name run: | - echo "current_version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + echo "current_version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" - name: Read ChangeLog - uses: mindsers/changelog-reader-action@v2.4.0 + uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0 id: changelog with: validation_depth: 1 @@ -76,76 +82,53 @@ jobs: path: ./CHANGELOG.md - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - body: | - ${{ steps.changelog.outputs.changes }} - draft: true - prerelease: false - - - name: Upload Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./fluidd.zip - asset_name: fluidd.zip - asset_content_type: application/zip - - - name: Upload Artifact - uses: actions/upload-artifact@v7 - with: - name: fluidd-${{ github.sha }}.zip - path: ./dist - - - name: Publish Release - uses: eregon/publish-release@v1.0.6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - release_id: ${{ steps.create_release.outputs.id }} + name: ${{ steps.tag_name.outputs.current_version }} + body: ${{ steps.changelog.outputs.changes }} + files: ./fluidd.zip + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') }} publish-web: name: Deploy to Host needs: build if: ${{ github.repository == 'fluidd-core/fluidd' }} runs-on: ubuntu-latest + timeout-minutes: 15 permissions: id-token: write contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@v7 + with: + persist-credentials: false - - name: Download fluidd.zip + - name: Download fluidd dist uses: actions/download-artifact@v8 with: - name: fluidd-${{ github.sha }}.zip + name: fluidd-${{ github.sha }} path: ./dist - name: Prepare Deploy run: | cp ./server/config.json ./dist/config.json - - uses: aws-actions/configure-aws-credentials@v6 + - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: aws-region: us-east-1 role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE }} - name: Deploy to Host - uses: reggionick/s3-deploy@v4 - with: - folder: dist - bucket: ${{ secrets.S3_BUCKET_NAME }} - bucket-region: us-east-1 - dist-id: ${{ secrets.CF_DISTRIBUTION_ID }} - delete-removed: true - no-cache: true - private: true + env: + BUCKET: ${{ secrets.S3_BUCKET_NAME }} + DIST_ID: ${{ secrets.CF_DISTRIBUTION_ID }} + run: | + aws s3 sync ./dist "s3://${BUCKET}" --delete --cache-control no-cache + aws cloudfront create-invalidation --distribution-id "${DIST_ID}" --paths '/*' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000000..7ee08b8889 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,53 @@ +name: Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '30 1 * * 6' + push: + branches: + - develop + +permissions: {} + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # Needed to upload the results to code scanning + security-events: write + # Needed to publish results and get a badge (used only for Public repositories) + id-token: write + contents: read + actions: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/semantic_pull_request.yml b/.github/workflows/semantic_pull_request.yml index 30e1450804..8f3855e200 100644 --- a/.github/workflows/semantic_pull_request.yml +++ b/.github/workflows/semantic_pull_request.yml @@ -8,13 +8,23 @@ on: - edited - synchronize +permissions: {} + jobs: main: name: Semantic Pull Request runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Validate PR title - uses: amannn/action-semantic-pull-request@v6 + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 with: types: | feat diff --git a/.gitignore b/.gitignore index 7de704de99..bd79c9a396 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .pnpm-store /dist /dev-dist +/.analyze /tests/e2e/videos/ /tests/e2e/screenshots/ diff --git a/package.json b/package.json index 8c0b357d53..9e155ed558 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "build:analyze": "ANALYZE=1 vite build", "preview": "vite preview", "serve:prod": "vite preview --port 5000", "serve:docs": "cd ./docs && zensical serve", @@ -88,6 +89,7 @@ "jsdom": "^30.0.1", "mockdate": "^3.0.5", "neostandard": "^0.13.0", + "rollup-plugin-visualizer": "^7.1.1", "sass-embedded": "^1.103.1", "skott": "^0.35.11", "svgo": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b0ec1b1c0..8df2ac8c24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: neostandard: specifier: ^0.13.0 version: 0.13.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript-native-bridge@6.0.3-bridge.15.tsgo.7.0.2) + rollup-plugin-visualizer: + specifier: ^7.1.1 + version: 7.1.1(rolldown@1.2.5)(rollup@4.62.5) sass-embedded: specifier: ^1.103.1 version: 1.103.1 @@ -1824,6 +1827,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -1832,6 +1839,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} @@ -1974,6 +1985,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2048,6 +2063,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2321,10 +2340,22 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -2423,6 +2454,9 @@ packages: electron-to-chromium@1.5.412: resolution: {integrity: sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2775,6 +2809,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3061,6 +3099,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -3653,6 +3695,10 @@ packages: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} + open@11.0.1: + resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} + engines: {node: '>=20'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3823,6 +3869,14 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + powershell-utils@0.2.0: + resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} + engines: {node: '>=20'} + preact@10.29.8: resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} peerDependencies: @@ -4022,11 +4076,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-visualizer@7.1.1: + resolution: {integrity: sha512-ThaGiHTU8XW02OkK80TrTHATraJmM9OAduU4otal+7gyXLpYEtmGBLfx5kW+EHvvLwn03YGW2NnwKUIqsYlJAA==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: ^4.62.4 + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.62.5: resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4321,6 +4392,14 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.codepointat@0.2.1: resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} @@ -4357,6 +4436,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -5004,6 +5087,14 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5058,6 +5149,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@16.2.2: resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} @@ -5066,6 +5161,10 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6623,6 +6722,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.3.0: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -6631,6 +6732,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + anynum@1.0.1: {} argparse@1.0.10: @@ -6797,6 +6900,10 @@ snapshots: buffer-from@1.1.2: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -6875,6 +6982,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -7168,12 +7281,21 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -7298,6 +7420,8 @@ snapshots: electron-to-chromium@1.5.412: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} enhanced-resolve@5.24.5: @@ -7794,6 +7918,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8075,6 +8201,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -8632,6 +8760,15 @@ snapshots: on-headers@1.1.0: {} + open@11.0.1: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.2.0 + wsl-utils: 1.0.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8797,6 +8934,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + + powershell-utils@0.2.0: {} + preact@10.29.8: {} prelude-ls@1.2.1: {} @@ -9017,6 +9158,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5 + rollup-plugin-visualizer@7.1.1(rolldown@1.2.5)(rollup@4.62.5): + dependencies: + open: 11.0.1 + picomatch: 4.0.5 + source-map: 0.8.0 + yargs: 18.1.0 + optionalDependencies: + rolldown: 1.2.5 + rollup: 4.62.5 + rollup@4.62.5: dependencies: '@types/estree': 1.0.9 @@ -9049,6 +9200,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.5 fsevents: 2.3.3 + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -9355,6 +9508,17 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string.prototype.codepointat@0.2.1: {} string.prototype.matchall@4.0.12: @@ -9420,6 +9584,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + strip-bom@3.0.0: {} strip-comments@2.0.1: {} @@ -10074,6 +10242,17 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wsl-utils@1.0.0: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} xml-naming@0.3.0: {} @@ -10105,6 +10284,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@16.2.2: dependencies: cliui: 7.0.4 @@ -10125,6 +10306,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/tools/bundle-size.mjs b/tools/bundle-size.mjs new file mode 100644 index 0000000000..acd0806709 --- /dev/null +++ b/tools/bundle-size.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// Bundle size manifest generator + comparator, used by the CI bundle-size report. +// +// Usage: +// node tools/bundle-size.mjs generate +// node tools/bundle-size.mjs compare [--markdown-only] +// +// No dependencies — only node:fs, node:path and node:zlib. Sizes are gzip'd since +// server/nginx/default.conf.template serves assets gzipped; that's the number that +// matters for what a browser actually downloads. + +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { gzipSync } from 'node:zlib' +import { join, relative, sep } from 'node:path' + +// Vite/rolldown content hashes are always 8 chars of [A-Za-z0-9_-], immediately +// before the extension (e.g. `setupMonaco-BGNH_NaM.js` -> `setupMonaco-*.js`). +// Stripping exactly 8 (not "8 or more") avoids false-positives on names that +// merely end in enough letters, e.g. `editor.worker-D9zwrD0f.js`'s `.worker` part. +const HASH_RE = /-[\w-]{8}(\.[a-z0-9]+)$/ + +function stripHash (filename) { + return filename.replace(HASH_RE, '-*$1') +} + +function walk (dir, root = dir, out = {}) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + walk(full, root, out) + continue + } + + if (!/\.(?:js|css)$/.test(entry.name)) continue + + const raw = statSync(full).size + const gzip = gzipSync(readFileSync(full), { level: 9 }).length + const relDir = relative(root, dir) + const key = (relDir ? `${relDir}${sep}` : '') + stripHash(entry.name) + + // A hash collision after stripping (unlikely, but not impossible for tiny + // chunks) would silently overwrite — sum instead so nothing goes missing. + if (out[key]) { + out[key] = { raw: out[key].raw + raw, gzip: out[key].gzip + gzip } + } else { + out[key] = { raw, gzip } + } + } + + return out +} + +function generate (distDir, outFile) { + const entries = walk(distDir) + const manifest = { generatedAt: new Date().toISOString(), entries } + + writeFileSync(outFile, JSON.stringify(manifest, null, 2)) + + const count = Object.keys(entries).length + console.error(`Wrote ${count} entries to ${outFile}`) +} + +function formatBytes (bytes) { + if (bytes === 0) return '0 B' + + const units = ['B', 'kB', 'MB'] + let value = Math.abs(bytes) + let unit = 0 + + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + + const formatted = unit === 0 ? String(Math.round(value)) : value.toFixed(1) + + return `${bytes < 0 ? '-' : ''}${formatted} ${units[unit]}` +} + +function formatDelta (bytes) { + if (bytes === 0) return '±0 B' + + return `${bytes > 0 ? '+' : ''}${formatBytes(bytes)}` +} + +function compare (baseFile, headFile) { + const base = JSON.parse(readFileSync(baseFile, 'utf8')).entries + const head = JSON.parse(readFileSync(headFile, 'utf8')).entries + + const keys = new Set([...Object.keys(base), ...Object.keys(head)]) + + const rows = [...keys].map(key => { + const baseGzip = base[key]?.gzip ?? null + const headGzip = head[key]?.gzip ?? null + const delta = (headGzip ?? 0) - (baseGzip ?? 0) + const status = baseGzip == null ? 'added' : headGzip == null ? 'removed' : 'changed' + + return { key, baseGzip, headGzip, delta, status } + }) + + const changed = rows + .filter(row => row.delta !== 0) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + + const totalBase = rows.reduce((sum, row) => sum + (row.baseGzip ?? 0), 0) + const totalHead = rows.reduce((sum, row) => sum + (row.headGzip ?? 0), 0) + const totalDelta = totalHead - totalBase + + const lines = [] + + lines.push('## Bundle size report (gzip)') + lines.push('') + + if (changed.length === 0) { + lines.push('No chunk size changes detected.') + } else { + lines.push('| Chunk | Base | Head | Δ |') + lines.push('|---|---|---|---|') + + for (const row of changed) { + const chunk = row.status === 'added' + ? `${row.key} 🆕` + : row.status === 'removed' + ? `${row.key} 🗑️` + : row.key + + lines.push(`| \`${chunk}\` | ${row.baseGzip == null ? '—' : formatBytes(row.baseGzip)} | ${row.headGzip == null ? '—' : formatBytes(row.headGzip)} | ${formatDelta(row.delta)} |`) + } + + lines.push(`| **Total** | **${formatBytes(totalBase)}** | **${formatBytes(totalHead)}** | **${formatDelta(totalDelta)}** |`) + } + + lines.push('') + lines.push(`${rows.length} chunks compared, ${changed.length} changed. Sizes are gzip, matching what nginx serves.`) + + console.log(lines.join('\n')) +} + +const [, , command, ...args] = process.argv + +switch (command) { + case 'generate': { + const [distDir, outFile] = args + if (!distDir || !outFile) { + console.error('Usage: bundle-size.mjs generate ') + process.exit(1) + } + generate(distDir, outFile) + break + } + + case 'compare': { + const [baseFile, headFile] = args + if (!baseFile || !headFile) { + console.error('Usage: bundle-size.mjs compare ') + process.exit(1) + } + compare(baseFile, headFile) + break + } + + default: + console.error('Usage: bundle-size.mjs ...') + process.exit(1) +} diff --git a/vite.config.ts b/vite.config.ts index e3a9d56647..3a0753fc5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig, type PluginOption } from 'vite' import vue from '@pedrolamas/plugin-vue2' import { VitePWA } from 'vite-plugin-pwa' import Components from 'unplugin-vue-components/rolldown' @@ -6,6 +6,7 @@ import { VuetifyResolver } from 'unplugin-vue-components/resolvers' import path from 'path' import content from '@originjs/vite-plugin-content' import checker from 'vite-plugin-checker' +import { visualizer } from 'rollup-plugin-visualizer' import version from './vite.config.inject-version.ts' export default defineConfig({ @@ -109,7 +110,14 @@ export default defineConfig({ resolvers: [ VuetifyResolver() ] - }) + }), + // Opt-in treemap for `pnpm run build:analyze` — emitted outside dist/ so it + // can never be swept into the deployed bundle or the PWA precache manifest. + !!process.env.ANALYZE && visualizer({ + filename: path.resolve(import.meta.dirname, './.analyze/stats.html'), + gzipSize: true, + template: 'treemap' + }) as PluginOption ], css: {