fix(publish): strip semver build metadata before packing (#11525)

When a published version contained a `+<build>` 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.
This commit is contained in:
Zoltan Kochan
2026-05-07 17:55:59 +02:00
committed by GitHub
parent 24f3669c4a
commit 8eb1be4988
3 changed files with 58 additions and 15 deletions

View File

@@ -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 `+<build>` 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).

View File

@@ -223,21 +223,6 @@ export async function api (opts: PackOptions): Promise<PackResult> {
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<PackResult> {
catalogs: opts.catalogs ?? {},
hooks: opts.hooks,
})
// Strip semver build metadata (the `+<build>` 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<string, unknown>,
})
@@ -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) {

View File

@@ -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',