diff --git a/.github/scripts/measure-bundle-sizes.js b/.github/scripts/measure-bundle-sizes.js new file mode 100644 index 00000000..835c3f66 --- /dev/null +++ b/.github/scripts/measure-bundle-sizes.js @@ -0,0 +1,52 @@ +const fs = require('fs'); +const path = require('path'); + +const outputPath = process.argv[2]; + +if (!outputPath) { + console.error( + 'Usage: node .github/scripts/measure-bundle-sizes.js ', + ); + process.exit(1); +} + +const rootDir = process.cwd(); +const sizes = {}; + +function normalizePath(filePath) { + return path.relative(rootDir, filePath).replace(/\\/g, '/'); +} + +function shouldTrack(filePath) { + const normalizedPath = normalizePath(filePath); + + if (normalizedPath.startsWith('packages/packer/dist/')) { + return false; + } + + return /(^|\/)dist\/[^/]+\.(js|cjs|mjs|css)$/.test(normalizedPath); +} + +function walk(dirPath) { + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (entry.name === 'node_modules') { + continue; + } + + const entryPath = path.join(dirPath, entry.name); + + if (entry.isDirectory()) { + walk(entryPath); + continue; + } + + if (!shouldTrack(entryPath)) { + continue; + } + + sizes[normalizePath(entryPath)] = fs.statSync(entryPath).size; + } +} + +walk(rootDir); +fs.writeFileSync(outputPath, `${JSON.stringify(sizes, null, 2)}\n`); diff --git a/.github/scripts/render-bundle-size-comment.js b/.github/scripts/render-bundle-size-comment.js new file mode 100644 index 00000000..bdf0a12c --- /dev/null +++ b/.github/scripts/render-bundle-size-comment.js @@ -0,0 +1,155 @@ +const fs = require('fs'); + +const prPath = process.argv[2]; +const basePath = process.argv[3]; + +if (!prPath || !basePath) { + console.error( + 'Usage: node .github/scripts/render-bundle-size-comment.js ', + ); + process.exit(1); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function formatSize(bytes) { + if (bytes == null) { + return '-'; + } + + if (Math.abs(bytes) < 1024) { + return `${bytes} B`; + } + + return `${(bytes / 1024).toFixed(2)} kB`; +} + +function formatSignedSize(bytes) { + const absoluteBytes = Math.abs(bytes); + + if (absoluteBytes < 1024) { + return `${bytes >= 0 ? '+' : '-'}${absoluteBytes} B`; + } + + return `${bytes >= 0 ? '+' : '-'}${(absoluteBytes / 1024).toFixed(2)} kB`; +} + +function formatDiff(diff, baseValue) { + if (diff === 0) { + return '-'; + } + + const percentage = + baseValue > 0 + ? ` (${diff > 0 ? '+' : ''}${((diff / baseValue) * 100).toFixed(2)}%)` + : ''; + + return `${formatSignedSize(diff)}${percentage}`; +} + +function getPackageName(filePath) { + const match = filePath.match(/^packages\/([^/]+)\//); + return match ? match[1] : '(root)'; +} + +function getFileLabel(filePath, packageName) { + const packagePrefix = `packages/${packageName}/dist/`; + + if (packageName !== '(root)' && filePath.startsWith(packagePrefix)) { + return filePath.slice(packagePrefix.length); + } + + return filePath; +} + +const prSizes = readJson(prPath); +const baseSizes = readJson(basePath); + +const allFiles = [ + ...new Set([...Object.keys(prSizes), ...Object.keys(baseSizes)]), +].sort(); +const changedFiles = allFiles.filter( + (filePath) => prSizes[filePath] !== baseSizes[filePath], +); + +if (changedFiles.length === 0) { + process.stdout.write('## Bundle Size Changes\n\nNo bundle size changes.\n'); + process.exit(0); +} + +const totalPrSize = allFiles.reduce( + (sum, filePath) => sum + (prSizes[filePath] ?? 0), + 0, +); +const totalBaseSize = allFiles.reduce( + (sum, filePath) => sum + (baseSizes[filePath] ?? 0), + 0, +); +const totalDiff = totalPrSize - totalBaseSize; + +const filesByPackage = new Map(); + +for (const filePath of changedFiles) { + const packageName = getPackageName(filePath); + const files = filesByPackage.get(packageName) ?? []; + files.push(filePath); + filesByPackage.set(packageName, files); +} + +const sections = [...filesByPackage.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([packageName, files]) => { + const packagePrSize = files.reduce( + (sum, filePath) => sum + (prSizes[filePath] ?? 0), + 0, + ); + const packageBaseSize = files.reduce( + (sum, filePath) => sum + (baseSizes[filePath] ?? 0), + 0, + ); + const packageDiff = packagePrSize - packageBaseSize; + + const rows = files + .map((filePath) => { + const fileDiff = (prSizes[filePath] ?? 0) - (baseSizes[filePath] ?? 0); + return `| \`${getFileLabel(filePath, packageName)}\` | ${formatSize( + baseSizes[filePath], + )} | ${formatSize(prSizes[filePath])} | ${formatDiff( + fileDiff, + baseSizes[filePath] ?? 0, + )} |`; + }) + .join('\n'); + + return [ + '
', + `\`${packageName}\` - ${formatSize( + packageBaseSize, + )} -> ${formatSize(packagePrSize)} (${formatDiff( + packageDiff, + packageBaseSize, + )})`, + '', + '| File | Base | PR | Diff |', + '|------|------|----|------|', + rows, + '', + '
', + ].join('\n'); + }); + +const body = [ + '## Bundle Size Changes', + '', + `**Size change:** ${formatDiff( + totalDiff, + totalBaseSize, + )} | **Total size:** ${formatSize(totalPrSize)}`, + '', + sections.join('\n\n'), + '', +].join('\n'); + +process.stdout.write(body); diff --git a/.github/workflows/eslint-check.yml b/.github/workflows/eslint-check.yml index 40e522a8..715394e9 100644 --- a/.github/workflows/eslint-check.yml +++ b/.github/workflows/eslint-check.yml @@ -2,7 +2,7 @@ name: ESLint Check on: push: - pull_request_target: + pull_request: jobs: eslint_check_upload: @@ -12,10 +12,15 @@ jobs: name: ESLint Check and Report Upload steps: - - uses: actions/checkout@v4 + - name: Checkout pull request head + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha }} + - name: Checkout current branch + if: github.event_name != 'pull_request' + uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v3 with: @@ -38,49 +43,60 @@ jobs: with: name: eslint_report.json path: eslint_report.json + - name: Measure PR bundle sizes + if: github.event_name == 'pull_request' + run: node .github/scripts/measure-bundle-sizes.js pr-sizes.json + - name: Upload PR bundle sizes + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: pr-sizes + path: pr-sizes.json - annotation: - # Skip the annotation action in push events - if: github.event_name == 'pull_request_target' - permissions: - checks: write + bundle_size_build: + # Only runs on PRs. Reuses the PR build from eslint_check_upload (via the + # pr-sizes artifact) and only builds the base branch itself. The privileged + # bundle-size-comment workflow then posts the PR comment without ever + # executing fork code. + if: github.event_name == 'pull_request' needs: eslint_check_upload runs-on: ubuntu-latest - name: ESLint Annotation - steps: - - uses: actions/download-artifact@v4 - with: - name: eslint_report.json - - name: Annotate Code Linting Results - uses: ataylorme/eslint-annotate-action@v2 - with: - repo-token: '${{ secrets.GITHUB_TOKEN }}' - report-json: 'eslint_report.json' - - bundle_size: - # Only runs on PRs (needs a base branch to compare against) - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-latest permissions: + actions: read contents: read - pull-requests: write - name: Check Bundle Sizes + name: Build Base for Bundle Size Comparison steps: + - name: Checkout workflow ref + uses: actions/checkout@v4 + - name: Prepare bundle size helper + run: | + cp .github/scripts/measure-bundle-sizes.js /tmp/measure-bundle-sizes.js + # --- Base branch --- - uses: actions/checkout@v4 with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.head_ref }} - - name: Setup Node - uses: actions/setup-node@v3 + ref: ${{ github.base_ref }} + - name: Download PR bundle sizes + uses: actions/download-artifact@v4 + with: + name: pr-sizes + - uses: actions/setup-node@v3 with: node-version: lts/* cache: 'yarn' - - name: Check bundle sizes - uses: preactjs/compressed-size-action@v2 - with: - install-script: 'yarn install --frozen-lockfile' - build-script: 'build:all' - compression: 'none' - pattern: '**/dist/*.{js,cjs,mjs,css}' + - name: Install base dependencies + run: yarn install --frozen-lockfile env: PUPPETEER_SKIP_DOWNLOAD: true + - name: Build base branch + run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all + env: + PUPPETEER_SKIP_DOWNLOAD: true + - name: Measure base bundle sizes + run: node /tmp/measure-bundle-sizes.js base-sizes.json + + - uses: actions/upload-artifact@v4 + with: + name: bundle-size-data + path: | + pr-sizes.json + base-sizes.json diff --git a/.github/workflows/pr-checks-privileged.yml b/.github/workflows/pr-checks-privileged.yml new file mode 100644 index 00000000..d78f6358 --- /dev/null +++ b/.github/workflows/pr-checks-privileged.yml @@ -0,0 +1,85 @@ +name: PR Checks (privileged) + +# Runs in the base-repo context (privileged) after eslint-check.yml completes. +# Downloads pre-built artifacts and posts PR comments/annotations. +# Never checks out or executes fork code. +on: + workflow_run: + workflows: ['ESLint Check'] + types: [completed] + +jobs: + comment: + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + pull-requests: write + steps: + - name: Checkout trusted workflow helpers + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + - uses: actions/download-artifact@v4 + with: + name: bundle-size-data + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + - name: Find PR number + id: find-pr + uses: actions/github-script@v7 + with: + script: | + const run = context.payload.workflow_run; + if (run.pull_requests && run.pull_requests.length > 0) { + return run.pull_requests[0].number; + } + // Fallback for fork PRs (pull_requests is empty for forks) + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${run.head_repository.full_name}:${run.head_branch}`, + state: 'open', + }); + if (prs.length === 0) { + core.setFailed('Could not determine PR number'); + return; + } + return prs[0].number; + result-encoding: string + + - name: Render bundle size comment + if: steps.find-pr.outputs.result + run: | + node .github/scripts/render-bundle-size-comment.js pr-sizes.json base-sizes.json > bundle-size-comment.md + + - name: Post bundle size comment + if: steps.find-pr.outputs.result + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 + with: + header: bundle-size + path: bundle-size-comment.md + number: ${{ steps.find-pr.outputs.result }} + + annotate: + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + checks: write + steps: + - uses: actions/download-artifact@v4 + with: + name: eslint_report.json + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + - name: Annotate Code Linting Results + uses: ataylorme/eslint-annotate-action@5f4dc2e3af8d3c21b727edb597e5503510b1dc9c + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + report-json: 'eslint_report.json'