Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/build-upstream/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ runs:
run: |
pnpm exec napi build --use-napi-cross --target ${INPUTS_TARGET} --profile "$INPUTS_PROFILE" -p vp_global_cli
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: z
INPUTS_PROFILE: ${{ inputs.profile }}
TARGET_CC: clang
TARGET_CFLAGS: ${{ contains(inputs.target, 'aarch64') && '-D_BSD_SOURCE' || '' }}
Expand All @@ -177,6 +178,7 @@ runs:
run: |
pnpm exec napi build -x --target ${INPUTS_TARGET} --profile "$INPUTS_PROFILE" -p vp_global_cli
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: z
INPUTS_PROFILE: ${{ inputs.profile }}
TARGET_CC: clang
TARGET_CFLAGS: ${{ contains(inputs.target, 'aarch64') && '-D_BSD_SOURCE' || '' }}
Expand All @@ -188,6 +190,9 @@ runs:
shell: bash
run: cargo build --profile "$INPUTS_PROFILE" --target ${INPUTS_TARGET} -p vp_global_cli
env:
# Size-optimize the standalone executable and its release dependencies.
# Keep this step-local so NAPI retains its performance-oriented profile.
CARGO_PROFILE_RELEASE_OPT_LEVEL: z
INPUTS_PROFILE: ${{ inputs.profile }}
INPUTS_TARGET: ${{ inputs.target }}

Expand Down
14 changes: 12 additions & 2 deletions .github/actions/build-windows-cli/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,20 @@ runs:
# hatch is needed.
CXXFLAGS: -D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH

- name: Build Rust CLI binaries
- name: Build Rust CLI binary
if: steps.binaries-cache.outputs.cache-hit != 'true'
shell: bash
run: cargo xwin build --profile "$INPUTS_PROFILE" --target x86_64-pc-windows-msvc -p vp_global_cli -p vp_installer
run: cargo xwin build --profile "$INPUTS_PROFILE" --target x86_64-pc-windows-msvc -p vp_global_cli
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: z
INPUTS_PROFILE: ${{ inputs.profile }}
XWIN_ACCEPT_LICENSE: '1'
CXXFLAGS: -D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH

