diff --git a/.github/scripts/__tests__/upgrade-deps.spec.ts b/.github/scripts/__tests__/upgrade-deps.spec.ts index 75b7567350..444c18759e 100644 --- a/.github/scripts/__tests__/upgrade-deps.spec.ts +++ b/.github/scripts/__tests__/upgrade-deps.spec.ts @@ -37,12 +37,26 @@ test.each([false, true])( 'pnpm-workspace.yaml', 'packages/tools/.upstream-versions.json', 'packages/cli/src/utils/constants.ts', + 'crates/vp_setup/src/install.rs', + 'packages/cli/generate-install-lockfile.ts', + 'crates/vp_pm_cli/tests/package_manager_pin_verification.rs', ]) { mkdirSync(dirname(join(tempDir, file)), { recursive: true }); copyFileSync(join(root, file), join(tempDir, file)); } const nodeVersionPath = join(tempDir, '.node-version'); writeFileSync(nodeVersionPath, '24.9.0\n'); + const packageJsonPath = join(tempDir, 'package.json'); + writeJson(packageJsonPath, { packageManager: 'pnpm@12.11.2' }); + const packageJsonBefore = readFileSync(packageJsonPath, 'utf8'); + const installerPath = join(tempDir, 'crates/vp_setup/src/install.rs'); + const oldPnpmVersion = /const PINNED_PNPM_VERSION: &str = "([^"]+)";/.exec( + readFileSync(installerPath, 'utf8'), + )![1]; + const oldPnpmIntegrity = /const PINNED_PNPM_INTEGRITY: &str = "([^"]+)";/.exec( + readFileSync(installerPath, 'utf8'), + )![1]; + const pnpmDigest = Buffer.alloc(64, 0xab); const workspacePath = join(tempDir, 'pnpm-workspace.yaml'); writeFileSync( workspacePath, @@ -78,6 +92,18 @@ test.each([false, true])( if (url === 'https://registry.npmjs.org/vitest') { return Response.json({ versions: { '4.1.11': {}, '5.0.0': {} } }); } + if (url === 'https://registry.npmjs.org/pnpm') { + return Response.json({ + 'dist-tags': { latest: '12.11.2' }, + versions: { '12.11.2': {}, '10.99.0-beta.1': {}, '10.40.2': {}, '10.9.0': {} }, + }); + } + if (url === 'https://registry.npmjs.org/pnpm/10.40.2') { + return Response.json({ + version: '10.40.2', + dist: { integrity: `sha512-${pnpmDigest.toString('base64')}` }, + }); + } if (url === 'https://registry.npmjs.org/@tsdown/css/latest') { return Response.json({ dependencies: { lightningcss: '^1.33.0' } }); } @@ -144,8 +170,29 @@ test.each([false, true])( expect(readFileSync(workspacePath, 'utf8')).toContain('\n oxlint: =1.88.0\n'); expect(readFileSync(workspacePath, 'utf8')).toContain('\n oxlint-tsgolint: =7.0.2004\n'); expect(readFileSync(nodeVersionPath, 'utf8')).toBe('24.10.2\n'); + expect(readFileSync(installerPath, 'utf8')).toContain( + 'const PINNED_PNPM_VERSION: &str = "10.40.2";', + ); + expect(readFileSync(installerPath, 'utf8')).toContain( + `const PINNED_PNPM_INTEGRITY: &str = "sha512.${pnpmDigest.toString('hex')}";`, + ); + expect( + readFileSync(join(tempDir, 'packages/cli/generate-install-lockfile.ts'), 'utf8'), + ).toContain("export const INSTALL_PNPM_VERSION = '10.40.2';"); + expect( + readFileSync( + join(tempDir, 'crates/vp_pm_cli/tests/package_manager_pin_verification.rs'), + 'utf8', + ), + ).toContain('const PINNED_PNPM_VERSION: &str = "10.40.2";'); + expect(readFileSync(packageJsonPath, 'utf8')).toBe(packageJsonBefore); const versions = JSON.parse(readFileSync(join(metaDir, 'versions.json'), 'utf8')); expect(versions['Node.js']).toEqual({ old: '24.9.0', new: '24.10.2' }); + expect(versions['pnpm (installer)']).toEqual({ old: oldPnpmVersion, new: '10.40.2' }); + expect(versions['pnpm installer integrity']).toEqual({ + old: oldPnpmIntegrity, + new: `sha512.${pnpmDigest.toString('hex')}`, + }); expect(versions['lint-staged']).toEqual({ old: '16.2.6', new: '17.5.1' }); expect(versions['@oxlint/plugins'].new).toBe('1.88.0'); expect(versions['@oxlint/plugins'].new).toBe(versions.oxlint.new); @@ -166,6 +213,9 @@ test.each([false, true])( expect(readFileSync(join(metaDir, 'pr-body.md'), 'utf8')).toContain( '| `lint-staged` | `16.2.6` | `17.5.1` |', ); + expect(readFileSync(join(metaDir, 'pr-body.md'), 'utf8')).toContain( + `| \`pnpm (installer)\` | \`${oldPnpmVersion}\` | \`10.40.2\` |`, + ); }, ); diff --git a/.github/scripts/upgrade-deps-utils.ts b/.github/scripts/upgrade-deps-utils.ts index 78521a9c2e..b4fd178b4a 100644 --- a/.github/scripts/upgrade-deps-utils.ts +++ b/.github/scripts/upgrade-deps-utils.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { join } from 'node:path'; @@ -104,3 +104,89 @@ export async function getLatestNodeVersion(major: number): Promise { } return version; } + +export async function getPnpmInstallerIntegrity(version: string): Promise { + // Use the official registry regardless of the workspace's npm configuration. + const response = await fetch(`https://registry.npmjs.org/pnpm/${version}`); + if (!response.ok) { + throw new Error(`Failed to fetch pnpm ${version}: ${response.status} ${response.statusText}`); + } + const data = (await response.json()) as { + version?: unknown; + dist?: { integrity?: unknown }; + } | null; + if (data?.version !== version) { + throw new Error(`Invalid npm response for pnpm ${version}: version mismatch`); + } + const integrity = data.dist?.integrity; + const match = typeof integrity === 'string' && /^sha512-([A-Za-z0-9+/]{86}==)$/.exec(integrity); + if (!match) { + throw new Error(`Invalid npm response for pnpm ${version}: expected SHA-512 dist.integrity`); + } + const digest = Buffer.from(match[1], 'base64'); + // Buffer.from tolerates malformed base64, so require a canonical 64-byte digest. + if (digest.length !== 64 || digest.toString('base64') !== match[1]) { + throw new Error(`Invalid npm response for pnpm ${version}: malformed SHA-512 dist.integrity`); + } + return `sha512.${digest.toString('hex')}`; +} + +export function updatePnpmInstallerPins( + root: string, + version: string, + integrity: string, +): { oldVersion: string; oldIntegrity: string } { + const files = [ + { + path: 'crates/vp_setup/src/install.rs', + pattern: /const PINNED_PNPM_VERSION: &str = "([^"]+)";/g, + }, + { + path: 'packages/cli/generate-install-lockfile.ts', + pattern: /export const INSTALL_PNPM_VERSION = '([^']+)';/g, + }, + { + path: 'crates/vp_pm_cli/tests/package_manager_pin_verification.rs', + pattern: /const PINNED_PNPM_VERSION: &str = "([^"]+)";/g, + }, + ].map(({ path, pattern }) => { + const filePath = join(root, path); + const content = readFileSync(filePath, 'utf8'); + const matches = [...content.matchAll(pattern)]; + if (matches.length !== 1 || !parseStableVersion(matches[0][1])) { + throw new Error(`Expected exactly one stable pnpm installer version pin in ${path}`); + } + return { + filePath, + content, + oldVersion: matches[0][1], + updated: content.replace(pattern, (match: string, old: string) => + match.replace(old, version), + ), + }; + }); + const installer = files[0]; + if (files.some((file) => file.oldVersion !== installer.oldVersion)) { + throw new Error( + 'pnpm installer version pins are out of sync; update all three copies together', + ); + } + const integrityPattern = /const PINNED_PNPM_INTEGRITY: &str = "(sha512\.[a-f0-9]{128})";/g; + const integrityMatches = [...installer.content.matchAll(integrityPattern)]; + if (integrityMatches.length !== 1) { + throw new Error( + `Expected exactly one SHA-512 pnpm installer integrity pin in ${installer.filePath}`, + ); + } + const oldIntegrity = integrityMatches[0][1]; + installer.updated = installer.updated.replace(integrityPattern, (match: string) => + match.replace(oldIntegrity, integrity), + ); + // Validate every copy before writing so a stale pattern cannot leave partial pins. + for (const file of files) { + if (file.content !== file.updated) { + writeFileSync(file.filePath, file.updated); + } + } + return { oldVersion: installer.oldVersion, oldIntegrity }; +} diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 0927b3a9d2..06a0302111 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -6,6 +6,8 @@ import { findLatestStableVersionForMajor, getBundledDependencyRanges, getLatestNodeVersion, + getPnpmInstallerIntegrity, + updatePnpmInstallerPins, } from './upgrade-deps-utils.ts'; const ROOT = process.cwd(); @@ -82,6 +84,9 @@ const STABLE_SEMVER_TAG_RE = /^v?\d+\.\d+\.\d+$/; const SUPPORTED_VITEST_MAJOR = 5; // Review compatibility before advancing the shared development, CI, and installation pin. const SUPPORTED_NODE_MAJOR = 24; +// Installer pnpm is independent of package.json's development package manager. +// Review install behavior and lockfile compatibility before advancing this major. +const SUPPORTED_PNPM_INSTALLER_MAJOR = 10; const isFullSha = (s: string): boolean => /^[0-9a-f]{40}$/.test(s); @@ -201,6 +206,11 @@ async function getLatestNpmVersionForMajor(packageName: string, major: number): return version; } +async function getLatestPnpmInstaller(): Promise<{ version: string; integrity: string }> { + const version = await getLatestNpmVersionForMajor('pnpm', SUPPORTED_PNPM_INSTALLER_MAJOR); + return { version, integrity: await getPnpmInstallerIntegrity(version) }; +} + // Read a dependency range from the latest published version of `packageName`, // e.g. the `lightningcss` range that the bundled `@tsdown/css` depends on. async function getNpmDependencyRange(packageName: string, dependencyName: string): Promise { @@ -467,8 +477,12 @@ function writeMetaFiles(): void { JSON.stringify(versionsObj, null, 2) + '\n', ); - const changed = [...changes.entries()].filter(([, v]) => v.old !== v.new); - const unchanged = [...changes.entries()].filter(([, v]) => v.old === v.new); + // Keep the integrity in versions.json, but report the installer as one dependency. + const dependencies = [...changes.entries()].filter( + ([name]) => name !== 'pnpm installer integrity', + ); + const changed = dependencies.filter(([, v]) => v.old !== v.new); + const unchanged = dependencies.filter(([, v]) => v.old === v.new); const commitLines = ['feat(deps): upgrade upstream dependencies', '']; if (changed.length) { @@ -542,6 +556,7 @@ async function upgradeDependencies(): Promise { const [ nodeVersion, + pnpmInstaller, vitestVersion, tsdownVersion, lightningcssVersion, @@ -556,6 +571,7 @@ async function upgradeDependencies(): Promise { oxcParserVersion, ] = await Promise.all([ getLatestNodeVersion(SUPPORTED_NODE_MAJOR), + getLatestPnpmInstaller(), getLatestNpmVersionForMajor('vitest', SUPPORTED_VITEST_MAJOR), getLatestNpmVersion('tsdown'), // Mirror exactly what the bundled @tsdown/css depends on. @@ -572,6 +588,7 @@ async function upgradeDependencies(): Promise { ]); console.log(`Node.js: ${nodeVersion}`); + console.log(`pnpm (installer): ${pnpmInstaller.version}`); console.log(`vitest: ${vitestVersion}`); console.log(`tsdown: ${tsdownVersion}`); console.log(`lightningcss (from @tsdown/css): ${lightningcssVersion}`); @@ -586,6 +603,13 @@ async function upgradeDependencies(): Promise { console.log(`@oxc-project/types: ${oxcProjectTypesVersion}`); console.log(`oxc-parser: ${oxcParserVersion}`); + const { oldVersion, oldIntegrity } = updatePnpmInstallerPins( + ROOT, + pnpmInstaller.version, + pnpmInstaller.integrity, + ); + recordChange('pnpm (installer)', oldVersion, pnpmInstaller.version); + recordChange('pnpm installer integrity', oldIntegrity, pnpmInstaller.integrity); await updateUpstreamVersions(); await updatePnpmWorkspace({ vitest: vitestVersion, diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 29e8ae5e56..f0c763e7d4 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -326,6 +326,14 @@ jobs: run: | set -euo pipefail node packages/tools/src/check-rolldown-compat.ts + # Earlier native builds or restored caches can use a different C compiler. + # CMake can drop its install prefix when that compiler changes. Clear its + # configuration before rebuilding, while keeping compiled Rust dependencies. + target_dir="${CARGO_TARGET_DIR:-target}" + if [[ -d "$target_dir" ]]; then + find "$target_dir" -type f -name CMakeCache.txt -delete + find "$target_dir" -type d -name CMakeFiles -prune -exec rm -rf -- {} + + fi pnpm bootstrap-cli export PATH="$HOME/.vite-plus/bin:$PATH" pnpm test:unit @@ -406,6 +414,8 @@ jobs: Include the version and a short 7-char SHA in both `From` and `To` for `rolldown` and `vite`. Link each SHA label to the full commit in the official repository. Omit packages whose old and new values are equal. + Report the pnpm installer version only; omit its integrity hash from this table + and the unchanged dependency list. Keep the baseline list of unchanged dependencies after the table. 3. `## Code changes` — bullet list of the actual non-version edits, each naming the file(s) touched. If the only changes are version bumps + lockfile + generated diff --git a/AGENTS.md b/AGENTS.md index 878091e46d..5d80a38c87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,11 @@ For changes to the guidance shipped to user projects, use `packages/cli/AGENTS.m Prefer local fixtures or mock servers for tests that depend on external services. Use real network requests only when they are necessary to verify the behavior under test, and explain that need in the test. Reuse existing test infrastructure, preserve behavioral coverage, and keep mutable state isolated between cases. -For behavior changes, find nearby tests before editing and add or update coverage in the same area. Choose checks for the changed layer: +For product behavior changes, find nearby tests before editing and add or update coverage in the same area. + +For repository automation and test-support scripts (for example, `.github/scripts/`), do not add unit tests unless the user explicitly requests them. Validate changes with focused script runs and existing checks instead. + +Choose checks for the changed layer: For local CLI installation, use a fresh absolute temporary `VP_HOME` and set `VP_SELF_SETUP_NO_MODIFY_PATH=1`. Keep installation, checks, and `PATH` changes inside one subshell; remove the temporary installation on exit. Follow the [isolated installation procedure](CONTRIBUTING.md#isolated-cli-installation). Test shell setup through the snapshot runner's isolated homes.