Skip to content

feat(migrate): upgrade existing Vite+ projects across versions #2828

feat(migrate): upgrade existing Vite+ projects across versions

feat(migrate): upgrade existing Vite+ projects across versions #2828

name: Test vp create
permissions: {}
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'packages/cli/src/create/**'
- 'packages/cli/templates/**'
- 'packages/cli/src/migration/**'
- '.github/workflows/test-vp-create.yml'
pull_request:
types: [opened, synchronize, labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.ref_name != 'main' }}
defaults:
run:
shell: bash
jobs:
detect-changes:
runs-on: namespace-profile-linux-x64-default
permissions:
contents: read
pull-requests: read
outputs:
related-files-changed: ${{ steps.filter.outputs.related-files }}
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
related-files:
- 'packages/cli/src/create/**'
- 'packages/cli/templates/**'
- 'packages/cli/src/migration/**'
- .github/workflows/test-vp-create.yml
download-previous-rolldown-binaries:
needs: detect-changes
runs-on: namespace-profile-linux-x64-default
# Run if: not a PR, OR PR has 'test: create-e2e' label, OR PR is from the deps/upstream-update or a renovate/** dependency branch, OR create-related files changed
if: >-
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'test: create-e2e') ||
github.head_ref == 'deps/upstream-update' ||
startsWith(github.head_ref, 'renovate/') ||
needs.detect-changes.outputs.related-files-changed == 'true'
permissions:
contents: read
packages: read
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: ./.github/actions/download-rolldown-binaries
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
build:
name: Build vite-plus packages
runs-on: namespace-profile-linux-x64-default
permissions:
contents: read
packages: read
needs:
- download-previous-rolldown-binaries
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: ./.github/actions/clone
- uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24)
with:
save-cache: ${{ github.ref_name == 'main' }}
cache-key: create-e2e-build
- uses: oxc-project/setup-node@4c588e9266bd930b6ddc34307df0659ed511d187 # v1.3.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rolldown-binaries
path: ./rolldown/packages/rolldown/src
merge-multiple: true
- name: Build with upstream
uses: ./.github/actions/build-upstream
with:
target: x86_64-unknown-linux-gnu
- name: Pack packages into tgz
run: |
mkdir -p tmp/tgz
# Every tgz consumer below references fixed `*-0.0.0.tgz` filenames.
# A release commit can leave `packages/{core,cli}` at a published
# version (e.g. 0.1.22), which would make `pnpm pack` emit
# `*-0.1.22.tgz` instead. Pin both to 0.0.0 so the names stay stable.
(cd packages/core && npm pkg set version=0.0.0)
(cd packages/cli && npm pkg set version=0.0.0)
cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
# Bun is uniquely strict about peer-dep resolution:
# 1. It checks the *resolved target's* package name and version
# against the peer range (vitest 4.1.9 declares peer
# `vite ^6 || ^7 || ^8`).
# 2. A file: override pointing at the vite-plus-core tgz fails
# both the name check (target is `@voidzero-dev/vite-plus-core`,
# not `vite`) and the version check (0.0.0 is outside `^6|^7|^8`).
# pnpm/npm/yarn don't enforce either, and using the same core tgz as
# the file: target for both `vite` and `@voidzero-dev/vite-plus-core`
# is the only configuration they install cleanly. See
# https://github.com/oven-sh/bun/issues/8406.
#
# Generate a sibling vite-7.99.0.tgz: a copy of the core tgz with
# `package.json#name` rewritten to "vite" and `version` to 7.99.0.
# Only the bun matrix entry below points its vite override at this
# alias tgz; pnpm/npm/yarn keep pointing at the real core tgz so
# pnpm's workspace resolver doesn't trip on a "vite@<version>"
# registry lookup (the renamed tgz makes pnpm register the dep as
# vite@7.99.0 and then probe npmjs.org to validate the version).
pnpm exec tool repack-vite-tgz tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz tmp/tgz/vite-7.99.0.tgz vite 7.99.0
# Copy vp binary for test jobs
cp target/x86_64-unknown-linux-gnu/release/vp tmp/tgz/vp
ls -la tmp/tgz
- name: Upload tgz artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vite-plus-packages
path: tmp/tgz/
retention-days: 1
test-vp-create:
name: vp create ${{ matrix.template.name }} (${{ matrix.package-manager }})
runs-on: namespace-profile-linux-x64-default
permissions:
contents: read
needs:
- build
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
template:
- name: monorepo
create-args: vite:monorepo --directory test-project
template-args: ''
verify-command: vp run ready
verify-migration: 'false'
- name: application
create-args: vite:application --directory test-project
template-args: '-- --template vanilla-ts'
verify-command: vp run build
verify-migration: 'false'
- name: library
create-args: vite:library --directory test-project
template-args: ''
verify-command: |
vp run build
vp run test
verify-migration: 'false'
# Remote template that ships ESLint (+ an eslint.config.js importing
# @eslint/js etc.). Exercises the migrate-before-rewrite reorder in
# `vp create`: after scaffold, ESLint → oxlint and Prettier → oxfmt
# run before the vite-plus rewrite so `.oxlintrc` / `.oxfmtrc` get
# merged into vite.config.ts.
- name: remote-vite-react-ts
create-args: vite@9.0.5
template-args: '-- test-project --template react-ts'
verify-command: vp run build
verify-migration: 'true'
package-manager:
- pnpm
- npm
- yarn
- bun
env:
# Bun's strict peer check requires the `vite` override target's tgz to be
# named "vite" with a version satisfying vitest's `peer vite ^6 || ^7 || ^8`.
# The bun matrix entry uses the masquerade tgz (vite-7.99.0.tgz). pnpm/npm/yarn
# point at the real core tgz — anything else trips a registry lookup for
# vite@<version> when sub-package and override targets are both file: tgz aliases.
VITE_OVERRIDE_TGZ: ${{ matrix.package-manager == 'bun' && 'vite-7.99.0.tgz' || 'voidzero-dev-vite-plus-core-0.0.0.tgz' }}
VP_VERSION: 'file:${{ github.workspace }}/tmp/tgz/vite-plus-0.0.0.tgz'
# Force full dependency rewriting so the library template's existing
# vite-plus dep gets overridden with the local tgz
VP_FORCE_MIGRATE: '1'
# yarn 4 quarantines packages published within `npmMinimalAgeGate`
# (default 1440 min / 24h). When an oxlint bump landed <24h ago, the
# migration step's `vp dlx @oxlint/migrate@<bundled oxlint>` fails with
# `YN0016 ... are quarantined`. The migrate tool is version-pinned to the
# bundled oxlint, so disable the gate for this test (no-op for npm/pnpm/bun).
YARN_NPM_MINIMAL_AGE_GATE: '0'
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Download vite-plus packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: vite-plus-packages
path: tmp/tgz
- name: Install vp CLI
run: |
mkdir -p target/release
cp tmp/tgz/vp target/release/vp
chmod +x target/release/vp
node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-0.0.0.tgz
echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH
- name: Verify vp installation
run: |
which vp
vp --version
- name: Run vp create ${{ matrix.template.name }} with ${{ matrix.package-manager }}
working-directory: ${{ runner.temp }}
env:
VP_OVERRIDE_PACKAGES: '{"vite":"file:${{ github.workspace }}/tmp/tgz/${{ env.VITE_OVERRIDE_TGZ }}","@voidzero-dev/vite-plus-core":"file:${{ github.workspace }}/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz","vitest":"4.1.9","@vitest/expect":"4.1.9","@vitest/runner":"4.1.9","@vitest/snapshot":"4.1.9","@vitest/spy":"4.1.9","@vitest/utils":"4.1.9","@vitest/mocker":"4.1.9","@vitest/pretty-format":"4.1.9","@vitest/coverage-v8":"4.1.9","@vitest/coverage-istanbul":"4.1.9"}'
run: |
vp create ${{ matrix.template.create-args }} \
--no-interactive \
--no-agent \
--package-manager ${{ matrix.package-manager }} \
${{ matrix.template.template-args }}
- name: Verify project structure
working-directory: ${{ runner.temp }}/test-project
run: |
# package.json must exist
test -f package.json
echo "✓ package.json exists"
cat package.json
# List all files for debugging
echo "--- Project root files ---"
ls -la
# Check correct lockfile exists
case "${{ matrix.package-manager }}" in
pnpm)
test -f pnpm-lock.yaml
echo "✓ pnpm-lock.yaml exists"
;;
npm)
test -f package-lock.json
echo "✓ package-lock.json exists"
;;
yarn)
test -f yarn.lock
echo "✓ yarn.lock exists"
;;
bun)
if [ -f bun.lock ]; then
echo "✓ bun.lock exists"
elif [ -f bun.lockb ]; then
echo "✓ bun.lockb exists"
else
echo "✗ No bun lockfile found"
exit 1
fi
;;
esac
# node_modules must exist (vp install ran successfully)
test -d node_modules
echo "✓ node_modules exists"
# Monorepo-specific checks
if [ "${{ matrix.template.name }}" = "monorepo" ]; then
test -d apps/website
echo "✓ apps/website exists"
test -d packages/utils
echo "✓ packages/utils exists"
case "${{ matrix.package-manager }}" in
pnpm)
test -f pnpm-workspace.yaml
echo "✓ pnpm-workspace.yaml exists"
;;
yarn)
test -f .yarnrc.yml
echo "✓ .yarnrc.yml exists"
;;
esac
fi
- name: Verify single dependency instances (pnpm only)
if: matrix.package-manager == 'pnpm'
working-directory: ${{ runner.temp }}/test-project
run: |
# The `vite` override must dedupe vite-plus / vite / vitest to a single
# instance each. When a peer variation splits the graph (e.g. an upstream
# `vite` auto-installed to satisfy vitest's peer in a package without a
# direct `vite` dep), `vp why` reports multiple instances. Detection:
# - vite-plus / vitest: a split prints "Found 1 version, N instances of <pkg>".
# - vite: it is overridden to @voidzero-dev/vite-plus-core, so a clean tree
# only summarises that package; a standalone upstream copy adds a
# "Found <n> version(s) of vite" line.
# Regression guard for voidzero-dev/vite-plus#1932 (the pnpm dedupe fix).
# `-r` checks every workspace package, not just the root importer, so a
# duplicate confined to a sub-package (apps/website, packages/utils) is
# still caught.
fail=0
check() {
pkg="$1"; pattern="$2"
out=$(vp why -r "$pkg" 2>&1)
found=$(echo "$out" | grep '^Found' || true)
echo "[$pkg]"; echo "$found"
if echo "$found" | grep -qE "$pattern"; then
echo "✗ $pkg is not a single instance (override did not dedupe under pnpm)"
echo "----- full \`vp why -r $pkg\` output -----"
echo "$out"
echo "---------------------------------------"
fail=1
else
echo "✓ $pkg single instance"
fi
}
check vite-plus 'instances of vite-plus'
check vitest 'instances of vitest'
check vite 'of vite$'
if [ "$fail" -ne 0 ]; then
echo "Expected vite-plus, vite, and vitest to each resolve to a single instance."
exit 1
fi
echo "✓ vite-plus, vite, vitest are all single instances"
- name: Verify local tgz packages installed
working-directory: ${{ runner.temp }}/test-project
run: |
node -e "
const path = require('path');
const pkg = require(path.resolve('node_modules/vite-plus/package.json'));
if (pkg.version !== '0.0.0') {
console.error('Expected vite-plus@0.0.0, got ' + pkg.version);
process.exit(1);
}
console.log('✓ vite-plus@' + pkg.version + ' installed correctly');
"
- name: Verify monorepo sub-package deps
if: matrix.template.name == 'monorepo'
working-directory: ${{ runner.temp }}/test-project
env:
PACKAGE_MANAGER: ${{ matrix.package-manager }}
run: |
# Issue 1: packages/utils inherits `vite-plus: ^x.y.z` from the
# library template. In catalog-supporting monorepos (pnpm/yarn/bun)
# the migrator must normalize it so siblings don't drift.
# Issue 2: apps/website is scaffolded by create-vite which ships
# `vite` (and sometimes `vitest`) in devDependencies. After
# migration the scripts are rewritten to `vp ...` and `vite-plus`
# brings the runtime in transitively. For npm/yarn/bun those keys
# are dropped (the root overrides/resolutions redirect the
# transitive/peer vite to @voidzero-dev/vite-plus-core regardless).
# For pnpm the aliased `vite` is kept on purpose: pnpm only surfaces
# the pnpm-workspace.yaml `overrides.vite: catalog:` entry through a
# package that directly depends on `vite`, so dropping it would make
# `vp why vite` report upstream vite and the override look ineffective.
node -e "
const fs = require('fs');
const pm = process.env.PACKAGE_MANAGER;
for (const f of ['apps/website/package.json', 'packages/utils/package.json']) {
if (!fs.existsSync(f)) {
console.error('✗ expected ' + f + ' to exist after vp create vite:monorepo');
process.exit(1);
}
}
const app = JSON.parse(fs.readFileSync('apps/website/package.json', 'utf8'));
const utils = JSON.parse(fs.readFileSync('packages/utils/package.json', 'utf8'));
const appDev = app.devDependencies || {};
const utilsDev = utils.devDependencies || {};
if (pm === 'pnpm') {
if (!appDev['vite']) {
console.error('✗ pnpm apps/website should keep aliased vite so the workspace override stays effective');
process.exit(1);
}
console.log('✓ pnpm apps/website keeps aliased vite');
} else {
for (const name of ['vite', 'vitest']) {
if (appDev[name]) {
console.error('✗ apps/website devDependencies still has ' + name + ': ' + appDev[name]);
process.exit(1);
}
}
console.log('✓ apps/website devDependencies has no vite/vitest');
}
if (!appDev['vite-plus']) {
console.error('✗ apps/website missing vite-plus devDependency');
process.exit(1);
}
if (!utilsDev['vite-plus']) {
console.error('✗ packages/utils missing vite-plus devDependency');
process.exit(1);
}
if (pm !== 'npm' && appDev['vite-plus'] !== utilsDev['vite-plus']) {
console.error('✗ vite-plus spec drift: apps/website=' + appDev['vite-plus'] + ' packages/utils=' + utilsDev['vite-plus']);
process.exit(1);
}
console.log('✓ vite-plus consistent across sub-packages: app=' + appDev['vite-plus'] + ' utils=' + utilsDev['vite-plus']);
"
- name: Verify ESLint/Prettier auto-migration
if: matrix.template.verify-migration == 'true'
working-directory: ${{ runner.temp }}/test-project
run: |
# eslint.config.js must be gone (migration deleted it)
test ! -f eslint.config.js
echo "✓ eslint.config.js removed"
# .oxlintrc.json must NOT be loose on disk — it was merged into
# vite.config.ts by the rewrite step that runs after migration.
test ! -f .oxlintrc.json
echo "✓ .oxlintrc.json merged into vite.config.ts"
# vite.config.ts must contain the merged oxlint config.
grep -q '^[[:space:]]*lint:' vite.config.ts
echo "✓ vite.config.ts has merged lint section"
# package.json: eslint devDep removed, vite-plus present, lint script rewritten.
node -e "
const pkg = require('./package.json');
if (pkg.devDependencies && pkg.devDependencies.eslint) {
console.error('✗ eslint devDependency should have been removed');
process.exit(1);
}
if (!pkg.devDependencies || !pkg.devDependencies['vite-plus']) {
console.error('✗ vite-plus devDependency missing');
process.exit(1);
}
if (!pkg.scripts || !pkg.scripts.lint || !pkg.scripts.lint.includes('vp lint')) {
console.error('✗ lint script should invoke vp lint, got: ' + (pkg.scripts && pkg.scripts.lint));
process.exit(1);
}
console.log('✓ package.json migrated (eslint gone, vite-plus added, lint script rewritten)');
"
- name: Run vp check
working-directory: ${{ runner.temp }}/test-project
run: vp check
- name: Verify project builds
working-directory: ${{ runner.temp }}/test-project
run: ${{ matrix.template.verify-command }}
- name: Verify cache (monorepo only)
if: matrix.template.name == 'monorepo'
working-directory: ${{ runner.temp }}/test-project
run: |
# Under npm, `vp run ready` reaches 100% cache hit only on the
# third invocation (#1638): vite-task's directory-listing
# fingerprint and fspy read/write tracking surface false-positive
# misses on run #2 because `packages/utils/node_modules/` is born
# during run #1. pnpm/yarn/bun pre-create per-package
# `node_modules/` at install time and reach 100% on run #2.
# The preceding `Verify project builds` step already invoked
# `vp run ready` once (verify-command for monorepo), so one
# extra warm-up here is enough under npm.
if [ "${{ matrix.package-manager }}" = "npm" ]; then
vp run ready >/dev/null 2>&1
fi
output=$(vp run ready 2>&1)
echo "$output"
if ! echo "$output" | grep -q 'cache hit (100%)'; then
echo "✗ Expected 100% cache hit"
echo "--- vp run --last-details (cache-miss diagnostics) ---"
vp run --last-details || true
exit 1
fi
echo "✓ 100% cache hit verified"