feat(migrate): upgrade existing Vite+ projects across versions #2829
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |