feat(publish): add --skip-manifest-obfuscation flag for pack/publish (#11393)

* feat(publish): add preserve-manifest-fields option

* fix(publish): omit pnpm field when preserveManifestFields is enabled

The preserve-manifest-fields option was deep-cloning the entire manifest,
which leaked the pnpm-specific `pnpm` field into packed/published manifests.
The PR description explicitly calls for this field to remain stripped;
align the implementation, tests, help text, and changeset accordingly.

* refactor(publish): rename preserve-manifest-fields to skip-manifest-obfuscation

The original name implied the flag preserves *all* manifest fields, which
isn't true — the pnpm-specific `pnpm` field is still stripped, and
`publishConfig` / workspace-protocol / catalog rewriting still happen. The
flag is really an escape hatch from pnpm's manifest mangling, so name it
that way. Help text and changeset updated to match.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
modten
2026-05-24 08:15:18 +08:00
committed by GitHub
parent cdceebc2ab
commit 3b62f9da31
11 changed files with 177 additions and 8 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/releasing.commands": minor
"@pnpm/releasing.exportable-manifest": minor
"@pnpm/config.reader": minor
"pnpm": minor
---
Add a `skip-manifest-obfuscation` option for `pnpm pack` and `pnpm publish`. When enabled, the original `packageManager` field and publish lifecycle scripts are kept in the packed/published manifest instead of being stripped. The pnpm-specific `pnpm` field continues to be omitted.

View File

@@ -210,6 +210,7 @@ export interface Config extends OptionsFromRootManifest {
modulesCacheMaxAge: number
dlxCacheMaxAge: number
embedReadme?: boolean
skipManifestObfuscation?: boolean
gitShallowHosts?: string[]
legacyDirFiltering?: boolean
allowBuilds?: Record<string, boolean | string>

View File

@@ -152,6 +152,7 @@ export const excludedPnpmKeys = [
'test-pattern',
'changed-files-ignore-pattern',
'embed-readme',
'skip-manifest-obfuscation',
'fail-if-no-match',
'sync-injected-deps-after-scripts',
'cpu',

View File

@@ -208,6 +208,7 @@ export async function getConfig (opts: {
'workspace-concurrency': getDefaultWorkspaceConcurrency(),
'workspace-prefix': opts.workspaceDir,
'embed-readme': false,
'skip-manifest-obfuscation': false,
'registry-supports-time-field': false,
'virtual-store-dir-max-length': isWindows() ? 60 : 120,
'virtual-store-only': false,
@@ -886,4 +887,3 @@ function addSettingsFromWorkspaceManifestToConfig (pnpmConfig: Config & ConfigCo
}
pnpmConfig.catalogs = getCatalogsFromWorkspaceManifest(workspaceManifest)
}

View File

@@ -139,6 +139,7 @@ export const pnpmTypes = {
'test-pattern': [String, Array],
'changed-files-ignore-pattern': [String, Array],
'embed-readme': Boolean,
'skip-manifest-obfuscation': Boolean,
'update-notifier': Boolean,
'agent': [null, String],
'registry-supports-time-field': Boolean,

View File

@@ -32,6 +32,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
...cliOptionsTypes(),
...pick([
'npm-path',
'skip-manifest-obfuscation',
], allTypes),
}
}
@@ -45,6 +46,7 @@ export function cliOptionsTypes (): Record<string, unknown> {
'pack-destination',
'pack-gzip-level',
'json',
'skip-manifest-obfuscation',
'workspace-concurrency',
], allTypes),
}
@@ -82,6 +84,10 @@ export function help (): string {
name: '--recursive',
shortAlias: '-r',
},
{
description: 'Skip pnpm\'s manifest obfuscation: keep the original `packageManager` field and publish lifecycle scripts in the packed manifest instead of stripping them. The pnpm-specific `pnpm` field is still omitted.',
name: '--skip-manifest-obfuscation',
},
{
description: `Set the maximum number of concurrency. Default is ${getDefaultWorkspaceConcurrency()}. For unlimited concurrency use Infinity.`,
name: '--workspace-concurrency <number>',
@@ -98,6 +104,7 @@ export type PackOptions = Pick<UniversalOptions, 'dir'> & Pick<Config, 'catalogs
| 'embedReadme'
| 'packGzipLevel'
| 'nodeLinker'
| 'skipManifestObfuscation'
| 'userAgent'
> & Partial<Pick<Config, 'extraBinPaths'
| 'extraEnv'
@@ -230,6 +237,7 @@ export async function api (opts: PackOptions): Promise<PackResult> {
embedReadme: opts.embedReadme,
catalogs: opts.catalogs ?? {},
hooks: opts.hooks,
skipManifestObfuscation: opts.skipManifestObfuscation,
})
// 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.
@@ -394,14 +402,16 @@ async function createPublishManifest (opts: {
manifest: ProjectManifest
catalogs: Catalogs
hooks?: Hooks
skipManifestObfuscation?: boolean
}): Promise<ExportedManifest> {
const { projectDir, embedReadme, modulesDir, manifest, catalogs, hooks } = opts
const { projectDir, embedReadme, modulesDir, manifest, catalogs, hooks, skipManifestObfuscation } = opts
const readmeFile = embedReadme ? await readReadmeFile(projectDir) : undefined
return createExportableManifest(projectDir, manifest, {
catalogs,
hooks,
readmeFile,
modulesDir,
skipManifestObfuscation,
})
}

View File

@@ -26,6 +26,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'access',
'git-checks',
'ignore-scripts',
'skip-manifest-obfuscation',
'provenance',
'npm-path',
'otp',
@@ -87,6 +88,10 @@ export function help (): string {
description: 'Ignores any publish related lifecycle scripts (prepublishOnly, postpublish, and the like)',
name: '--ignore-scripts',
},
{
description: 'Skip pnpm\'s manifest obfuscation: keep the original `packageManager` field and publish lifecycle scripts in the published manifest instead of stripping them. The pnpm-specific `pnpm` field is still omitted.',
name: '--skip-manifest-obfuscation',
},
{
description: 'Packages are proceeded to be published even if their current version is already in the registry. This is useful when a "prepublishOnly" script bumps the version of the package before it is published',
name: '--force',
@@ -124,7 +129,7 @@ export async function handler (
json?: boolean
recursive?: boolean
workspaceDir?: string
} & Pick<Config, 'bin' | 'gitChecks' | 'ignoreScripts' | 'pnpmHomeDir' | 'publishBranch' | 'embedReadme'>
} & Pick<Config, 'bin' | 'gitChecks' | 'ignoreScripts' | 'pnpmHomeDir' | 'publishBranch' | 'embedReadme' | 'skipManifestObfuscation'>
& Pick<ConfigContext, 'allProjects'>,
params: string[]
): Promise<{ exitCode?: number, output?: string } | undefined> {
@@ -161,7 +166,7 @@ export async function publish (
engineStrict?: boolean
recursive?: boolean
workspaceDir?: string
} & Pick<Config, 'bin' | 'gitChecks' | 'ignoreScripts' | 'pnpmHomeDir' | 'publishBranch' | 'embedReadme' | 'packGzipLevel'>
} & Pick<Config, 'bin' | 'gitChecks' | 'ignoreScripts' | 'pnpmHomeDir' | 'publishBranch' | 'embedReadme' | 'packGzipLevel' | 'skipManifestObfuscation'>
& Pick<ConfigContext, 'allProjects'>,
params: string[]
): Promise<PublishResult> {

View File

@@ -464,6 +464,42 @@ test('pack: remove publishConfig', async () => {
})
})
test('pack: preserves packageManager and publish lifecycle scripts when skipManifestObfuscation is enabled, but still omits pnpm', async () => {
prepare({
name: 'skip-manifest-obfuscation',
version: '1.0.0',
packageManager: 'pnpm@10.0.0',
pnpm: {
testField: true,
},
scripts: {
prepublishOnly: 'echo prepublishOnly',
postinstall: 'echo postinstall',
},
} as Parameters<typeof prepare>[0] & { pnpm?: Record<string, unknown> })
await pack.handler({
...DEFAULT_OPTS,
argv: { original: [] },
dir: process.cwd(),
extraBinPaths: [],
packDestination: process.cwd(),
skipManifestObfuscation: true,
})
await tar.x({ file: 'skip-manifest-obfuscation-1.0.0.tgz' })
expect((await import(path.resolve('package/package.json'))).default).toEqual({
name: 'skip-manifest-obfuscation',
version: '1.0.0',
packageManager: 'pnpm@10.0.0',
scripts: {
prepublishOnly: 'echo prepublishOnly',
postinstall: 'echo postinstall',
},
})
})
test('pack should read from the correct node_modules when publishing from a custom directory', async () => {
prepare({
name: 'custom-publish-dir',

View File

@@ -381,6 +381,50 @@ test('publish: package with publishConfig.directory', async () => {
expect(fs.existsSync('node_modules/publish_config_directory_dist_package/prepublishOnly')).toBeTruthy()
})
test('publish: preserves packageManager and publish lifecycle scripts when skipManifestObfuscation is enabled, but still omits pnpm', async () => {
preparePackages([
{
name: 'test-publish-skip-manifest-obfuscation',
version: '1.0.0',
packageManager: 'pnpm@10.0.0',
pnpm: {
testField: true,
},
scripts: {
prepublishOnly: 'echo prepublishOnly',
postinstall: 'echo postinstall',
},
} as Parameters<typeof preparePackages>[0][number] & { pnpm?: Record<string, unknown> },
{
name: 'test-publish-skip-manifest-obfuscation-installation',
version: '1.0.0',
},
])
process.chdir('test-publish-skip-manifest-obfuscation')
await publish.handler({
...DEFAULT_OPTS,
argv: { original: ['publish'] },
configByUri: CONFIG_BY_URI,
dir: process.cwd(),
skipManifestObfuscation: true,
}, [])
process.chdir('../test-publish-skip-manifest-obfuscation-installation')
crossSpawn.sync(pnpmBin, ['add', 'test-publish-skip-manifest-obfuscation', `--registry=http://localhost:${REGISTRY_MOCK_PORT}`], { env: SPAWN_ENV })
const { default: publishedManifest } = await import(path.resolve('node_modules/test-publish-skip-manifest-obfuscation/package.json'))
expect(publishedManifest).toEqual({
name: 'test-publish-skip-manifest-obfuscation',
version: '1.0.0',
packageManager: 'pnpm@10.0.0',
scripts: {
prepublishOnly: 'echo prepublishOnly',
postinstall: 'echo postinstall',
},
})
})
test.skip('publish package that calls executable from the workspace .bin folder in prepublishOnly script', async () => {
await using server = await createTestIpcServer()

View File

@@ -8,7 +8,7 @@ import { parseJsrSpecifier } from '@pnpm/resolving.jsr-specifier-parser'
import type { Dependencies, ProjectManifest } from '@pnpm/types'
import { tryReadProjectManifest } from '@pnpm/workspace.project-manifest-reader'
import { pMapValues } from 'p-map-values'
import { omit } from 'ramda'
import { clone, omit } from 'ramda'
import { overridePublishConfig } from './overridePublishConfig.js'
import { type ExportedManifest, transform } from './transform/index.js'
@@ -28,6 +28,7 @@ export interface MakePublishManifestOptions {
catalogs: Catalogs
hooks?: Hooks
modulesDir?: string
skipManifestObfuscation?: boolean
readmeFile?: string
}
@@ -36,9 +37,14 @@ export async function createExportableManifest (
originalManifest: ProjectManifest,
opts: MakePublishManifestOptions
): Promise<ExportedManifest> {
let publishManifest: ProjectManifest = omit(['scripts', 'packageManager', 'pnpm' as keyof ProjectManifest], originalManifest)
if (originalManifest.scripts != null) {
publishManifest.scripts = omit(PREPUBLISH_SCRIPTS, originalManifest.scripts)
let publishManifest: ProjectManifest
if (opts.skipManifestObfuscation) {
publishManifest = omit(['pnpm' as keyof ProjectManifest], clone(originalManifest))
} else {
publishManifest = omit(['scripts', 'packageManager', 'pnpm' as keyof ProjectManifest], originalManifest)
if (originalManifest.scripts != null) {
publishManifest.scripts = omit(PREPUBLISH_SCRIPTS, originalManifest.scripts)
}
}
const catalogResolver = resolveFromCatalog.bind(null, opts.catalogs)

View File

@@ -82,6 +82,63 @@ test('publish lifecycle scripts are removed', async () => {
})
})
test('packageManager and publish lifecycle scripts are preserved when skipManifestObfuscation is enabled, but pnpm is still omitted', async () => {
const manifest: ProjectManifest & { pnpm?: Record<string, unknown> } = {
name: 'foo',
version: '1.0.0',
packageManager: 'pnpm@10.0.0',
pnpm: {
testField: true,
},
scripts: {
prepublishOnly: 'echo',
postinstall: 'echo hello',
},
}
expect(await createExportableManifest(process.cwd(), manifest, {
...defaultOpts,
skipManifestObfuscation: true,
})).toStrictEqual({
name: 'foo',
version: '1.0.0',
packageManager: 'pnpm@10.0.0',
scripts: {
prepublishOnly: 'echo',
postinstall: 'echo hello',
},
})
})
test('skipManifestObfuscation does not mutate the original manifest', async () => {
const manifest: ProjectManifest = {
name: 'foo',
version: '1.0.0',
publishConfig: {
main: './dist/index.js',
},
}
expect(await createExportableManifest(process.cwd(), manifest, {
...defaultOpts,
skipManifestObfuscation: true,
readmeFile: 'readme content',
})).toStrictEqual({
name: 'foo',
version: '1.0.0',
main: './dist/index.js',
readme: 'readme content',
})
expect(manifest).toStrictEqual({
name: 'foo',
version: '1.0.0',
publishConfig: {
main: './dist/index.js',
},
})
})
test('readme added to published manifest', async () => {
expect(await createExportableManifest(process.cwd(), {
name: 'foo',