From 8eb1be4988e6901d6591b4cfbd628fc886dfc78c Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 7 May 2026 17:55:59 +0200 Subject: [PATCH] fix(publish): strip semver build metadata before packing (#11525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a published version contained a `+` segment (e.g. `1.0.0-canary.0+abc1234`), `pnpm publish --provenance` was rejected by the registry with a 422 verifying the sigstore provenance bundle. `libnpmpublish.publish()` runs `semver.clean()` on `manifest.version`, which strips build metadata, before computing the provenance subject. pnpm was packing the tarball with the original version, so the version embedded in the packed `package.json` no longer matched the version in the metadata payload and the bundle's subject — causing the registry to reject the publish. Strip build metadata from the published version after creating the publish manifest, then derive both the tarball filename and the manifest packed inside the tarball from that cleaned version. Closes #11518. --- .changeset/publish-strip-build-metadata.md | 6 ++++ releasing/commands/src/publish/pack.ts | 42 ++++++++++++++-------- releasing/commands/test/publish/pack.ts | 25 +++++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 .changeset/publish-strip-build-metadata.md 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',