fix(lockfile): keep non-derivable tarball URLs when lockfileIncludeTarballUrl is false (#11509)

* fix(lockfile): keep non-reconstructable tarball URLs when lockfileIncludeTarballUrl is false

`lockfile-include-tarball-url` defaults to `false`, so for the vast
majority of users the early return added by #10621 silently dropped
tarball URLs that cannot be reconstructed from registry+name+version —
breaking `pnpm install --frozen-lockfile` from an empty store on
GitHub Packages (`https://npm.pkg.github.com/download/<scope>/<name>/<version>/<hash>`),
JSR, and similar registries.

`false` now matches the historical (v10) heuristic: tarball URLs are
written when they are non-reconstructable, otherwise omitted.
`true` continues to force every tarball URL into the lockfile.

Refs #11276, #11407.

* chore: appease cspell

Replace "reconstructable" with "derivable" and avoid the cspell-flagged
"mypkg" placeholder in the new test fixture.

* docs(changeset): use camelCase setting name

* fix(lockfile): guard against missing tarball field in toLockfileResolution

`TarballResolution.tarball` is typed as required, but callers that
deserialize resolutions from external state can violate that. Return
early with just `integrity` if the tarball URL is missing instead of
asserting non-null at the use site (which previously paired a
`as string | undefined` cast with `tarball!.replaceAll(...)` —
contradictory signals that confused both readers and review tools).
This commit is contained in:
Zoltan Kochan
2026-05-07 08:14:55 +02:00
parent 36b4c83b39
commit cfa271b7ee
4 changed files with 49 additions and 12 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/lockfile.utils": patch
"pnpm": patch
---
Restored the heuristic that preserves tarball URLs in `pnpm-lock.yaml` when they cannot be derived from name+version+registry, even with the default `lockfileIncludeTarballUrl: false`. Without this, `pnpm install --frozen-lockfile` from an empty store fails with `ERR_PNPM_FETCH_404` for packages on registries that serve tarballs from a non-standard path — most notably GitHub Packages (`https://npm.pkg.github.com/download/<scope>/<name>/<version>/<hash>`) and JSR. `lockfileIncludeTarballUrl: true` continues to force the URL into the lockfile for every package [#11276](https://github.com/pnpm/pnpm/issues/11276).

View File

@@ -1464,7 +1464,11 @@ test('exclude tarball URL when lockfileIncludeTarballUrl is false', async () =>
.toBeUndefined()
})
test('exclude non-standard tarball URL when lockfileIncludeTarballUrl is false', async () => {
test('keep non-standard tarball URL when lockfileIncludeTarballUrl is false', async () => {
// Tarball URLs that cannot be reconstructed from name+version+registry must
// remain in the lockfile even when `lockfileIncludeTarballUrl: false`,
// otherwise `--frozen-lockfile` installs from an empty store will 404.
// See https://github.com/pnpm/pnpm/issues/11276.
const project = prepareEmpty()
await addDependenciesToPackage({}, ['esprima-fb@3001.1.0-dev-harmony-fb'], testDefaults({ fastUnpack: false, lockfileIncludeTarballUrl: false }))
@@ -1472,7 +1476,7 @@ test('exclude non-standard tarball URL when lockfileIncludeTarballUrl is false',
const lockfile = project.readLockfile()
expect((lockfile.packages['esprima-fb@3001.1.0-dev-harmony-fb'].resolution as TarballResolution).tarball)
.toBeUndefined()
.toBe(`http://localhost:${REGISTRY_MOCK_PORT}/esprima-fb/-/esprima-fb-3001.0001.0000-dev-harmony-fb.tgz`)
})
test('lockfile v6', async () => {

View File

@@ -14,12 +14,18 @@ export function toLockfileResolution (
if (resolution.type !== undefined || !resolution['integrity']) {
return resolution as LockfileResolution
}
// Tarball-typed resolutions are guaranteed to carry a tarball URL by the
// resolver, but guard for unexpected inputs (e.g. resolutions deserialized
// from external state) so we don't blow up on a missing field.
const tarball = resolution['tarball'] as string | undefined
if (tarball == null) {
return { integrity: resolution['integrity'] }
}
// Honor the resolver-supplied flag, with a URL fallback for resolutions
// that didn't go through the git resolver (e.g. config-dep migrations or
// legacy lockfiles read by callers that don't enrich the field).
const gitHosted = (resolution as TarballResolution).gitHosted === true ||
(tarball != null && isGitHostedTarballUrl(tarball))
isGitHostedTarballUrl(tarball)
if (lockfileIncludeTarballUrl) {
return preservingGitHosted({
integrity: resolution['integrity'],
@@ -30,22 +36,23 @@ export function toLockfileResolution (
// and registry must always stay in the lockfile, otherwise the package can
// no longer be re-fetched. This covers local `file:` tarballs and tarballs
// served by git providers (GitHub, GitLab, Bitbucket).
if (tarball != null && (tarball.startsWith('file:') || gitHosted)) {
if (tarball.startsWith('file:') || gitHosted) {
return preservingGitHosted({
integrity: resolution['integrity'],
tarball,
}, gitHosted)
}
if (lockfileIncludeTarballUrl === false) {
return {
integrity: resolution['integrity'],
}
}
// Sometimes packages are hosted under non-standard tarball URLs.
// For instance, when they are hosted on npm Enterprise. See https://github.com/pnpm/pnpm/issues/867
// Or in other weird cases, like https://github.com/pnpm/pnpm/issues/1072
// Or in other weird cases, like https://github.com/pnpm/pnpm/issues/1072.
// Even when the user explicitly sets `lockfileIncludeTarballUrl: false`, we
// must preserve such URLs — otherwise the package cannot be re-fetched on a
// frozen-lockfile install (e.g. GitHub Packages tarballs at
// `https://npm.pkg.github.com/download/<scope>/<name>/<version>/<hash>`).
// `lockfileIncludeTarballUrl` only controls whether URLs that *can* be
// derived from name+version+registry are written.
const expectedTarball = getNpmTarballUrl(pkg.name, pkg.version, { registry })
const actualTarball = tarball!.replaceAll('%2f', '/')
const actualTarball = tarball.replaceAll('%2f', '/')
if (removeProtocol(expectedTarball) !== removeProtocol(actualTarball)) {
return preservingGitHosted({
integrity: resolution['integrity'],

View File

@@ -36,7 +36,11 @@ test('drops the tarball for standard registry URLs when lockfileIncludeTarballUr
})
})
test('drops the tarball for non-standard registry URLs when lockfileIncludeTarballUrl is false', () => {
test('keeps the tarball for non-standard registry URLs when lockfileIncludeTarballUrl is false', () => {
// A tarball URL whose host doesn't match the configured registry cannot be
// reconstructed from name+version+registry, so dropping it would break
// re-fetching on `--frozen-lockfile`. `lockfileIncludeTarballUrl: false`
// only suppresses URLs that *can* be reconstructed.
expect(toLockfileResolution(
{ name: 'esprima-fb', version: '3001.1.0-dev-harmony-fb' },
{ integrity: 'sha512-AAAA', tarball: 'https://example.com/esprima-fb/-/esprima-fb-3001.1.0-dev-harmony-fb.tgz' },
@@ -44,6 +48,22 @@ test('drops the tarball for non-standard registry URLs when lockfileIncludeTarba
false
)).toEqual({
integrity: 'sha512-AAAA',
tarball: 'https://example.com/esprima-fb/-/esprima-fb-3001.1.0-dev-harmony-fb.tgz',
})
})
test('keeps GitHub Packages /download/ tarball URLs when lockfileIncludeTarballUrl is false', () => {
// GitHub Packages serves tarballs at /download/<scope>/<name>/<version>/<hash>,
// which cannot be derived from name+version+registry. See
// https://github.com/pnpm/pnpm/issues/11276.
expect(toLockfileResolution(
{ name: '@example/private', version: '1.2.3' },
{ integrity: 'sha512-AAAA', tarball: 'https://npm.pkg.github.com/download/@example/private/1.2.3/0123456789abcdef0123456789abcdef01234567' },
'https://npm.pkg.github.com/',
false
)).toEqual({
integrity: 'sha512-AAAA',
tarball: 'https://npm.pkg.github.com/download/@example/private/1.2.3/0123456789abcdef0123456789abcdef01234567',
})
})