diff --git a/.changeset/publish-strip-build-metadata.md b/.changeset/publish-strip-build-metadata.md new file mode 100644 index 0000000000..7550296f06 --- /dev/null +++ b/.changeset/publish-strip-build-metadata.md @@ -0,0 +1,6 @@ +--- +"@pnpm/releasing.commands": patch +"pnpm": patch +--- + +Fixed `pnpm publish --provenance` failing with a 422 from the registry when the package version contained semver build metadata (e.g. `1.0.0-canary.0+abc1234`). The `+` segment is now stripped before packing so that the version embedded in the tarball, the metadata sent to the registry, and the sigstore provenance subject all agree [#11518](https://github.com/pnpm/pnpm/issues/11518). diff --git a/releasing/commands/src/publish/pack.ts b/releasing/commands/src/publish/pack.ts index 564c849888..e2ff0bc52c 100644 --- a/releasing/commands/src/publish/pack.ts +++ b/releasing/commands/src/publish/pack.ts @@ -223,21 +223,6 @@ export async function api (opts: PackOptions): Promise { if (!manifest.version) { throw new PnpmError('PACKAGE_VERSION_NOT_FOUND', `Package version is not defined in the ${manifestFileName}.`) } - let tarballName: string - let packDestination: string | undefined - const normalizedName = manifest.name.replace('@', '').replace('/', '-') - if (opts.out) { - if (opts.packDestination) { - throw new PnpmError('INVALID_OPTION', 'Cannot use --pack-destination and --out together') - } - const preparedOut = opts.out.replaceAll('%s', normalizedName).replaceAll('%v', manifest.version) - const parsedOut = path.parse(preparedOut) - packDestination = parsedOut.dir ? parsedOut.dir : opts.packDestination - tarballName = parsedOut.base - } else { - tarballName = `${normalizedName}-${manifest.version}.tgz` - packDestination = opts.packDestination - } const publishManifest = await createPublishManifest({ projectDir: dir, modulesDir: path.join(opts.dir, 'node_modules'), @@ -246,6 +231,28 @@ export async function api (opts: PackOptions): Promise { catalogs: opts.catalogs ?? {}, hooks: opts.hooks, }) + // Strip semver build metadata (the `+` segment) from the published version so that + // the tarball, the manifest packed inside it, and the metadata sent to the registry all agree. + // libnpmpublish runs `semver.clean()` on `manifest.version` before computing the provenance + // subject, which removes build metadata. Leaving it in here would mismatch the version embedded + // in the tarball's package.json and cause the registry to reject the publish with a 422 when + // verifying the sigstore provenance bundle. See https://github.com/pnpm/pnpm/issues/11518. + publishManifest.version = stripBuildMetadata(publishManifest.version!) + let tarballName: string + let packDestination: string | undefined + const normalizedName = manifest.name.replace('@', '').replace('/', '-') + if (opts.out) { + if (opts.packDestination) { + throw new PnpmError('INVALID_OPTION', 'Cannot use --pack-destination and --out together') + } + const preparedOut = opts.out.replaceAll('%s', normalizedName).replaceAll('%v', publishManifest.version) + const parsedOut = path.parse(preparedOut) + packDestination = parsedOut.dir ? parsedOut.dir : opts.packDestination + tarballName = parsedOut.base + } else { + tarballName = `${normalizedName}-${publishManifest.version}.tgz` + packDestination = opts.packDestination + } const files = await packlist(dir, { manifest: publishManifest as Record, }) @@ -320,6 +327,11 @@ export interface PackResult { unpackedSize: number } +function stripBuildMetadata (version: string): string { + const plusIndex = version.indexOf('+') + return plusIndex === -1 ? version : version.slice(0, plusIndex) +} + function preventBundledDependenciesWithoutHoistedNodeLinker (nodeLinker: Config['nodeLinker'], manifest: ProjectManifest): void { if (nodeLinker === 'hoisted') return for (const key of ['bundledDependencies', 'bundleDependencies'] as const) { diff --git a/releasing/commands/test/publish/pack.ts b/releasing/commands/test/publish/pack.ts index 9b7788719a..d1e094878a 100644 --- a/releasing/commands/test/publish/pack.ts +++ b/releasing/commands/test/publish/pack.ts @@ -227,6 +227,31 @@ test('pack a package without package version', async () => { })).rejects.toThrow('Package version is not defined in the package.json.') }) +test('pack: strips semver build metadata from the version', async () => { + prepare({ + name: 'test-strip-build-metadata', + version: '1.0.0-canary.0+abc1234', + }) + + await pack.handler({ + ...DEFAULT_OPTS, + argv: { original: [] }, + dir: process.cwd(), + extraBinPaths: [], + packDestination: process.cwd(), + }) + + expect(fs.existsSync('test-strip-build-metadata-1.0.0-canary.0.tgz')).toBeTruthy() + expect(fs.existsSync('test-strip-build-metadata-1.0.0-canary.0+abc1234.tgz')).toBeFalsy() + + await tar.x({ file: 'test-strip-build-metadata-1.0.0-canary.0.tgz' }) + + expect((await import(path.resolve('package/package.json'))).default).toEqual({ + name: 'test-strip-build-metadata', + version: '1.0.0-canary.0', + }) +}) + test('pack: runs prepack, prepare, and postpack', async () => { prepare({ name: 'test-publish-package.json',