Skip to content

Commit e90d92f

Browse files
committed
feat(publish): fail publish for non-private packages containing packageExtensions
1 parent ad6287b commit e90d92f

5 files changed

Lines changed: 89 additions & 47 deletions

File tree

docs/lib/content/configuring-npm/package-json.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,7 @@ For changing the resolved version of a dependency that is already declared, use
10461046
10471047
Like `overrides`, `packageExtensions` is only honored in the root `package.json` of a project (the workspace root in a workspace).
10481048
The field in installed dependencies and in non-root workspace packages is ignored.
1049+
Because it is root-only project policy, npm refuses to publish a non-private package that contains `packageExtensions`; it remains available to private packages and unpublished local projects.
10491050
10501051
Each key is a package selector: a package name with an optional semver range.
10511052

lib/commands/publish.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ class Publish extends BaseCommand {
9696
const spec = npa(args[0])
9797
let manifest = await this.#getManifest(spec, opts)
9898

99+
// packageExtensions is root-only project policy and must never be published; fail fast so dry-run reports it too
100+
this.#assertNoPackageExtensions(manifest)
101+
99102
// only run scripts for directory type publishes
100103
if (spec.type === 'directory' && !ignoreScripts) {
101104
await runScript({
@@ -122,6 +125,8 @@ class Publish extends BaseCommand {
122125

123126
// The purpose of re-reading the manifest is in case it changed, so that we send the latest and greatest thing to the registry note that publishConfig might have changed as well!
124127
manifest = await this.#getManifest(spec, opts, true)
128+
// re-check the authoritative manifest in case a lifecycle script introduced packageExtensions
129+
this.#assertNoPackageExtensions(manifest)
125130
const force = this.npm.config.get('force')
126131
const isDefaultTag = this.npm.config.isDefault('tag') && !manifest.publishConfig?.tag
127132

@@ -273,6 +278,16 @@ class Publish extends BaseCommand {
273278
}
274279
}
275280

281+
// packageExtensions is root-only project policy and must never reach the registry; private packages may keep it for local use
282+
#assertNoPackageExtensions (manifest) {
283+
if (!manifest.private && manifest.packageExtensions !== undefined) {
284+
throw Object.assign(
285+
new Error('packageExtensions is only honored at the project root and must not be published.'),
286+
{ code: 'EPACKAGEEXTENSIONS' }
287+
)
288+
}
289+
}
290+
276291
// if it's a directory, read it from the file system
277292
// otherwise, get the full metadata from whatever it is
278293
// XXX can't pacote read the manifest from a directory?

test/lib/commands/publish.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,67 @@ t.test('shows usage with wrong set of arguments', async t => {
218218
await t.rejects(publish.exec(['a', 'b', 'c']), publish.usage)
219219
})
220220

221+
t.test('fails for a non-private package containing packageExtensions', async t => {
222+
const { npm } = await loadNpmWithRegistry(t, {
223+
config: { ...auth },
224+
prefixDir: {
225+
'package.json': JSON.stringify({
226+
...pkgJson,
227+
packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } },
228+
}, null, 2),
229+
},
230+
authorization: token,
231+
})
232+
await t.rejects(
233+
npm.exec('publish', []),
234+
{ code: 'EPACKAGEEXTENSIONS', message: /must not be published/ },
235+
'refuses to publish'
236+
)
237+
})
238+
239+
t.test('fails on --dry-run for a package containing packageExtensions', async t => {
240+
const { npm } = await loadNpmWithRegistry(t, {
241+
config: { 'dry-run': true, ...auth },
242+
prefixDir: {
243+
'package.json': JSON.stringify({
244+
...pkgJson,
245+
packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } },
246+
}, null, 2),
247+
},
248+
authorization: token,
249+
})
250+
await t.rejects(
251+
npm.exec('publish', []),
252+
{ code: 'EPACKAGEEXTENSIONS' },
253+
'dry-run also reports the failure'
254+
)
255+
})
256+
257+
t.test('fails when a lifecycle script injects packageExtensions before the re-read', async t => {
258+
const { npm } = await loadNpmWithRegistry(t, {
259+
config: { ...auth },
260+
prefixDir: {
261+
'package.json': JSON.stringify({
262+
...pkgJson,
263+
scripts: { prepublishOnly: 'node inject.js' },
264+
}, null, 2),
265+
// the first manifest read is clean; this hook adds packageExtensions before the authoritative re-read
266+
'inject.js': [
267+
"const fs = require('fs')",
268+
"const p = JSON.parse(fs.readFileSync('package.json'))",
269+
"p.packageExtensions = { 'foo@1': { dependencies: { bar: '^1.0.0' } } }",
270+
"fs.writeFileSync('package.json', JSON.stringify(p))",
271+
].join('\n'),
272+
},
273+
authorization: token,
274+
})
275+
await t.rejects(
276+
npm.exec('publish', []),
277+
{ code: 'EPACKAGEEXTENSIONS' },
278+
'the post-script manifest re-read catches the injected field'
279+
)
280+
})
281+
221282
t.test('throws when invalid tag is semver', async t => {
222283
const { npm } = await loadNpmWithRegistry(t, {
223284
config: {

workspaces/libnpmpublish/lib/publish.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ Remove the 'private' field from the package.json to publish it.`),
2020
)
2121
}
2222

23-
// packageExtensions is consumer-side root policy; warn that publishing it has no effect on consumers
23+
// packageExtensions is root-only project policy and must never reach the registry manifest or the published tarball
2424
if (manifest.packageExtensions !== undefined) {
25-
log.warn('publish',
26-
'packageExtensions is only honored at the project root and will not affect consumers of this package.')
25+
throw Object.assign(
26+
new Error('packageExtensions is only honored at the project root and must not be published.'),
27+
{ code: 'EPACKAGEEXTENSIONS' }
28+
)
2729
}
2830

2931
// spec is used to pick the appropriate registry/auth combo

workspaces/libnpmpublish/test/publish.js

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -75,58 +75,21 @@ t.test('basic publish - no npmVersion', async t => {
7575
t.ok(ret, 'publish succeeded')
7676
})
7777

78-
t.test('warns when publishing a package with packageExtensions', async t => {
78+
t.test('fails when publishing a package with packageExtensions', async t => {
7979
const { publish } = t.mock('..')
80-
const registry = new MockRegistry({
81-
tap: t,
82-
registry: opts.registry,
83-
authorization: token,
84-
})
80+
// no registry interceptor: the publish must fail before any request is made
8581
const manifest = {
8682
name: 'libnpmpublish-test',
8783
version: '1.0.0',
8884
description: 'test libnpmpublish package',
8985
packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } },
9086
}
91-
const spec = npa(manifest.name)
92-
93-
// the published version still carries packageExtensions; the nock body proves it is not stripped
94-
const packument = {
95-
_id: manifest.name,
96-
name: manifest.name,
97-
description: manifest.description,
98-
'dist-tags': { latest: '1.0.0' },
99-
versions: {
100-
'1.0.0': {
101-
_id: `${manifest.name}@${manifest.version}`,
102-
_nodeVersion: process.versions.node,
103-
...manifest,
104-
dist: {
105-
shasum,
106-
integrity: integrity.sha512[0].toString(),
107-
tarball: 'http://mock.reg/libnpmpublish-test/-/libnpmpublish-test-1.0.0.tgz',
108-
},
109-
},
110-
},
111-
access: null,
112-
_attachments: {
113-
'libnpmpublish-test-1.0.0.tgz': {
114-
content_type: 'application/octet-stream',
115-
data: tarData.toString('base64'),
116-
length: tarData.length,
117-
},
118-
},
119-
}
12087

121-
const warnings = []
122-
const onlog = (level, ...args) => level === 'warn' && warnings.push(args.join(' '))
123-
process.on('log', onlog)
124-
t.teardown(() => process.removeListener('log', onlog))
125-
126-
registry.nock.put(`/${spec.escapedName}`, packument).reply(201, {})
127-
await publish(manifest, tarData, { ...opts, npmVersion: null })
128-
t.match(warnings.join('\n'), /packageExtensions is only honored at the project root/,
129-
'warns that packageExtensions does not affect consumers')
88+
await t.rejects(
89+
publish(manifest, tarData, { ...opts, npmVersion: null }),
90+
{ code: 'EPACKAGEEXTENSIONS', message: /must not be published/ },
91+
'refuses to publish a package containing packageExtensions'
92+
)
13093
})
13194

13295
t.test('scoped publish', async t => {

0 commit comments

Comments
 (0)