diff --git a/.changeset/preserve-non-derivable-tarballs.md b/.changeset/preserve-non-derivable-tarballs.md new file mode 100644 index 0000000000..a6ed02fc96 --- /dev/null +++ b/.changeset/preserve-non-derivable-tarballs.md @@ -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////`) and JSR. `lockfileIncludeTarballUrl: true` continues to force the URL into the lockfile for every package [#11276](https://github.com/pnpm/pnpm/issues/11276). diff --git a/installing/deps-installer/test/lockfile.ts b/installing/deps-installer/test/lockfile.ts index 3d227033c8..85eb860c5b 100644 --- a/installing/deps-installer/test/lockfile.ts +++ b/installing/deps-installer/test/lockfile.ts @@ -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 () => { diff --git a/lockfile/utils/src/toLockfileResolution.ts b/lockfile/utils/src/toLockfileResolution.ts index a06ac5eeed..324ae2ee9e 100644 --- a/lockfile/utils/src/toLockfileResolution.ts +++ b/lockfile/utils/src/toLockfileResolution.ts @@ -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////`). + // `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'], diff --git a/lockfile/utils/test/toLockfileResolution.ts b/lockfile/utils/test/toLockfileResolution.ts index 99ff998bbb..03f0faf0e3 100644 --- a/lockfile/utils/test/toLockfileResolution.ts +++ b/lockfile/utils/test/toLockfileResolution.ts @@ -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////, + // 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', }) })