- name: Build installer binary
if: steps.binaries-cache.outputs.cache-hit != 'true'
shell: bash
run: cargo xwin build --profile "$INPUTS_PROFILE" --target x86_64-pc-windows-msvc -p vp_installer
env:
INPUTS_PROFILE: ${{ inputs.profile }}
XWIN_ACCEPT_LICENSE: '1'
Expand Down
242 changes: 242 additions & 0 deletions .github/scripts/preview-binary-size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { execFileSync } from 'node:child_process';
import { lstat, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { gzipSync } from 'node:zlib';

const marker = '<!-- preview-binary-size -->';
const repository = 'https://github.com/voidzero-dev/vite-plus';
const maxBytes = 128 * 1024 ** 2;

export const targets = [
['aarch64-apple-darwin', 'darwin-arm64', 'macOS ARM64'],
['x86_64-apple-darwin', 'darwin-x64', 'macOS x64'],
['aarch64-unknown-linux-gnu', 'linux-arm64-gnu', 'Linux ARM64 GNU'],
['aarch64-unknown-linux-musl', 'linux-arm64-musl', 'Linux ARM64 musl'],
['x86_64-unknown-linux-gnu', 'linux-x64-gnu', 'Linux x64 GNU'],
['x86_64-unknown-linux-musl', 'linux-x64-musl', 'Linux x64 musl'],
['x86_64-pc-windows-msvc', 'win32-x64-msvc', 'Windows x64'],
['aarch64-pc-windows-msvc', 'win32-arm64-msvc', 'Windows ARM64'],
];

export function binaryArtifacts() {
return targets.flatMap(([target, platform, label]) => {
const windows = platform.startsWith('win32');
const archive = `vp-${target}.${windows ? 'zip' : 'tar.gz'}`;
const cli = (name, member) => ({
name: `${name} (${label})`,
path: `vp-global-cli-${target}/${member}`,
asset: archive,
member,
});
const artifacts = [
cli('`vp`', windows ? 'vp.exe' : 'vp'),
{
name: `NAPI (${label})`,
path: `vite-plus-native-${target}/vite-plus.${platform}.node`,
package: `@voidzero-dev/vite-plus-${platform}`,
member: `package/vite-plus.${platform}.node`,
},
];
if (windows) {
artifacts.push(cli('Trampoline', 'vp-shim.exe'), {
name: `Installer (${label})`,
path: `vp-setup-${target}/vp-setup.exe`,
asset: `vp-setup-${target}.exe`,
});
}
return artifacts;
});
}

function releaseVersion(release) {
if (release.draft || release.prerelease || !/^v\d+\.\d+\.\d+$/.test(release.tag_name)) {
throw new Error('Expected a stable Vite+ release tag');
}
return release.tag_name.slice(1);
}

export function measureBinary(bytes) {
if (bytes.length === 0 || bytes.length > maxBytes) {
throw new Error('Binary is empty or exceeds the size limit');
}
return { raw: bytes.length, gzip: gzipSync(bytes, { level: 9 }).length };
}

async function download(url, { allowMissing = false } = {}) {
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (allowMissing && response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Download failed (${response.status}): ${url}`);
}
const chunks = [];
let size = 0;
for await (const chunk of response.body) {
size += chunk.length;
if (size > maxBytes) {
throw new Error(`Download exceeds the size limit: ${url}`);
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

// Only release archives pass through tar/unzip. Preview binaries are read as
// bytes, never executed, loaded as addons, or installed as packages.
export function createReleaseLoader(release, directory) {
const version = releaseVersion(release);
const downloads = new Map();
async function cachedDownload(url) {
if (!downloads.has(url)) {
downloads.set(url, download(url));
}
return downloads.get(url);
}
return async (artifact) => {
let bytes;
let zip = false;
if (artifact.package) {
const metadataBytes = await download(
`https://registry.npmjs.org/${encodeURIComponent(artifact.package)}/${version}`,
{ allowMissing: true },
);
if (!metadataBytes) {
return null;
}
const metadata = JSON.parse(metadataBytes.toString('utf8'));
if (metadata.name !== artifact.package || metadata.version !== version) {
throw new Error(`Unexpected npm package: ${artifact.package}@${version}`);
}
const url = new URL(metadata.dist.tarball);
if (url.protocol !== 'https:' || url.host !== 'registry.npmjs.org') {
throw new Error('Expected an npm registry tarball');
}
bytes = await cachedDownload(url.href);
} else {
if (!release.assets.some((asset) => asset.name === artifact.asset)) {
return null;
}
bytes = await cachedDownload(
`${repository}/releases/download/${release.tag_name}/${artifact.asset}`,
);
zip = artifact.asset.endsWith('.zip');
}
if (!artifact.member) {
return bytes;
}
const options = { maxBuffer: maxBytes, timeout: 30_000 };
if (zip) {
const archive = join(directory, artifact.asset);
await writeFile(archive, bytes);
return execFileSync('unzip', ['-p', archive, artifact.member], options);
}
return execFileSync('tar', ['-xzOf', '-', artifact.member], { ...options, input: bytes });
};
}

function formatSize(bytes, signed = false) {
const absolute = Math.abs(bytes);
const sign = bytes < 0 ? '-' : signed && bytes > 0 ? '+' : '';
if (absolute >= 1024 ** 2) {
return `${sign}${(absolute / 1024 ** 2).toFixed(2)} MiB`;
}
if (absolute >= 1024) {
return `${sign}${(absolute / 1024).toFixed(2)} KiB`;
}
return `${sign}${absolute} B`;
}

function formatDelta(base, preview) {
const delta = preview - base;
return `${formatSize(delta, true)} (${delta > 0 ? '+' : ''}${((delta / base) * 100).toFixed(2)}%)`;
}

