From d6d6b1f3b88104b7f8d146e31f9744760a6981a4 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Nov 2026 10:15:50 +0800 Subject: [PATCH 1/4] feat(deps): automate pnpm installer pin updates --- .../__tests__/upgrade-deps-pnpm.spec.ts | 144 ++++++++++++++++++ .../scripts/__tests__/upgrade-deps.spec.ts | 50 ++++++ .github/scripts/upgrade-deps-utils.ts | 88 ++++++++++- .github/scripts/upgrade-deps.ts | 20 +++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/__tests__/upgrade-deps-pnpm.spec.ts diff --git a/.github/scripts/__tests__/upgrade-deps-pnpm.spec.ts b/.github/scripts/__tests__/upgrade-deps-pnpm.spec.ts new file mode 100644 index 0000000000..2dee18d955 --- /dev/null +++ b/.github/scripts/__tests__/upgrade-deps-pnpm.spec.ts @@ -0,0 +1,144 @@ +/// + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, onTestFinished, test, vi } from 'vite-plus/test'; + +import { getPnpmInstallerIntegrity, updatePnpmInstallerPins } from '../upgrade-deps-utils.ts'; + +const digest = Buffer.alloc(64, 0xab); +const sri = `sha512-${digest.toString('base64')}`; +const integrity = `sha512.${digest.toString('hex')}`; + +test('converts the official pnpm release integrity from SRI to packageManager hex', async () => { + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ version: '10.40.2', dist: { integrity: sri } })); + vi.stubGlobal('fetch', fetchMock); + await expect(getPnpmInstallerIntegrity('10.40.2')).resolves.toBe(integrity); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith('https://registry.npmjs.org/pnpm/10.40.2'); +}); + +test.each([ + undefined, + null, + 123, + '', + `sha1-${digest.toString('base64')}`, + integrity, + 'sha512-not-base64!', + `sha512-${Buffer.alloc(32).toString('base64')}`, + `sha512-${Buffer.alloc(65).toString('base64')}`, + // The low four bits of the last base64 digit must be zero for a 64-byte digest. + `sha512-${'A'.repeat(85)}B==`, +])('rejects missing or malformed pnpm SHA-512 integrity: %s', async (value) => { + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ version: '10.40.2', dist: { integrity: value } })), + ); + await expect(getPnpmInstallerIntegrity('10.40.2')).rejects.toThrow('dist.integrity'); +}); + +test.each([ + { + response: () => new Response('Unavailable', { status: 503, statusText: 'Unavailable' }), + error: 'Failed to fetch pnpm 10.40.2: 503 Unavailable', + }, + { + response: () => Response.json({ version: '10.40.1', dist: { integrity: sri } }), + error: 'version mismatch', + }, + { response: () => Response.json(null), error: 'version mismatch' }, + { response: () => Response.json({ version: '10.40.2' }), error: 'dist.integrity' }, +])('rejects unusable pnpm release metadata: $error', async ({ response, error }) => { + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response())); + await expect(getPnpmInstallerIntegrity('10.40.2')).rejects.toThrow(error); +}); + +function createPinFixture() { + const root = mkdtempSync(join(tmpdir(), 'vite-plus-pnpm-pins-')); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const oldVersion = '10.34.7'; + const oldIntegrity = `sha512.${'01'.repeat(64)}`; + const rustVersion = `const PINNED_PNPM_VERSION: &str = "${oldVersion}";`; + const paths = [ + 'crates/vp_setup/src/install.rs', + 'packages/cli/generate-install-lockfile.ts', + 'crates/vp_pm_cli/tests/package_manager_pin_verification.rs', + ].map((file) => join(root, file)); + const contents = [ + `${rustVersion}\nconst PINNED_PNPM_INTEGRITY: &str = "${oldIntegrity}";\n`, + `export const INSTALL_PNPM_VERSION = '${oldVersion}';\n`, + `${rustVersion}\n`, + ].map((content) => `// Keep surrounding content.\n${content}`); + for (const [index, path] of paths.entries()) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents[index]); + } + const read = () => paths.map((path) => readFileSync(path, 'utf8')); + return { root, paths, contents, oldVersion, oldIntegrity, read }; +} + +test('updates all pnpm pins together and is idempotent', () => { + const fixture = createPinFixture(); + expect(updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toEqual({ + oldVersion: fixture.oldVersion, + oldIntegrity: fixture.oldIntegrity, + }); + const expected = fixture.contents.map((content) => + content.replace(fixture.oldVersion, '10.40.2').replace(fixture.oldIntegrity, integrity), + ); + expect(fixture.read()).toEqual(expected); + expect(updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toEqual({ + oldVersion: '10.40.2', + oldIntegrity: integrity, + }); + expect(fixture.read()).toEqual(expected); +}); + +test('repairs integrity even when the selected pnpm version is unchanged', () => { + const fixture = createPinFixture(); + updatePnpmInstallerPins(fixture.root, fixture.oldVersion, integrity); + expect(fixture.read()).toEqual( + fixture.contents.map((content) => content.replace(fixture.oldIntegrity, integrity)), + ); +}); + +test.each([ + { file: 0, pin: 'PINNED_PNPM_VERSION' }, + { file: 0, pin: 'PINNED_PNPM_INTEGRITY' }, + { file: 1, pin: 'INSTALL_PNPM_VERSION' }, + { file: 2, pin: 'PINNED_PNPM_VERSION' }, +])('fails before writing when $pin in file $file is missing or duplicated', ({ file, pin }) => { + const fixture = createPinFixture(); + for (const content of [ + fixture.contents[file].replace(pin, 'RENAMED_PIN'), + fixture.contents[file] + fixture.contents[file], + ]) { + writeFileSync(fixture.paths[file], content); + const before = fixture.read(); + expect(() => updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toThrow( + 'Expected exactly one', + ); + expect(fixture.read()).toEqual(before); + } +}); + +test.each([1, 2])('fails before writing when pnpm version copy %s drifts', (file) => { + const fixture = createPinFixture(); + writeFileSync(fixture.paths[file], fixture.contents[file].replace(fixture.oldVersion, '10.34.8')); + const before = fixture.read(); + expect(() => updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toThrow('out of sync'); + expect(fixture.read()).toEqual(before); +}); 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..5d1661d565 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 { @@ -542,6 +552,7 @@ async function upgradeDependencies(): Promise { const [ nodeVersion, + pnpmInstaller, vitestVersion, tsdownVersion, lightningcssVersion, @@ -556,6 +567,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 +584,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 +599,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, From 702e506498febc505da43d1ded60d4858b1ad42c Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Nov 2026 10:22:19 +0800 Subject: [PATCH 2/4] chore: skip new unit tests for automation scripts --- .../__tests__/upgrade-deps-pnpm.spec.ts | 144 ------------------ AGENTS.md | 6 +- 2 files changed, 5 insertions(+), 145 deletions(-) delete mode 100644 .github/scripts/__tests__/upgrade-deps-pnpm.spec.ts diff --git a/.github/scripts/__tests__/upgrade-deps-pnpm.spec.ts b/.github/scripts/__tests__/upgrade-deps-pnpm.spec.ts deleted file mode 100644 index 2dee18d955..0000000000 --- a/.github/scripts/__tests__/upgrade-deps-pnpm.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -/// - -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import { expect, onTestFinished, test, vi } from 'vite-plus/test'; - -import { getPnpmInstallerIntegrity, updatePnpmInstallerPins } from '../upgrade-deps-utils.ts'; - -const digest = Buffer.alloc(64, 0xab); -const sri = `sha512-${digest.toString('base64')}`; -const integrity = `sha512.${digest.toString('hex')}`; - -test('converts the official pnpm release integrity from SRI to packageManager hex', async () => { - onTestFinished(() => { - vi.unstubAllGlobals(); - }); - const fetchMock = vi - .fn() - .mockResolvedValue(Response.json({ version: '10.40.2', dist: { integrity: sri } })); - vi.stubGlobal('fetch', fetchMock); - await expect(getPnpmInstallerIntegrity('10.40.2')).resolves.toBe(integrity); - expect(fetchMock).toHaveBeenCalledExactlyOnceWith('https://registry.npmjs.org/pnpm/10.40.2'); -}); - -test.each([ - undefined, - null, - 123, - '', - `sha1-${digest.toString('base64')}`, - integrity, - 'sha512-not-base64!', - `sha512-${Buffer.alloc(32).toString('base64')}`, - `sha512-${Buffer.alloc(65).toString('base64')}`, - // The low four bits of the last base64 digit must be zero for a 64-byte digest. - `sha512-${'A'.repeat(85)}B==`, -])('rejects missing or malformed pnpm SHA-512 integrity: %s', async (value) => { - onTestFinished(() => { - vi.unstubAllGlobals(); - }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue(Response.json({ version: '10.40.2', dist: { integrity: value } })), - ); - await expect(getPnpmInstallerIntegrity('10.40.2')).rejects.toThrow('dist.integrity'); -}); - -test.each([ - { - response: () => new Response('Unavailable', { status: 503, statusText: 'Unavailable' }), - error: 'Failed to fetch pnpm 10.40.2: 503 Unavailable', - }, - { - response: () => Response.json({ version: '10.40.1', dist: { integrity: sri } }), - error: 'version mismatch', - }, - { response: () => Response.json(null), error: 'version mismatch' }, - { response: () => Response.json({ version: '10.40.2' }), error: 'dist.integrity' }, -])('rejects unusable pnpm release metadata: $error', async ({ response, error }) => { - onTestFinished(() => { - vi.unstubAllGlobals(); - }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response())); - await expect(getPnpmInstallerIntegrity('10.40.2')).rejects.toThrow(error); -}); - -function createPinFixture() { - const root = mkdtempSync(join(tmpdir(), 'vite-plus-pnpm-pins-')); - onTestFinished(() => rmSync(root, { recursive: true, force: true })); - const oldVersion = '10.34.7'; - const oldIntegrity = `sha512.${'01'.repeat(64)}`; - const rustVersion = `const PINNED_PNPM_VERSION: &str = "${oldVersion}";`; - const paths = [ - 'crates/vp_setup/src/install.rs', - 'packages/cli/generate-install-lockfile.ts', - 'crates/vp_pm_cli/tests/package_manager_pin_verification.rs', - ].map((file) => join(root, file)); - const contents = [ - `${rustVersion}\nconst PINNED_PNPM_INTEGRITY: &str = "${oldIntegrity}";\n`, - `export const INSTALL_PNPM_VERSION = '${oldVersion}';\n`, - `${rustVersion}\n`, - ].map((content) => `// Keep surrounding content.\n${content}`); - for (const [index, path] of paths.entries()) { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, contents[index]); - } - const read = () => paths.map((path) => readFileSync(path, 'utf8')); - return { root, paths, contents, oldVersion, oldIntegrity, read }; -} - -test('updates all pnpm pins together and is idempotent', () => { - const fixture = createPinFixture(); - expect(updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toEqual({ - oldVersion: fixture.oldVersion, - oldIntegrity: fixture.oldIntegrity, - }); - const expected = fixture.contents.map((content) => - content.replace(fixture.oldVersion, '10.40.2').replace(fixture.oldIntegrity, integrity), - ); - expect(fixture.read()).toEqual(expected); - expect(updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toEqual({ - oldVersion: '10.40.2', - oldIntegrity: integrity, - }); - expect(fixture.read()).toEqual(expected); -}); - -test('repairs integrity even when the selected pnpm version is unchanged', () => { - const fixture = createPinFixture(); - updatePnpmInstallerPins(fixture.root, fixture.oldVersion, integrity); - expect(fixture.read()).toEqual( - fixture.contents.map((content) => content.replace(fixture.oldIntegrity, integrity)), - ); -}); - -test.each([ - { file: 0, pin: 'PINNED_PNPM_VERSION' }, - { file: 0, pin: 'PINNED_PNPM_INTEGRITY' }, - { file: 1, pin: 'INSTALL_PNPM_VERSION' }, - { file: 2, pin: 'PINNED_PNPM_VERSION' }, -])('fails before writing when $pin in file $file is missing or duplicated', ({ file, pin }) => { - const fixture = createPinFixture(); - for (const content of [ - fixture.contents[file].replace(pin, 'RENAMED_PIN'), - fixture.contents[file] + fixture.contents[file], - ]) { - writeFileSync(fixture.paths[file], content); - const before = fixture.read(); - expect(() => updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toThrow( - 'Expected exactly one', - ); - expect(fixture.read()).toEqual(before); - } -}); - -test.each([1, 2])('fails before writing when pnpm version copy %s drifts', (file) => { - const fixture = createPinFixture(); - writeFileSync(fixture.paths[file], fixture.contents[file].replace(fixture.oldVersion, '10.34.8')); - const before = fixture.read(); - expect(() => updatePnpmInstallerPins(fixture.root, '10.40.2', integrity)).toThrow('out of sync'); - expect(fixture.read()).toEqual(before); -}); 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. From d66914d123bbbb1a249bd448f8bd7115676ef8f4 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Nov 2026 11:22:52 +0800 Subject: [PATCH 3/4] chore(deps): omit installer integrity from PR summaries --- .github/scripts/upgrade-deps.ts | 8 ++++++-- .github/workflows/upgrade-deps.yml | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 5d1661d565..06a0302111 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -477,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) { diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 29e8ae5e56..4334ad66d0 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -406,6 +406,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 From 0cbfb869e6661940e13222850b1e508c0b2aa643 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Nov 2026 11:39:26 +0800 Subject: [PATCH 4/4] fix(ci): clear CMake configuration before final validation --- .github/workflows/upgrade-deps.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 4334ad66d0..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