Skip to content

Commit f3ac7b7

Browse files
reggiljharb
andauthored
feat!: no implicit latest tag on publish when latest > version (#7939)
BREAKING CHANGE: Upon publishing, in order to apply a default "latest" dist tag, the command now retrieves all prior versions of the package. It will require that the version you're trying to publish is above the latest semver version in the registry, not including pre-release tags. Implements [npm RFC7](https://github.com/npm/rfcs/blob/main/accepted/0007-publish-without-tag.md). Related to prerelease dist-tag: #7910 A part of npm 11 roadmap: npm/statusboard#898 --------- Co-authored-by: Jordan Harband <ljharb@gmail.com>
1 parent bc9b14d commit f3ac7b7

File tree

16 files changed

+346
-387
lines changed

16 files changed

+346
-387
lines changed

lib/commands/deprecate.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const fetch = require('npm-registry-fetch')
1+
const npmFetch = require('npm-registry-fetch')
22
const { otplease } = require('../utils/auth.js')
33
const npa = require('npm-package-arg')
44
const { log } = require('proc-log')
@@ -47,7 +47,7 @@ class Deprecate extends BaseCommand {
4747
}
4848

4949
const uri = '/' + p.escapedName
50-
const packument = await fetch.json(uri, {
50+
const packument = await npmFetch.json(uri, {
5151
...this.npm.flatOptions,
5252
spec: p,
5353
query: { write: true },
@@ -60,7 +60,7 @@ class Deprecate extends BaseCommand {
6060
for (const v of versions) {
6161
packument.versions[v].deprecated = msg
6262
}
63-
return otplease(this.npm, this.npm.flatOptions, opts => fetch(uri, {
63+
return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
6464
...opts,
6565
spec: p,
6666
method: 'PUT',

lib/commands/dist-tag.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const npa = require('npm-package-arg')
2-
const regFetch = require('npm-registry-fetch')
2+
const npmFetch = require('npm-registry-fetch')
33
const semver = require('semver')
44
const { log, output } = require('proc-log')
55
const { otplease } = require('../utils/auth.js')
@@ -119,7 +119,7 @@ class DistTag extends BaseCommand {
119119
},
120120
spec,
121121
}
122-
await otplease(this.npm, reqOpts, o => regFetch(url, o))
122+
await otplease(this.npm, reqOpts, o => npmFetch(url, o))
123123
output.standard(`+${t}: ${spec.name}@${version}`)
124124
}
125125

@@ -145,7 +145,7 @@ class DistTag extends BaseCommand {
145145
method: 'DELETE',
146146
spec,
147147
}
148-
await otplease(this.npm, reqOpts, o => regFetch(url, o))
148+
await otplease(this.npm, reqOpts, o => npmFetch(url, o))
149149
output.standard(`-${tag}: ${spec.name}@${version}`)
150150
}
151151

@@ -191,7 +191,7 @@ class DistTag extends BaseCommand {
191191
}
192192

193193
async fetchTags (spec, opts) {
194-
const data = await regFetch.json(
194+
const data = await npmFetch.json(
195195
`/-/package/${spec.escapedName}/dist-tags`,
196196
{ ...opts, 'prefer-online': true, spec }
197197
)

lib/commands/doctor.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const cacache = require('cacache')
22
const { access, lstat, readdir, constants: { R_OK, W_OK, X_OK } } = require('node:fs/promises')
3-
const fetch = require('make-fetch-happen')
3+
const npmFetch = require('make-fetch-happen')
44
const which = require('which')
55
const pacote = require('pacote')
66
const { resolve } = require('node:path')
@@ -166,7 +166,7 @@ class Doctor extends BaseCommand {
166166
const currentRange = `^${current}`
167167
const url = 'https://nodejs.org/dist/index.json'
168168
log.info('doctor', 'Getting Node.js release information')
169-
const res = await fetch(url, { method: 'GET', ...this.npm.flatOptions })
169+
const res = await npmFetch(url, { method: 'GET', ...this.npm.flatOptions })
170170
const data = await res.json()
171171
let maxCurrent = '0.0.0'
172172
let maxLTS = '0.0.0'

lib/commands/publish.js

+31-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ class Publish extends BaseCommand {
120120
const isDefaultTag = this.npm.config.isDefault('tag')
121121

122122
if (isPreRelease && isDefaultTag) {
123-
throw new Error('You must specify a tag using --tag when publishing a prerelease version')
123+
throw new Error('You must specify a tag using --tag when publishing a prerelease version.')
124124
}
125125

126126
// If we are not in JSON mode then we show the user the contents of the tarball
@@ -157,6 +157,14 @@ class Publish extends BaseCommand {
157157
}
158158
}
159159

160+
const latestVersion = await this.#latestPublishedVersion(resolved, registry)
161+
const latestSemverIsGreater = !!latestVersion && semver.gte(latestVersion, manifest.version)
162+
163+
if (latestSemverIsGreater && isDefaultTag) {
164+
/* eslint-disable-next-line max-len */
165+
throw new Error(`Cannot implicitly apply the "latest" tag because published version ${latestVersion} is higher than the new version ${manifest.version}. You must specify a tag using --tag.`)
166+
}
167+
160168
const access = opts.access === null ? 'default' : opts.access
161169
let msg = `Publishing to ${outputRegistry} with tag ${defaultTag} and ${access} access`
162170
if (dryRun) {
@@ -196,6 +204,28 @@ class Publish extends BaseCommand {
196204
}
197205
}
198206

207+
async #latestPublishedVersion (spec, registry) {
208+
try {
209+
const packument = await pacote.packument(spec, {
210+
...this.npm.flatOptions,
211+
preferOnline: true,
212+
registry,
213+
})
214+
if (typeof packument?.versions === 'undefined') {
215+
return null
216+
}
217+
const ordered = Object.keys(packument?.versions)
218+
.flatMap(v => {
219+
const s = new semver.SemVer(v)
220+
return s.prerelease.length > 0 ? [] : s
221+
})
222+
.sort((a, b) => b.compare(a))
223+
return ordered.length >= 1 ? ordered[0].version : null
224+
} catch (e) {
225+
return null
226+
}
227+
}
228+
199229
// if it's a directory, read it from the file system
200230
// otherwise, get the full metadata from whatever it is
201231
// XXX can't pacote read the manifest from a directory?

lib/commands/star.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const fetch = require('npm-registry-fetch')
1+
const npmFetch = require('npm-registry-fetch')
22
const npa = require('npm-package-arg')
33
const { log, output } = require('proc-log')
44
const getIdentity = require('../utils/get-identity')
@@ -32,7 +32,7 @@ class Star extends BaseCommand {
3232
const username = await getIdentity(this.npm, this.npm.flatOptions)
3333

3434
for (const pkg of pkgs) {
35-
const fullData = await fetch.json(pkg.escapedName, {
35+
const fullData = await npmFetch.json(pkg.escapedName, {
3636
...this.npm.flatOptions,
3737
spec: pkg,
3838
query: { write: true },
@@ -55,7 +55,7 @@ class Star extends BaseCommand {
5555
log.verbose('unstar', 'unstarring', body)
5656
}
5757

58-
const data = await fetch.json(pkg.escapedName, {
58+
const data = await npmFetch.json(pkg.escapedName, {
5959
...this.npm.flatOptions,
6060
spec: pkg,
6161
method: 'PUT',

lib/commands/stars.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const fetch = require('npm-registry-fetch')
1+
const npmFetch = require('npm-registry-fetch')
22
const { log, output } = require('proc-log')
33
const getIdentity = require('../utils/get-identity.js')
44
const BaseCommand = require('../base-cmd.js')
@@ -16,7 +16,7 @@ class Stars extends BaseCommand {
1616
user = await getIdentity(this.npm, this.npm.flatOptions)
1717
}
1818

19-
const { rows } = await fetch.json('/-/_view/starredByUser', {
19+
const { rows } = await npmFetch.json('/-/_view/starredByUser', {
2020
...this.npm.flatOptions,
2121
query: { key: `"${user}"` },
2222
})

lib/utils/ping.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// ping the npm registry
22
// used by the ping and doctor commands
3-
const fetch = require('npm-registry-fetch')
3+
const npmFetch = require('npm-registry-fetch')
44
module.exports = async (flatOptions) => {
5-
const res = await fetch('/-/ping', { ...flatOptions, cache: false })
5+
const res = await npmFetch('/-/ping', { ...flatOptions, cache: false })
66
return res.json().catch(() => ({}))
77
}

lib/utils/verify-signatures.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const fetch = require('npm-registry-fetch')
1+
const npmFetch = require('npm-registry-fetch')
22
const localeCompare = require('@isaacs/string-locale-compare')('en')
33
const npa = require('npm-package-arg')
44
const pacote = require('pacote')
@@ -202,7 +202,7 @@ class VerifySignatures {
202202

203203
// If keys not found in Sigstore TUF repo, fallback to registry keys API
204204
if (!keys) {
205-
keys = await fetch.json('/-/npm/v1/keys', {
205+
keys = await npmFetch.json('/-/npm/v1/keys', {
206206
...this.npm.flatOptions,
207207
registry,
208208
}).then(({ keys: ks }) => ks.map((key) => ({
@@ -253,7 +253,7 @@ class VerifySignatures {
253253
}
254254

255255
getSpecRegistry (spec) {
256-
return fetch.pickRegistry(spec, this.npm.flatOptions)
256+
return npmFetch.pickRegistry(spec, this.npm.flatOptions)
257257
}
258258

259259
getValidPackageInfo (edge) {

mock-registry/lib/index.js

+60-1
Original file line numberDiff line numberDiff line change
@@ -351,13 +351,30 @@ class MockRegistry {
351351
}
352352

353353
// full unpublish of an entire package
354-
async unpublish ({ manifest }) {
354+
unpublish ({ manifest }) {
355355
let nock = this.nock
356356
const spec = npa(manifest.name)
357357
nock = nock.delete(this.fullPath(`/${spec.escapedName}/-rev/${manifest._rev}`)).reply(201)
358358
return nock
359359
}
360360

361+
publish (name, {
362+
packageJson, access, noPut, putCode, manifest, packuments,
363+
} = {}) {
364+
// this getPackage call is used to get the latest semver version before publish
365+
if (manifest) {
366+
this.getPackage(name, { code: 200, resp: manifest })
367+
} else if (packuments) {
368+
this.getPackage(name, { code: 200, resp: this.manifest({ name, packuments }) })
369+
} else {
370+
// assumes the package does not exist yet and will 404 x2 from pacote.manifest
371+
this.getPackage(name, { times: 2, code: 404 })
372+
}
373+
if (!noPut) {
374+
this.putPackage(name, { code: putCode, packageJson, access })
375+
}
376+
}
377+
361378
getPackage (name, { times = 1, code = 200, query, resp = {} }) {
362379
let nock = this.nock
363380
nock = nock.get(`/${npa(name).escapedName}`).times(times)
@@ -372,6 +389,48 @@ class MockRegistry {
372389
this.nock = nock
373390
}
374391

392+
putPackage (name, { code = 200, resp = {}, ...putPackagePayload }) {
393+
this.nock.put(`/${npa(name).escapedName}`, body => {
394+
return this.#tap.match(body, this.putPackagePayload({ name, ...putPackagePayload }))
395+
}).reply(code, resp)
396+
}
397+
398+
putPackagePayload (opts) {
399+
const pkg = opts.packageJson
400+
const name = opts.name || pkg?.name
401+
const registry = opts.registry || pkg?.publishConfig?.registry || 'https://registry.npmjs.org'
402+
const access = opts.access || null
403+
404+
const nameProperties = !name ? {} : {
405+
_id: name,
406+
name: name,
407+
}
408+
409+
const packageProperties = !pkg ? {} : {
410+
'dist-tags': { latest: pkg.version },
411+
versions: {
412+
[pkg.version]: {
413+
_id: `${pkg.name}@${pkg.version}`,
414+
dist: {
415+
shasum: /\.*/,
416+
tarball:
417+
`http://${new URL(registry).host}/${pkg.name}/-/${pkg.name}-${pkg.version}.tgz`,
418+
},
419+
...pkg,
420+
},
421+
},
422+
_attachments: {
423+
[`${pkg.name}-${pkg.version}.tgz`]: {},
424+
},
425+
}
426+
427+
return {
428+
access,
429+
...nameProperties,
430+
...packageProperties,
431+
}
432+
}
433+
375434
getTokens (tokens) {
376435
return this.nock.get('/-/npm/v1/tokens')
377436
.reply(200, {

smoke-tests/test/fixtures/setup.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ const getCleanPaths = async () => {
7373
})
7474
}
7575

76-
module.exports = async (t, { testdir = {}, debug, mockRegistry = true, useProxy = false } = {}) => {
76+
module.exports = async (t, {
77+
testdir = {}, debug, mockRegistry = true, strictRegistryNock = true, useProxy = false,
78+
} = {}) => {
7779
const debugLog = debug || CI ? (...a) => t.comment(...a) : () => {}
7880
debugLog({ SMOKE_PUBLISH_TARBALL, CI })
7981

@@ -103,7 +105,7 @@ module.exports = async (t, { testdir = {}, debug, mockRegistry = true, useProxy
103105
tap: t,
104106
registry: MOCK_REGISTRY,
105107
debug,
106-
strict: true,
108+
strict: strictRegistryNock,
107109
})
108110

109111
const proxyEnv = {}

smoke-tests/test/npm-replace-global.js

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ t.test('publish and replace global self', async t => {
103103
getPaths,
104104
paths: { globalBin, globalNodeModules, cache },
105105
} = await setupNpmGlobal(t, {
106+
strictRegistryNock: false,
106107
testdir: {
107108
home: {
108109
'.npmrc': `//${setup.MOCK_REGISTRY.host}/:_authToken = test-token`,

test/fixtures/mock-npm.js

+17-3
Original file line numberDiff line numberDiff line change
@@ -292,12 +292,26 @@ const setupMockNpm = async (t, {
292292

293293
const loadNpmWithRegistry = async (t, opts) => {
294294
const mock = await setupMockNpm(t, opts)
295+
return {
296+
...mock,
297+
...loadRegistry(t, mock, opts),
298+
...loadFsAssertions(t, mock),
299+
}
300+
}
301+
302+
const loadRegistry = (t, mock, opts) => {
295303
const registry = new MockRegistry({
296304
tap: t,
297-
registry: mock.npm.config.get('registry'),
298-
strict: true,
305+
registry: opts.registry ?? mock.npm.config.get('registry'),
306+
authorization: opts.authorization,
307+
basic: opts.basic,
308+
debug: opts.debugRegistry ?? false,
309+
strict: opts.strictRegistryNock ?? true,
299310
})
311+
return { registry }
312+
}
300313

314+
const loadFsAssertions = (t, mock) => {
301315
const fileShouldExist = (filePath) => {
302316
t.equal(
303317
fsSync.existsSync(path.join(mock.npm.prefix, filePath)), true, `${filePath} should exist`
@@ -352,7 +366,7 @@ const loadNpmWithRegistry = async (t, opts) => {
352366
packageDirty,
353367
}
354368

355-
return { registry, assert, ...mock }
369+
return { assert }
356370
}
357371

358372
/** breaks down a spec "abbrev@1.1.1" into different parts for mocking */

0 commit comments

Comments
 (0)