Skip to content

Commit 6e95ec1

Browse files
committed
feat(ci): compare builds
1 parent 0e07e21 commit 6e95ec1

File tree

5 files changed

+280
-5
lines changed

5 files changed

+280
-5
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
name: Compare Build Outputs
2+
3+
on:
4+
workflow_run:
5+
workflows: ['Generate Docs']
6+
types: [completed]
7+
8+
permissions:
9+
contents: read
10+
actions: read
11+
pull-requests: write
12+
13+
jobs:
14+
get-comparators:
15+
name: Get Comparators
16+
runs-on: ubuntu-latest
17+
if: github.event.workflow_run.event == 'pull_request'
18+
outputs:
19+
comparators: ${{ steps.get-comparators.outputs.comparators }}
20+
steps:
21+
- name: Harden Runner
22+
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
23+
with:
24+
egress-policy: audit
25+
26+
- name: Checkout Code
27+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
28+
29+
- name: List comparators
30+
id: get-comparators
31+
run: |
32+
# List all .mjs files in scripts/compare-builds/ and remove the .mjs extension
33+
COMPARATORS=$(ls scripts/compare-builds/*.mjs | xargs -n1 basename | sed 's/\.mjs$//' | jq -R -s -c 'split("\n")[:-1]')
34+
echo "comparators=$COMPARATORS" >> $GITHUB_OUTPUT
35+
36+
compare:
37+
name: Run ${{ matrix.comparator }} comparator
38+
runs-on: ubuntu-latest
39+
needs: get-comparators
40+
strategy:
41+
matrix:
42+
comparator: ${{ fromJSON(needs.get-comparators.outputs.comparators) }}
43+
44+
steps:
45+
- name: Harden Runner
46+
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
47+
with:
48+
egress-policy: audit
49+
50+
- name: Checkout Code
51+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
52+
53+
- name: Download Output (HEAD)
54+
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
55+
with:
56+
name: ${{ matrix.comparator }}
57+
path: out/head
58+
run-id: ${{ github.event.workflow_run.id }}
59+
github-token: ${{ secrets.GITHUB_TOKEN }}
60+
61+
- name: Get Run ID from BASE
62+
id: base-run
63+
env:
64+
WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }}
65+
GH_TOKEN: ${{ github.token }}
66+
run: |
67+
ID=$(gh run list -c $GITHUB_SHA -w $WORKFLOW_ID -L 1 --json databaseId --jq ".[].databaseId")
68+
echo "run_id=$ID" >> $GITHUB_OUTPUT
69+
70+
- name: Download Output (BASE)
71+
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
72+
with:
73+
name: web
74+
path: out/base
75+
run-id: ${{ steps.base-run.outputs.run_id }}
76+
github-token: ${{ secrets.GITHUB_TOKEN }}
77+
78+
- name: Compare Bundle Size
79+
id: compare
80+
run: |
81+
node scripts/compare-builds/${{ matrix.comparator }}.mjs > result.txt
82+
if [ -s result.txt ]; then
83+
echo "has_output=true" >> "$GITHUB_OUTPUT"
84+
else
85+
echo "has_output=false" >> "$GITHUB_OUTPUT"
86+
fi
87+
88+
- name: Upload comparison artifact
89+
if: steps.compare.outputs.has_output == 'true'
90+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
91+
with:
92+
name: ${{ matrix.comparator }}
93+
path: result.txt
94+
95+
aggregate:
96+
name: Aggregate Comparison Results
97+
runs-on: ubuntu-latest
98+
needs: compare
99+
steps:
100+
- name: Download all comparison artifacts
101+
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
102+
with:
103+
path: results
104+
105+
- name: Combine results
106+
id: combine
107+
run: |
108+
shopt -s nullglob
109+
result_files=(results/*.txt)
110+
111+
if ((${#result_files[@]})); then
112+
{
113+
echo "combined<<EOF"
114+
cat "${result_files[@]}"
115+
echo "EOF"
116+
} >> "$GITHUB_OUTPUT"
117+
fi
118+
119+
- name: Add Comment to PR
120+
if: steps.combine.outputs.combined
121+
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
122+
with:
123+
comment-tag: compared
124+
message: ${{ steps.combine.outputs.combined }}
125+
pr-number: ${{ github.event.workflow_run.pull_requests[0].number }}

.github/workflows/generate.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,5 @@ jobs:
8787
- name: Upload ${{ matrix.target }} artifacts
8888
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
8989
with:
90-
name: ${{ matrix.target }}-${{ github.run_id }}
90+
name: ${{ matrix.target }}
9191
path: out/${{ matrix.target }}

scripts/compare-builds/web.mjs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { stat, readdir } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const BASE = fileURLToPath(import.meta.resolve('../../out/base'));
6+
const HEAD = fileURLToPath(import.meta.resolve('../../out/head'));
7+
const UNITS = ['B', 'KB', 'MB', 'GB'];
8+
9+
/**
10+
* Formats bytes into human-readable format
11+
* @param {number} bytes - Number of bytes
12+
* @returns {string} Formatted string (e.g., "1.50 KB")
13+
*/
14+
const formatBytes = bytes => {
15+
if (!bytes) {
16+
return '0 B';
17+
}
18+
19+
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
20+
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${UNITS[i]}`;
21+
};
22+
23+
/**
24+
* Formats the difference between base and head sizes
25+
* @param {number} base - Base file size in bytes
26+
* @param {number} head - Head file size in bytes
27+
* @returns {string} Formatted diff string (e.g., "+1.50 KB (+10. 00%)")
28+
*/
29+
const formatDiff = (base, head) => {
30+
const diff = head - base;
31+
const sign = diff > 0 ? '+' : '';
32+
const percent = base ? `${sign}${((diff / base) * 100).toFixed(2)}%` : 'N/A';
33+
return `${sign}${formatBytes(diff)} (${percent})`;
34+
};
35+
36+
/**
37+
* Gets all files in a directory with their stats
38+
* @param {string} dir - Directory path to search
39+
* @returns {Promise<Map<string, number>>} Map of filename to size
40+
*/
41+
const getDirectoryStats = async dir => {
42+
const files = await readdir(dir);
43+
const entries = await Promise.all(
44+
files.map(async file => [file, (await stat(path.join(dir, file))).size])
45+
);
46+
return new Map(entries);
47+
};
48+
49+
/**
50+
* Generates a table row for a file
51+
* @param {string} file - Filename
52+
* @param {number} baseSize - Base size in bytes
53+
* @param {number} headSize - Head size in bytes
54+
* @returns {string} Markdown table row
55+
*/
56+
const generateRow = (file, baseSize, headSize) => {
57+
const baseCol = formatBytes(baseSize);
58+
const headCol = formatBytes(headSize);
59+
const diffCol = formatDiff(baseSize, headSize);
60+
61+
return `| \`${file}\` | ${baseCol} | ${headCol} | ${diffCol} |`;
62+
};
63+
64+
/**
65+
* Generates a markdown table
66+
* @param {string[]} files - List of files
67+
* @param {Map<string, number>} baseStats - Base stats map
68+
* @param {Map<string, number>} headStats - Head stats map
69+
* @returns {string} Markdown table
70+
*/
71+
const generateTable = (files, baseStats, headStats) => {
72+
const header = '| File | Base | Head | Diff |\n|------|------|------|------|';
73+
const rows = files.map(f =>
74+
generateRow(f, baseStats.get(f), headStats.get(f))
75+
);
76+
return `${header}\n${rows.join('\n')}`;
77+
};
78+
79+
/**
80+
* Wraps content in a details/summary element
81+
* @param {string} summary - Summary text
82+
* @param {string} content - Content to wrap
83+
* @returns {string} Markdown details element
84+
*/
85+
const details = (summary, content) =>
86+
`<details>\n<summary>${summary}</summary>\n\n${content}\n\n</details>`;
87+
88+
const [baseStats, headStats] = await Promise.all(
89+
[BASE, HEAD].map(getDirectoryStats)
90+
);
91+
92+
const allFiles = Array.from(
93+
new Set([...baseStats.keys(), ...headStats.keys()])
94+
);
95+
96+
// Filter to only changed files (exist in both and have different sizes)
97+
const changedFiles = allFiles.filter(
98+
f =>
99+
baseStats.has(f) &&
100+
headStats.has(f) &&
101+
baseStats.get(f) !== headStats.get(f)
102+
);
103+
104+
if (changedFiles.length) {
105+
// Separate HTML files and their matching JS files from other files
106+
const pages = [];
107+
const other = [];
108+
109+
// Get all HTML base names
110+
const htmlBaseNames = new Set(
111+
changedFiles
112+
.filter(f => path.extname(f) === '.html')
113+
.map(f => path.basename(f, '.html'))
114+
);
115+
116+
for (const file of changedFiles) {
117+
const ext = path.extname(file);
118+
const basename = path.basename(file, ext);
119+
120+
// All HTML files go to pages
121+
if (ext === '.html') {
122+
pages.push(file);
123+
}
124+
// JS files go to pages only if they have a matching HTML file
125+
else if (ext === '.js' && htmlBaseNames.has(basename)) {
126+
pages.push(file);
127+
}
128+
// Everything else goes to other
129+
else {
130+
other.push(file);
131+
}
132+
}
133+
134+
pages.sort();
135+
other.sort();
136+
137+
console.log('## Web Generator\n');
138+
139+
if (other.length) {
140+
console.log(generateTable(other, baseStats, headStats));
141+
}
142+
143+
if (pages.length) {
144+
console.log(
145+
details(
146+
`Pages (${pages.filter(f => path.extname(f) === '.html').length})`,
147+
generateTable(pages, baseStats, headStats)
148+
)
149+
);
150+
}
151+
}

src/generators/web/constants.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const JSX_IMPORTS = {
8585
* Specification rules for resource hints like prerendering and prefetching.
8686
* @see https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
8787
*/
88-
export const SPECULATION_RULES = {
88+
export const SPECULATION_RULES = JSON.stringify({
8989
// Eagerly prefetch all links that point to the API docs themselves
9090
// in a moderate eagerness to improve resource loading
9191
prefetch: [{ where: { href_matches: '/*' }, eagerness: 'eager' }],
@@ -94,4 +94,4 @@ export const SPECULATION_RULES = {
9494
// These will be done in a moderate eagerness (hover, likely next navigation)
9595
{ where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'moderate' },
9696
],
97-
};
97+
});

src/generators/web/utils/processing.mjs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ export async function processJSXEntries(
106106
]);
107107

108108
const titleSuffix = `Node.js v${version.version} Documentation`;
109-
const speculationRulesString = JSON.stringify(SPECULATION_RULES, null, 2);
110109

111110
// Step 3: Create final HTML (could be parallelized in workers)
112111
const results = entries.map(({ data: { api, heading } }) => {
@@ -118,7 +117,7 @@ export async function processJSXEntries(
118117
.replace('{{dehydrated}}', serverBundle.get(fileName) ?? '')
119118
.replace('{{importMap}}', clientBundle.importMap ?? '')
120119
.replace('{{entrypoint}}', `./${fileName}?${randomUUID()}`)
121-
.replace('{{speculationRules}}', speculationRulesString);
120+
.replace('{{speculationRules}}', SPECULATION_RULES);
122121

123122
// Minify HTML (input must be a Buffer)
124123
const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {});

0 commit comments

Comments
 (0)