From 9fd28adf839d42dc82f01b94468f98694b15a993 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 2 Jan 2025 22:22:53 +0900 Subject: [PATCH] chore: dist file size chekcing with github actions (#2063) * chore: dist file size chekcing with github actions * fix: size checking scripts * fix: add size reporting scripts * fix: add github action workflows * fix: tweak workflow name * fix: add test for size reporting with workflow dispatch --- .github/workflows/nightly-release.yml | 2 +- .github/workflows/size-data.yml | 48 ++++++++ .github/workflows/size-report.yml | 83 +++++++++++++ package.json | 7 +- packages/size-check-core/scripts/size.mjs | 22 ++++ .../scripts/size.mjs | 22 ++++ packages/size-check-vue-i18n/scripts/size.mjs | 22 ++++ pnpm-lock.yaml | 8 ++ scripts/build.sh | 2 +- scripts/build.ts | 38 ++++-- scripts/report-size.ts | 110 ++++++++++++++++++ scripts/size.ts | 51 ++++++++ scripts/utils.ts | 42 ++++++- 13 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/size-data.yml create mode 100644 .github/workflows/size-report.yml create mode 100644 packages/size-check-core/scripts/size.mjs create mode 100644 packages/size-check-petite-vue-i18n/scripts/size.mjs create mode 100644 packages/size-check-vue-i18n/scripts/size.mjs create mode 100644 scripts/report-size.ts create mode 100644 scripts/size.ts diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 83d8aa622..bf16cf5d3 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -1,4 +1,4 @@ -name: nightly release +name: Nightly release on: push: branches: diff --git a/.github/workflows/size-data.yml b/.github/workflows/size-data.yml new file mode 100644 index 000000000..849cb59f3 --- /dev/null +++ b/.github/workflows/size-data.yml @@ -0,0 +1,48 @@ +name: Size data + +on: + push: + branches: + - master + pull_request: + branches: + - master + +permissions: + contents: read + +jobs: + upload: + if: github.repository == 'intlify/vue-i18n' + runs-on: ubuntu-latest + + steps: + - name: Checkout codes + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4.0.0 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 23 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check size + run: pnpm size + + - name: Save PR number & base branch + if: ${{github.event_name == 'pull_request'}} + run: | + echo ${{ github.event.number }} > ./temp/size/number.txt + echo ${{ github.base_ref }} > ./temp/size/base.txt + + - name: Upload Size Data + uses: actions/upload-artifact@v4 + with: + name: size-data + path: temp/size diff --git a/.github/workflows/size-report.yml b/.github/workflows/size-report.yml new file mode 100644 index 000000000..d0e820a67 --- /dev/null +++ b/.github/workflows/size-report.yml @@ -0,0 +1,83 @@ +name: Size report + +on: + workflow_run: + workflows: ['Size data'] + types: + - completed + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + size-report: + runs-on: ubuntu-latest + if: > + github.repository == 'intlify/vue-i18n' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4.0.0 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 23 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download Size Data + uses: dawidd6/action-download-artifact@v6 + with: + name: size-data + run_id: ${{ github.event.workflow_run.id }} + path: temp/size + + - name: Read PR Number + id: pr-number + uses: juliangruber/read-file-action@v1 + with: + path: temp/size/number.txt + + - name: Read base branch + id: pr-base + uses: juliangruber/read-file-action@v1 + with: + path: temp/size/base.txt + + - name: Download Previous Size Data + uses: dawidd6/action-download-artifact@v6 + with: + branch: ${{ steps.pr-base.outputs.content }} + workflow: size-data.yml + event: push + name: size-data + path: temp/size-prev + if_no_artifact_found: warn + + - name: Prepare report + run: npx tsx scripts/size-report.ts > size-report.md + + - name: Read Size Report + id: size-report + uses: juliangruber/read-file-action@v1 + with: + path: ./size-report.md + + - name: Create Comment + uses: actions-cool/maintain-one-comment@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + number: ${{ steps.pr-number.outputs.content }} + body: | + ${{ steps.size-report.outputs.content }} + + body-include: '' diff --git a/package.json b/package.json index 6d676da9d..6bb013162 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,13 @@ "build:sourcemap": "pnpm build --sourcemap", "build:type": "./scripts/build.sh", "build:typed": "pnpm build core-base vue-i18n-core --withTypes", - "check-install": "tsx ./scripts/playwright.ts", + "size": "tsx ./scripts/build.ts --size && tsx ./scripts/size.ts", + "size:report": "tsx ./scripts/report-size.ts", "clean": "run-p clean:*", "clean:coverage": "rm -rf ./coverage", "clean:dist": "rm -rf ./dist ./packages/**/dist ./docs/.vitepress/dist", "clean:docs": "trash './docs/api/!(injection).md'", "clean:type": "rm -rf ./temp", - "coverage": "opener coverage/index.html", "dev": "tsx ./scripts/dev.ts", "dev:e2e": "cross-env TZ=UTC vitest -c ./vitest.e2e.config.ts", "dev:eslint": "npx @eslint/config-inspector", @@ -73,6 +73,8 @@ "preview:size-petite-vue-i18n": "pnpm --filter @intlify/size-check-petite-vue-i18n preview", "preview:size-vue-i18n": "pnpm --filter @intlify/size-check-vue-i18n preview", "release": "bumpp package.json packages/**/package.json --commit \"release: v\" --push --tag", + "check-install": "tsx ./scripts/playwright.ts", + "coverage": "opener coverage/index.html", "test": "run-s lint test:cover check-install test:e2e", "test:cover": "pnpm test:unit --coverage", "test:e2e": "cross-env TZ=UTC vitest run -c ./vitest.e2e.config.ts", @@ -112,6 +114,7 @@ "jsdom": "^24.0.0", "lint-staged": "^15.2.2", "listhen": "^1.7.2", + "markdown-table": "^3.0.4", "mitata": "^1.0.20", "npm-run-all2": "^7.0.0", "opener": "^1.5.2", diff --git a/packages/size-check-core/scripts/size.mjs b/packages/size-check-core/scripts/size.mjs new file mode 100644 index 000000000..a95235267 --- /dev/null +++ b/packages/size-check-core/scripts/size.mjs @@ -0,0 +1,22 @@ +import { brotliCompressSync, gzipSync } from 'node:zlib' +import { build } from 'vite' + +const generated = await build({ + logLevel: 'silent', + build: { + minify: true + } +}) +const bundled = generated.output[0].code + +const size = bundled.length +const gzip = gzipSync(bundled).length +const brotli = brotliCompressSync(bundled).length + +const report = { + name: '@intlify/core', + size, + gzip, + brotli +} +console.log(JSON.stringify(report)) diff --git a/packages/size-check-petite-vue-i18n/scripts/size.mjs b/packages/size-check-petite-vue-i18n/scripts/size.mjs new file mode 100644 index 000000000..2037dd6ba --- /dev/null +++ b/packages/size-check-petite-vue-i18n/scripts/size.mjs @@ -0,0 +1,22 @@ +import { brotliCompressSync, gzipSync } from 'node:zlib' +import { build } from 'vite' + +const generated = await build({ + logLevel: 'silent', + build: { + minify: true + } +}) +const bundled = generated.output[0].code + +const size = bundled.length +const gzip = gzipSync(bundled).length +const brotli = brotliCompressSync(bundled).length + +const report = { + name: 'petite-vue-i18n', + size, + gzip, + brotli +} +console.log(JSON.stringify(report)) diff --git a/packages/size-check-vue-i18n/scripts/size.mjs b/packages/size-check-vue-i18n/scripts/size.mjs new file mode 100644 index 000000000..7b5a534ee --- /dev/null +++ b/packages/size-check-vue-i18n/scripts/size.mjs @@ -0,0 +1,22 @@ +import { brotliCompressSync, gzipSync } from 'node:zlib' +import { build } from 'vite' + +const generated = await build({ + logLevel: 'silent', + build: { + minify: true + } +}) +const bundled = generated.output[0].code + +const size = bundled.length +const gzip = gzipSync(bundled).length +const brotli = brotliCompressSync(bundled).length + +const report = { + name: 'vue-i18n', + size, + gzip, + brotli +} +console.log(JSON.stringify(report)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9317f8f5..bd07c7e11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: listhen: specifier: ^1.7.2 version: 1.7.2 + markdown-table: + specifier: ^3.0.4 + version: 3.0.4 mitata: specifier: ^1.0.20 version: 1.0.20 @@ -5015,6 +5018,9 @@ packages: markdown-table@2.0.0: resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + match-casing@1.0.3: resolution: {integrity: sha512-oMyC3vUVCFbGu+M2Zxl212LPJThcaw7QxB5lFuJPQCgV/dsGBP0yZeCoLmX6CiBkoBcVbAKDJZrBpJVu0XcLMw==} @@ -12703,6 +12709,8 @@ snapshots: dependencies: repeat-string: 1.6.1 + markdown-table@3.0.4: {} + match-casing@1.0.3: {} match-index@1.0.3: diff --git a/scripts/build.sh b/scripts/build.sh index dd0bfd4d7..0aaf71a5a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -2,6 +2,6 @@ set -e -pnpm build --withTypes --size +pnpm build --withTypes tsx ./scripts/postprocess.ts diff --git a/scripts/build.ts b/scripts/build.ts index d9ef1f56b..bc6e37285 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -27,6 +27,7 @@ import pc from 'picocolors' import { targets as allTargets, checkSizeDistFiles, + displaySize, fuzzyMatchTarget, readJson } from './utils' @@ -84,20 +85,27 @@ const { } = values const formats = rawFormats?.split(',') +const sizeDir = path.resolve(__dirname, '../temp/size') async function main() { await run() async function run() { + if (size) { + await fs.mkdir(sizeDir, { recursive: true }) + } + const rtsCachePath = path.resolve(__dirname, './node_modules/.rts2_cache') if (isRelease && existsSync(rtsCachePath)) { // remove build cache for release builds to avoid outdated enum values await fs.rm(rtsCachePath, { recursive: true }) } + const resolvedTargets = targets.length ? await fuzzyMatchTarget(targets, buildAllMatching) : await allTargets() await buildAll(resolvedTargets) + if (size) { await checkAllSizes(resolvedTargets) } @@ -273,17 +281,33 @@ async function main() { return } const file = await fs.readFile(filePath) - const minSize = (file.length / 1024).toFixed(2) + 'kb' + const filename = path.basename(filePath) + const gzipped = gzipSync(file) - const gzippedSize = (gzipped.length / 1024).toFixed(2) + 'kb' - const compressed = brotliCompressSync(file) - const compressedSize = - compressed != null ? (compressed.length / 1024).toFixed(2) + 'kb' : 'N/A' + const brotli = brotliCompressSync(file) console.log( - `📦 ${pc.gray( + `📦 ${pc.green( pc.bold(path.basename(filePath)) - )} min:${minSize} / gzip:${gzippedSize} / brotli:${compressedSize}` + )} - min: ${displaySize(file.length)} / gzip: ${displaySize(gzipped.length)} / brotli: ${displaySize(brotli.length)}` ) + + if (size) { + const sizeContents = JSON.stringify( + { + file: filename, + size: file.length, + gzip: gzipped.length, + brotli: brotli.length + }, + null, + 2 + ) + await fs.writeFile( + path.resolve(sizeDir, `${filename}.json`), + sizeContents, + 'utf-8' + ) + } } } diff --git a/scripts/report-size.ts b/scripts/report-size.ts new file mode 100644 index 000000000..d85d6a2d2 --- /dev/null +++ b/scripts/report-size.ts @@ -0,0 +1,110 @@ +import { markdownTable } from 'markdown-table' +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' +import { displaySize } from './utils' + +import type { BundleReport } from './utils' + +type UsageReport = Record + +const currDir = path.resolve('temp/size') +const prevDir = path.resolve('temp/size-prev') +const sizeHeaders = ['Size', 'Gzip', 'Brotli'] + +async function rednerFiles(output: string) { + const filterFiles = (files: string[]) => + files.filter(file => file[0] !== '_' && !file.endsWith('.txt')) + + const curr = filterFiles(await fs.readdir(currDir)) + const prev = existsSync(prevDir) ? filterFiles(await fs.readdir(prevDir)) : [] + const fileList = new Set([...curr, ...prev]) + + const rows = [] + for (const file of fileList) { + const currPath = path.resolve(currDir, file) + const prevPath = path.resolve(prevDir, file) + + const curr = await importJSON(currPath) + const prev = await importJSON(prevPath) + const fileName = curr?.file || prev?.file || '' + + if (!curr) { + rows.push([`~~${fileName}~~`]) + } else { + rows.push([ + fileName, + `${displaySize(curr.size)}${getDiff(curr.size, prev?.size)}`, + `${displaySize(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`, + `${displaySize(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}` + ]) + } + } + + output += '### Bundles\n\n' + output += markdownTable([['File', ...sizeHeaders], ...rows]) + output += '\n\n' + + return output +} + +async function importJSON(filePath: string) { + if (!existsSync(filePath)) { + return undefined + } + return (await import(filePath, { assert: { type: 'json' } })).default +} + +function getDiff(curr: number, prev: number) { + if (prev === undefined) { + return '' + } + const diff = curr - prev + if (diff === 0) { + return '' + } + const sign = diff > 0 ? '+' : '' + return ` (**${sign}${displaySize(diff)}**)` +} + +async function renderUsages(output: string) { + const curr = (await importJSON( + path.resolve(currDir, '_usages.json') + )) as UsageReport + const prev = (await importJSON( + path.resolve(prevDir, '_usages.json') + )) as UsageReport + + output += '\n### Usages\n\n' + + const data = Object.values(curr) + .map(usage => { + const prevUsage = prev?.[usage.name] + const diffSize = getDiff(usage.size, prevUsage?.size) + const diffGzipped = getDiff(usage.gzip, prevUsage?.gzip) + const diffBrotli = getDiff(usage.brotli, prevUsage?.brotli) + + return [ + usage.name, + `${displaySize(usage.size)}${diffSize}`, + `${displaySize(usage.gzip)}${diffGzipped}`, + `${displaySize(usage.brotli)}${diffBrotli}` + ] + }) + .filter(usage => !!usage) + + output += `${markdownTable([['Name', ...sizeHeaders], ...data])}\n\n` + + return output +} + +async function main() { + let output = '## Size Report\n\n' + output = await rednerFiles(output) + output = await renderUsages(output) + process.stdout.write(output) +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/size.ts b/scripts/size.ts new file mode 100644 index 000000000..75a9be142 --- /dev/null +++ b/scripts/size.ts @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import pc from 'picocolors' +import { displaySize, sizeTargets } from './utils' + +import type { BundleReport } from './utils' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const sizeDir = path.resolve(__dirname, '../temp/size') + +async function main() { + console.log('📏 Checking bundle sizes ...') + console.log() + const targets = await sizeTargets() + const results: BundleReport[] = [] + for (const target of targets) { + const root = path.resolve(__dirname, `../packages/${target}`) + results.push(bundle(root)) + } + + for (const result of results) { + console.log( + `${pc.green(pc.bold(result.name))} - ` + + `min: ${displaySize(result.size)} / ` + + `gzip: ${displaySize(result.gzip)} / ` + + `brotli: ${displaySize(result.brotli)}` + ) + } + + await fs.mkdir(sizeDir, { recursive: true }) + await fs.writeFile( + path.resolve(sizeDir, '_usages.json'), + JSON.stringify(Object.fromEntries(results.map(r => [r.name, r])), null, 2), + 'utf-8' + ) +} + +function bundle(target: string) { + const orgDir = process.cwd() + process.chdir(target) + const result = spawnSync('node', ['scripts/size.mjs']) + process.chdir(orgDir) + return JSON.parse(result.stdout.toString()) as BundleReport +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/utils.ts b/scripts/utils.ts index 732aa56cc..d65112c1b 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -2,6 +2,13 @@ import { promises as fs } from 'node:fs' import { dirname, resolve } from 'node:path' import pc from 'picocolors' +export type BundleReport = { + name: string + size: number + gzip: number + brotli: number +} + export const targets = async () => { const packages = await fs.readdir('packages') const files = await Promise.all( @@ -54,12 +61,45 @@ export const fuzzyMatchTarget = async ( } } +export async function sizeTargets() { + const packages = await fs.readdir('packages') + const files = await Promise.all( + packages.map(async f => { + const stat = await fs.stat(`packages/${f}`) + if (!stat.isDirectory()) { + return '' + } + const pkg = await readJson( + resolve(dirname(''), `./packages/${f}/package.json`) + ) + if (!pkg.private) { + return '' + } + return f + }) + ) + return files.filter((_, f) => files[f]).filter(f => /size-check/.test(f)) +} + export async function checkSizeDistFiles(target: string) { const dirs = await fs.readdir(`${target}/dist`) - return dirs.filter(file => /prod.(cjs|js)$/.test(file)) + // prettier-ignore + return dirs.filter(file => /^(message-compiler|core|vue-i18n|petite-vue-i18n)/.test(file)) + .filter(file => !/^core-base/.test(file)) + .filter(file => /prod.(cjs|js)$/.test(file)) + .filter(file => !/cjs.prod.js$/.test(file)) } export async function readJson(path: string) { const data = await fs.readFile(path, 'utf8') return JSON.parse(data) } + +const NUMBER_FORMATTER = new Intl.NumberFormat('en', { + maximumFractionDigits: 2, + minimumFractionDigits: 2 +}) + +export function displaySize(bytes: number) { + return `${NUMBER_FORMATTER.format(bytes / 1000)} kB` +}