From 6e95ec18f769d1be0c090de68401d4fc5156e164 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Thu, 11 Dec 2025 16:53:18 -0500 Subject: [PATCH 1/4] feat(ci): compare builds --- .github/workflows/compare-builds.yml | 125 ++++++++++++++++++++ .github/workflows/generate.yml | 2 +- scripts/compare-builds/web.mjs | 151 ++++++++++++++++++++++++ src/generators/web/constants.mjs | 4 +- src/generators/web/utils/processing.mjs | 3 +- 5 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/compare-builds.yml create mode 100644 scripts/compare-builds/web.mjs diff --git a/.github/workflows/compare-builds.yml b/.github/workflows/compare-builds.yml new file mode 100644 index 00000000..01dec71d --- /dev/null +++ b/.github/workflows/compare-builds.yml @@ -0,0 +1,125 @@ +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: web + 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: Download all comparison artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: results + + - name: Combine results + id: combine + run: | + shopt -s nullglob + result_files=(results/*.txt) + + if ((${#result_files[@]})); then + { + echo "combined<> "$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 }} diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 30765597..750ee278 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -87,5 +87,5 @@ jobs: - name: Upload ${{ matrix.target }} artifacts uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: ${{ matrix.target }}-${{ github.run_id }} + name: ${{ matrix.target }} path: out/${{ matrix.target }} diff --git a/scripts/compare-builds/web.mjs b/scripts/compare-builds/web.mjs new file mode 100644 index 00000000..dea47084 --- /dev/null +++ b/scripts/compare-builds/web.mjs @@ -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]}`; +}; + +/** + * 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 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} baseStats - Base stats map + * @param {Map} 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) => + `
\n${summary}\n\n${content}\n\n
`; + +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) + ) + ); + } +} diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 4b1d2409..7c785838 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -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({ // 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' }, ], -}; +}); diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index a2839f6f..71e2ad49 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -106,7 +106,6 @@ export async function processJSXEntries( ]); const titleSuffix = `Node.js v${version.version} Documentation`; - const speculationRulesString = JSON.stringify(SPECULATION_RULES, null, 2); // Step 3: Create final HTML (could be parallelized in workers) const results = entries.map(({ data: { api, heading } }) => { @@ -118,7 +117,7 @@ export async function processJSXEntries( .replace('{{dehydrated}}', serverBundle.get(fileName) ?? '') .replace('{{importMap}}', clientBundle.importMap ?? '') .replace('{{entrypoint}}', `./${fileName}?${randomUUID()}`) - .replace('{{speculationRules}}', speculationRulesString); + .replace('{{speculationRules}}', SPECULATION_RULES); // Minify HTML (input must be a Buffer) const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); From 47110307d9dda162f34af31c4f7333d1e9babe3d Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Thu, 11 Dec 2025 20:25:53 -0500 Subject: [PATCH 2/4] Update .github/workflows/compare-builds.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/compare-builds.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compare-builds.yml b/.github/workflows/compare-builds.yml index 01dec71d..c62f1e75 100644 --- a/.github/workflows/compare-builds.yml +++ b/.github/workflows/compare-builds.yml @@ -70,7 +70,7 @@ jobs: - name: Download Output (BASE) uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: - name: web + name: ${{ matrix.comparator }} path: out/base run-id: ${{ steps.base-run.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} From 37a134efdbb08370b86988110f6f833e952a1852 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Thu, 11 Dec 2025 20:26:04 -0500 Subject: [PATCH 3/4] Update scripts/compare-builds/web.mjs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/compare-builds/web.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/compare-builds/web.mjs b/scripts/compare-builds/web.mjs index dea47084..f8ce2a3a 100644 --- a/scripts/compare-builds/web.mjs +++ b/scripts/compare-builds/web.mjs @@ -24,7 +24,7 @@ const formatBytes = bytes => { * 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%)") + * @returns {string} Formatted diff string (e.g., "+1.50 KB (+10.00%)") */ const formatDiff = (base, head) => { const diff = head - base; From 7d339217d2640b384c3dd9b19a68023dc3a2b024 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 12 Dec 2025 15:29:11 -0500 Subject: [PATCH 4/4] harden runner --- .github/workflows/compare-builds.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/compare-builds.yml b/.github/workflows/compare-builds.yml index 01dec71d..ae65fc61 100644 --- a/.github/workflows/compare-builds.yml +++ b/.github/workflows/compare-builds.yml @@ -97,6 +97,11 @@ jobs: 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: