fix: up-to-date check on local tarball with peers (#9807)

* test: add allProjectsAreUpToDate test for local tarball with peers

* fix: up-to-date check on local tarball with peers
This commit is contained in:
Brandon Cheng
2025-07-30 05:43:12 -04:00
committed by GitHub
parent e9b589cd5f
commit 19b1880526
3 changed files with 140 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/lockfile.verification": patch
pnpm: patch
---
Fix a bug causing `pnpm install` to incorrectly assume the lockfile is up to date after changing a local tarball that has peers dependencies.

View File

@@ -1,5 +1,5 @@
import { getTarballIntegrity } from '@pnpm/crypto.hash'
import { refToRelative } from '@pnpm/dependency-path'
import * as dp from '@pnpm/dependency-path'
import {
type ProjectSnapshot,
type PackageSnapshots,
@@ -53,11 +53,34 @@ export async function localTarballDepsAreUpToDate (
return pEvery(
Object.entries(lockfileDeps),
async ([depName, ref]) => {
if (!refIsLocalTarball(ref)) {
if (!ref.startsWith('file:')) {
return true
}
// The tarball ref can contain peers. Ex: file:bar.tgz(react@19.1.0)
//
// Trim out the peer suffix version to get a path to the local tarball.
//
// - file:bar.tgz → file:bar.tgz
// - file:bar.tgz(react@19.1.0) → file:bar.tgz
//
const depPath = dp.refToRelative(ref, depName)
if (depPath == null) {
return true
}
const parsed = dp.parse(depPath)
const tarballRefWithoutPeersSuffix = parsed.nonSemverVersion
// Tarball refs aren't "semver" versions. If the nonSemverVersion field
// is empty, this isn't a depPath for a tarball.
if (tarballRefWithoutPeersSuffix == null) {
return true
}
if (!refIsLocalTarball(tarballRefWithoutPeersSuffix)) {
return true
}
const depPath = refToRelative(ref, depName)
const packageSnapshot = depPath != null ? lockfilePackages?.[depPath] : null
// If there's no snapshot for this local tarball yet, the project is out
@@ -67,7 +90,8 @@ export async function localTarballDepsAreUpToDate (
return false
}
const filePath = path.join(lockfileDir, ref.slice('file:'.length))
const fileRelativePath = tarballRefWithoutPeersSuffix.slice('file:'.length)
const filePath = path.join(lockfileDir, fileRelativePath)
const fileIntegrityPromise = fileIntegrityCache.get(filePath) ?? getTarballIntegrity(filePath)
if (!fileIntegrityCache.has(filePath)) {

View File

@@ -591,6 +591,112 @@ describe('local tgz file dependency', () => {
})
})
// Regression tests for https://github.com/pnpm/pnpm/pull/9807.
describe('local tgz file dependency with peer dependencies', () => {
beforeEach(async () => {
prepareEmpty()
})
const projects = [
{
id: 'bar' as ProjectId,
manifest: {
dependencies: {
'@pnpm.e2e/foo': '1.0.0',
'local-tarball': 'file:local-tarball.tar',
},
},
rootDir: 'bar' as ProjectRootDir,
},
]
const wantedLockfile: LockfileObject = {
lockfileVersion: LOCKFILE_VERSION,
importers: {
['bar' as ProjectId]: {
dependencies: {
'@pnpm.e2e/foo': '1.0.0',
'local-tarball': 'file:local-tarball.tar(@pnpm.e2e/foo@1.0.0)',
},
specifiers: {
'@pnpm.e2e/foo': '1.0.0',
'local-tarball': 'file:local-tarball.tar',
},
},
},
packages: {
['@pnpm.e2e/foo@1.0.0' as DepPath]: {
resolution: {
integrity: 'sha512-/HITDx7DEbvGeznQ5aq9qK5rn7YlVGST+fW2cQ0QAoO7/kVn/QJkN7VYAB0nvRIFkFsaAMJZ61zB8pJo9Fonng==',
},
version: '1.0.0',
},
['local-tarball@file:local-tarball.tar(@pnpm.e2e/foo@1.0.0)' as DepPath]: {
resolution: {
integrity: 'sha512-dVXphRGPXHhIt6CKeest8Tkbva4FatStRw4PZbJ4zFszWppqAkZureR6mOF0mT/9Drr5wZ5y9tPaqcmsf/a5cw==',
tarball: 'file:local-tarball.tar',
},
version: '1.0.0',
dependencies: {
'@pnpm.e2e/foo': '1.0.0',
},
},
},
}
const options = {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile,
workspacePackages,
lockfileDir: process.cwd(),
}
test('allProjectsAreUpToDate(): returns true if local file not changed', async () => {
expect.hasAssertions()
const pack = tar.pack()
pack.entry({ name: 'package.json', mtime: new Date('1970-01-01T00:00:00.000Z') }, JSON.stringify({
name: 'local-tarball',
version: '1.0.0',
peerDependencies: {
'@pnpm.e2e/foo': '1.0.0',
},
}))
pack.finalize()
await pipeline(pack, createWriteStream('./local-tarball.tar'))
// Make sure the test is set up correctly and the local-tarball.tar created
// above has the expected integrity hash.
await expect(getTarballIntegrity('./local-tarball.tar')).resolves.toEqual('sha512-dVXphRGPXHhIt6CKeest8Tkbva4FatStRw4PZbJ4zFszWppqAkZureR6mOF0mT/9Drr5wZ5y9tPaqcmsf/a5cw==')
const lockfileDir = process.cwd()
expect(await allProjectsAreUpToDate(projects, { ...options, lockfileDir })).toBeTruthy()
})
test('allProjectsAreUpToDate(): returns false if local file has changed', async () => {
expect.hasAssertions()
const pack = tar.pack()
pack.entry({ name: 'package.json', mtime: new Date('2000-01-01T00:00:00') }, JSON.stringify({
name: 'local-tarball',
// Incrementing the version from 1.0.0 to 2.0.0.
version: '2.0.0',
peerDependencies: {
'@pnpm.e2e/foo': '1.0.0',
},
}))
pack.finalize()
await pipeline(pack, createWriteStream('./local-tarball.tar'))
const lockfileDir = process.cwd()
expect(await allProjectsAreUpToDate(projects, { ...options, lockfileDir })).toBeFalsy()
})
})
test('allProjectsAreUpToDate(): returns true if workspace dependency\'s version type is tag', async () => {
const projects = [
{