-
Notifications
You must be signed in to change notification settings - Fork 24
feat(ci): compare builds #517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6e95ec1
4711030
37a134e
7d33921
9ef0970
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| name: Compare Build Outputs | ||
|
|
||
| on: | ||
| workflow_run: | ||
| workflows: ['Generate Docs'] | ||
| types: [completed] | ||
|
|
||
| permissions: | ||
| contents: read | ||
| actions: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| get-comparators: | ||
| name: Get Comparators | ||
| runs-on: ubuntu-latest | ||
| if: github.event.workflow_run.event == 'pull_request' | ||
| outputs: | ||
| comparators: ${{ steps.get-comparators.outputs.comparators }} | ||
| steps: | ||
| - name: Harden Runner | ||
| uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 | ||
| with: | ||
| egress-policy: audit | ||
|
|
||
| - name: Checkout Code | ||
| uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 | ||
|
|
||
| - name: List comparators | ||
| id: get-comparators | ||
| run: | | ||
| # List all .mjs files in scripts/compare-builds/ and remove the .mjs extension | ||
| COMPARATORS=$(ls scripts/compare-builds/*.mjs | xargs -n1 basename | sed 's/\.mjs$//' | jq -R -s -c 'split("\n")[:-1]') | ||
| echo "comparators=$COMPARATORS" >> $GITHUB_OUTPUT | ||
| compare: | ||
| name: Run ${{ matrix.comparator }} comparator | ||
| runs-on: ubuntu-latest | ||
| needs: get-comparators | ||
| strategy: | ||
| matrix: | ||
| comparator: ${{ fromJSON(needs.get-comparators.outputs.comparators) }} | ||
|
|
||
| steps: | ||
| - name: Harden Runner | ||
| uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 | ||
| with: | ||
| egress-policy: audit | ||
|
|
||
| - name: Checkout Code | ||
| uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 | ||
|
|
||
| - name: Download Output (HEAD) | ||
| uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 | ||
| with: | ||
| name: ${{ matrix.comparator }} | ||
| path: out/head | ||
| run-id: ${{ github.event.workflow_run.id }} | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Get Run ID from BASE | ||
| id: base-run | ||
| env: | ||
| WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| ID=$(gh run list -c $GITHUB_SHA -w $WORKFLOW_ID -L 1 --json databaseId --jq ".[].databaseId") | ||
| echo "run_id=$ID" >> $GITHUB_OUTPUT | ||
| - name: Download Output (BASE) | ||
| uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 | ||
| with: | ||
| name: ${{ matrix.comparator }} | ||
| path: out/base | ||
| run-id: ${{ steps.base-run.outputs.run_id }} | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Compare Bundle Size | ||
| id: compare | ||
| run: | | ||
| node scripts/compare-builds/${{ matrix.comparator }}.mjs > result.txt | ||
| if [ -s result.txt ]; then | ||
| echo "has_output=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "has_output=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| - name: Upload comparison artifact | ||
| if: steps.compare.outputs.has_output == 'true' | ||
| uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||
| with: | ||
| name: ${{ matrix.comparator }} | ||
| path: result.txt | ||
|
|
||
| aggregate: | ||
| name: Aggregate Comparison Results | ||
| runs-on: ubuntu-latest | ||
| needs: compare | ||
| steps: | ||
| - name: Harden Runner | ||
| uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 | ||
| with: | ||
| egress-policy: audit | ||
|
|
||
| - name: Download all comparison artifacts | ||
| uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 | ||
| with: | ||
| path: results | ||
avivkeller marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| - name: Combine results | ||
| id: combine | ||
| run: | | ||
| shopt -s nullglob | ||
| result_files=(results/*.txt) | ||
avivkeller marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if ((${#result_files[@]})); then | ||
avivkeller marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| echo "combined<<EOF" | ||
| cat "${result_files[@]}" | ||
| echo "EOF" | ||
| } >> "$GITHUB_OUTPUT" | ||
| fi | ||
| - name: Add Comment to PR | ||
| if: steps.combine.outputs.combined | ||
| uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 | ||
| with: | ||
| comment-tag: compared | ||
| message: ${{ steps.combine.outputs.combined }} | ||
| pr-number: ${{ github.event.workflow_run.pull_requests[0].number }} | ||
avivkeller marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import { stat, readdir } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| const BASE = fileURLToPath(import.meta.resolve('../../out/base')); | ||
| const HEAD = fileURLToPath(import.meta.resolve('../../out/head')); | ||
| const UNITS = ['B', 'KB', 'MB', 'GB']; | ||
|
|
||
| /** | ||
| * Formats bytes into human-readable format | ||
| * @param {number} bytes - Number of bytes | ||
| * @returns {string} Formatted string (e.g., "1.50 KB") | ||
| */ | ||
| const formatBytes = bytes => { | ||
| if (!bytes) { | ||
| return '0 B'; | ||
| } | ||
|
|
||
| const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)); | ||
| return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${UNITS[i]}`; | ||
avivkeller marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| /** | ||
| * Formats the difference between base and head sizes | ||
| * @param {number} base - Base file size in bytes | ||
| * @param {number} head - Head file size in bytes | ||
| * @returns {string} Formatted diff string (e.g., "+1.50 KB (+10.00%)") | ||
| */ | ||
| const formatDiff = (base, head) => { | ||
| const diff = head - base; | ||
| const sign = diff > 0 ? '+' : ''; | ||
| const percent = base ? `${sign}${((diff / base) * 100).toFixed(2)}%` : 'N/A'; | ||
| return `${sign}${formatBytes(diff)} (${percent})`; | ||
| }; | ||
|
|
||
| /** | ||
| * Gets all files in a directory with their stats | ||
| * @param {string} dir - Directory path to search | ||
| * @returns {Promise<Map<string, number>>} Map of filename to size | ||
| */ | ||
| const getDirectoryStats = async dir => { | ||
| const files = await readdir(dir); | ||
| const entries = await Promise.all( | ||
| files.map(async file => [file, (await stat(path.join(dir, file))).size]) | ||
| ); | ||
| return new Map(entries); | ||
| }; | ||
|
|
||
| /** | ||
| * Generates a table row for a file | ||
| * @param {string} file - Filename | ||
| * @param {number} baseSize - Base size in bytes | ||
| * @param {number} headSize - Head size in bytes | ||
| * @returns {string} Markdown table row | ||
| */ | ||
| const generateRow = (file, baseSize, headSize) => { | ||
| const baseCol = formatBytes(baseSize); | ||
| const headCol = formatBytes(headSize); | ||
| const diffCol = formatDiff(baseSize, headSize); | ||
|
|
||
| return `| \`${file}\` | ${baseCol} | ${headCol} | ${diffCol} |`; | ||
| }; | ||
|
|
||
| /** | ||
| * Generates a markdown table | ||
| * @param {string[]} files - List of files | ||
| * @param {Map<string, number>} baseStats - Base stats map | ||
| * @param {Map<string, number>} headStats - Head stats map | ||
| * @returns {string} Markdown table | ||
| */ | ||
| const generateTable = (files, baseStats, headStats) => { | ||
| const header = '| File | Base | Head | Diff |\n|------|------|------|------|'; | ||
| const rows = files.map(f => | ||
| generateRow(f, baseStats.get(f), headStats.get(f)) | ||
| ); | ||
| return `${header}\n${rows.join('\n')}`; | ||
| }; | ||
|
|
||
| /** | ||
| * Wraps content in a details/summary element | ||
| * @param {string} summary - Summary text | ||
| * @param {string} content - Content to wrap | ||
| * @returns {string} Markdown details element | ||
| */ | ||
| const details = (summary, content) => | ||
| `<details>\n<summary>${summary}</summary>\n\n${content}\n\n</details>`; | ||
|
|
||
| const [baseStats, headStats] = await Promise.all( | ||
| [BASE, HEAD].map(getDirectoryStats) | ||
| ); | ||
|
|
||
| const allFiles = Array.from( | ||
| new Set([...baseStats.keys(), ...headStats.keys()]) | ||
| ); | ||
|
|
||
| // Filter to only changed files (exist in both and have different sizes) | ||
| const changedFiles = allFiles.filter( | ||
| f => | ||
| baseStats.has(f) && | ||
| headStats.has(f) && | ||
| baseStats.get(f) !== headStats.get(f) | ||
| ); | ||
|
|
||
| if (changedFiles.length) { | ||
| // Separate HTML files and their matching JS files from other files | ||
| const pages = []; | ||
| const other = []; | ||
|
|
||
| // Get all HTML base names | ||
| const htmlBaseNames = new Set( | ||
| changedFiles | ||
| .filter(f => path.extname(f) === '.html') | ||
| .map(f => path.basename(f, '.html')) | ||
| ); | ||
|
|
||
| for (const file of changedFiles) { | ||
| const ext = path.extname(file); | ||
| const basename = path.basename(file, ext); | ||
|
|
||
| // All HTML files go to pages | ||
| if (ext === '.html') { | ||
| pages.push(file); | ||
| } | ||
| // JS files go to pages only if they have a matching HTML file | ||
| else if (ext === '.js' && htmlBaseNames.has(basename)) { | ||
| pages.push(file); | ||
| } | ||
| // Everything else goes to other | ||
| else { | ||
| other.push(file); | ||
| } | ||
| } | ||
|
|
||
| pages.sort(); | ||
| other.sort(); | ||
|
|
||
| console.log('## Web Generator\n'); | ||
|
|
||
| if (other.length) { | ||
| console.log(generateTable(other, baseStats, headStats)); | ||
| } | ||
|
|
||
| if (pages.length) { | ||
| console.log( | ||
| details( | ||
| `Pages (${pages.filter(f => path.extname(f) === '.html').length})`, | ||
| generateTable(pages, baseStats, headStats) | ||
| ) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -85,7 +85,7 @@ export const JSX_IMPORTS = { | |||||
| * Specification rules for resource hints like prerendering and prefetching. | ||||||
| * @see https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API | ||||||
| */ | ||||||
| export const SPECULATION_RULES = { | ||||||
| export const SPECULATION_RULES = JSON.stringify({ | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IDK why you made this unrelated change tho... Any reason why calling JSON.stringify right on definition? Not sure we do that with constants.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
As I mentioned in the comments, I made this change to slightly reduce the build size, showcasing the comparison. It's on the constant just so that the same stringify isn't called on every page.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Hmm, how does this reduce build size? Hmmm
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Also apologies for not notidcing on the comments, I just quickly jumped to the PR) |
||||||
| // Eagerly prefetch all links that point to the API docs themselves | ||||||
| // in a moderate eagerness to improve resource loading | ||||||
| prefetch: [{ where: { href_matches: '/*' }, eagerness: 'eager' }], | ||||||
|
|
@@ -94,4 +94,4 @@ export const SPECULATION_RULES = { | |||||
| // These will be done in a moderate eagerness (hover, likely next navigation) | ||||||
| { where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'moderate' }, | ||||||
| ], | ||||||
| }; | ||||||
| }); | ||||||
Uh oh!
There was an error while loading. Please reload this page.