export function renderSizeReport({ release, headSha, runUrl, rows }) {
releaseVersion(release);
if (!/^[a-f0-9]{40}$/.test(headSha)) {
throw new Error('Invalid preview commit');
}
const table = (format) => [
'| Binary | Latest release | Preview | Change |',
'| --- | ---: | ---: | ---: |',
...rows.map(({ name, baseline, preview }) =>
baseline
? `| ${name} | ${formatSize(baseline[format])} | ${formatSize(preview[format])} | ${formatDelta(baseline[format], preview[format])} |`
: `| ${name} | — | ${formatSize(preview[format])} | Not in release |`,
),
];
return [
marker,
'',
`### Preview binary sizes (\`${headSha.slice(0, 7)}\`)`,
'',
`Compared with the latest stable release, [\`${release.tag_name}\`](${repository}/releases/tag/${release.tag_name}). [Preview build](${runUrl}).`,
'Each row measures one binary. Negative changes mean a smaller preview binary.',
'',
...table('raw'),
'',
'<details>',
'<summary>gzip -9 sizes</summary>',
'',
...table('gzip'),
'',
'</details>',
'',
'Raw sizes are uncompressed file sizes. gzip sizes recompress each binary at level 9; they are not package or download archive sizes.',
'The baseline uses published release files, including NAPI packages from npm at the same version. Build versions and toolchains can affect the difference.',
].join('\n');
}

export async function comparePreviewBinaries({
release,
artifactDirectory,
headSha,
runUrl,
loadRelease,
}) {
releaseVersion(release);
const directory = await mkdtemp(join(tmpdir(), 'vp-release-size-'));
try {
loadRelease ??= createReleaseLoader(release, directory);
const rows = [];
for (const artifact of binaryArtifacts()) {
const path = join(artifactDirectory, artifact.path);
const info = await lstat(path);
if (!info.isFile() || info.size === 0 || info.size > maxBytes) {
throw new Error(`Expected a regular preview binary: ${artifact.path}`);
}
const preview = measureBinary(await readFile(path));
const baseline = await loadRelease(artifact);
rows.push({ name: artifact.name, preview, baseline: baseline && measureBinary(baseline) });
}
return renderSizeReport({ release, headSha, runUrl, rows });
} finally {
await rm(directory, { recursive: true, force: true });
}
}

export async function postSizeComment({ github, context, core, prNumber, body }) {
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: prNumber });
if (
pr.state !== 'open' ||
pr.head.sha !== context.payload.workflow_run.head_sha ||
!pr.labels.some((label) => label.name === 'preview-build')
) {
core.info('The PR changed after this preview build; skipping its size comment.');
return;
}
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: prNumber,
});
const existing = comments.find(
(comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: prNumber, body });
}
}
66 changes: 66 additions & 0 deletions .github/workflows/publish-preview-register.yml
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,72 @@ jobs:
# batch. The bridge's own WORKSPACE_PACKAGES must also allow each name.
workspace-packages: vite-plus,create-vite-plus,@voidzero-dev/vite-plus-*

# Read the triggering run's binaries as data using code from the default
# branch. Never execute a preview binary in this write-enabled job.
comment-binary-size:
name: Compare preview binaries with latest release
needs: authorize
if: needs.authorize.outputs.eligible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
actions: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .node-version
package-manager-cache: false
- name: Download preview CLI binaries
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: vp-global-cli-*
path: ${{ runner.temp }}/preview-binary-size
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Download preview NAPI binaries
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: vite-plus-native-*
path: ${{ runner.temp }}/preview-binary-size
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Download preview installers
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: vp-setup-*
path: ${{ runner.temp }}/preview-binary-size
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Compare and comment binary sizes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
ARTIFACT_DIRECTORY: ${{ runner.temp }}/preview-binary-size
PR_NUMBER: ${{ needs.authorize.outputs.pr }}
with:
script: |
const { comparePreviewBinaries, postSizeComment } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/preview-binary-size.mjs`
);
const { data: release } = await github.rest.repos.getLatestRelease(context.repo);
const run = context.payload.workflow_run;
const body = await comparePreviewBinaries({
release,
artifactDirectory: process.env.ARTIFACT_DIRECTORY,
headSha: run.head_sha,
runUrl: run.html_url,
});
await core.summary.addRaw(body).write();
await postSizeComment({
github, context, core, body,
prNumber: Number(process.env.PR_NUMBER),
});

# Sticky comment with the resolved versions and per-package-manager registry
# config. Separate job: it needs write permissions, and SR-5 keeps those away
# from the job holding id-token.
Expand Down
Loading
Loading