Skip to content

Commit 26d4c2b

Browse files
committed
feat!: no implicit latest tag on publish when latest > version
1 parent 2bccf91 commit 26d4c2b

File tree

5 files changed

+172
-6
lines changed

5 files changed

+172
-6
lines changed

lib/commands/dist-tag.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class DistTag extends BaseCommand {
102102
throw new Error('Tag name must not be a valid SemVer range: ' + t)
103103
}
104104

105-
const tags = await this.fetchTags(spec, opts)
105+
const tags = await DistTag.fetchTags(spec, opts)
106106
if (tags[t] === version) {
107107
log.warn('dist-tag add', t, 'is already set to version', version)
108108
return
@@ -131,7 +131,7 @@ class DistTag extends BaseCommand {
131131
throw this.usageError()
132132
}
133133

134-
const tags = await this.fetchTags(spec, opts)
134+
const tags = await DistTag.fetchTags(spec, opts)
135135
if (!tags[tag]) {
136136
log.info('dist-tag del', tag, 'is not a dist-tag on', spec.name)
137137
throw new Error(tag + ' is not a dist-tag on ' + spec.name)
@@ -164,7 +164,7 @@ class DistTag extends BaseCommand {
164164
spec = npa(spec)
165165

166166
try {
167-
const tags = await this.fetchTags(spec, opts)
167+
const tags = await DistTag.fetchTags(spec, opts)
168168
const msg =
169169
Object.keys(tags).map(k => `${k}: ${tags[k]}`).sort().join('\n')
170170
output.standard(msg)
@@ -190,7 +190,7 @@ class DistTag extends BaseCommand {
190190
}
191191
}
192192

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

lib/commands/publish.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { getContents, logTar } = require('../utils/tar.js')
1616
const { flatten } = require('@npmcli/config/lib/definitions')
1717
const pkgJson = require('@npmcli/package-json')
1818
const BaseCommand = require('../base-cmd.js')
19+
const DistTag = require('./dist-tag.js')
1920

2021
class Publish extends BaseCommand {
2122
static description = 'Publish a package'
@@ -131,6 +132,13 @@ class Publish extends BaseCommand {
131132

132133
const resolved = npa.resolve(manifest.name, manifest.version)
133134

135+
const latestDistTag = await this.#latestDistTag(resolved)
136+
const latestTagIsGreater = latestDistTag ? semver.gte(latestDistTag, manifest.version) : false
137+
138+
if (latestTagIsGreater && isDefaultTag) {
139+
throw new Error('Cannot publish a lower version without an explicit dist tag.')
140+
}
141+
134142
// make sure tag is valid, this will throw if invalid
135143
npa(`${manifest.name}@${defaultTag}`)
136144

@@ -196,6 +204,16 @@ class Publish extends BaseCommand {
196204
}
197205
}
198206

207+
async #latestDistTag (spec) {
208+
try {
209+
const tags = await DistTag.fetchTags(spec, this.npm.flatOptions)
210+
return tags.latest
211+
} catch (_e) {
212+
// this will fail if the package is new, so just return null
213+
return null
214+
}
215+
}
216+
199217
// if it's a directory, read it from the file system
200218
// otherwise, get the full metadata from whatever it is
201219
// XXX can't pacote read the manifest from a directory?

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ t.test('publish and replace global self', async t => {
136136
if (setup.SMOKE_PUBLISH) {
137137
await npmPackage()
138138
}
139+
registry.nock.get('/-/package/npm/dist-tags').reply(404, 'not found')
139140
registry.nock.put('/npm', body => {
140141
if (body._id === 'npm' && body.versions[version]) {
141142
publishedPackument = body.versions[version]

test/fixtures/mock-npm.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,14 +459,17 @@ const mockNpmRegistryFetch = (tags) => {
459459
fetchOpts[url] = [opts]
460460
}
461461
const find = ({ ...tags })[url]
462+
if (!find) {
463+
throw new Error(`no npm-registry-fetch mock for ${url}`)
464+
}
462465
if (typeof find === 'function') {
463466
return find()
464467
}
465468
return find
466469
}
467470
const nrf = async (url, opts) => {
468471
return {
469-
json: getRequest(url, opts),
472+
json: () => getRequest(url, opts),
470473
}
471474
}
472475
const mock = Object.assign(nrf, npmFetch, { json: getRequest })
@@ -475,9 +478,34 @@ const mockNpmRegistryFetch = (tags) => {
475478
return { mocks, mock, fetchOpts, getOpts }
476479
}
477480

481+
const putPackagePayload = ({ pkg, alternateRegistry, version }) => ({
482+
_id: pkg,
483+
name: pkg,
484+
'dist-tags': { latest: version },
485+
access: null,
486+
versions: {
487+
[version]: {
488+
name: pkg,
489+
version: version,
490+
_id: `${pkg}@${version}`,
491+
dist: {
492+
shasum: /\.*/,
493+
tarball: `http:${alternateRegistry.slice(6)}/test-package/-/test-package-${version}.tgz`,
494+
},
495+
publishConfig: {
496+
registry: alternateRegistry,
497+
},
498+
},
499+
},
500+
_attachments: {
501+
[`${pkg}-${version}.tgz`]: {},
502+
},
503+
})
504+
478505
module.exports = setupMockNpm
479506
module.exports.load = setupMockNpm
480507
module.exports.setGlobalNodeModules = setGlobalNodeModules
481508
module.exports.loadNpmWithRegistry = loadNpmWithRegistry
482509
module.exports.workspaceMock = workspaceMock
483510
module.exports.mockNpmRegistryFetch = mockNpmRegistryFetch
511+
module.exports.putPackagePayload = putPackagePayload

test/lib/commands/publish.js

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
const t = require('tap')
2-
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
2+
const {
3+
load: originalLoadMockNpm,
4+
mockNpmRegistryFetch,
5+
putPackagePayload } = require('../../fixtures/mock-npm')
36
const { cleanZlib } = require('../../fixtures/clean-snapshot')
47
const MockRegistry = require('@npmcli/mock-registry')
58
const pacote = require('pacote')
@@ -22,6 +25,20 @@ const pkgJson = {
2225

2326
t.cleanSnapshot = data => cleanZlib(data)
2427

28+
function loadMockNpm (test, args) {
29+
return originalLoadMockNpm(test, {
30+
...args,
31+
mocks: {
32+
...mockNpmRegistryFetch({
33+
[`/-/package/${pkg}/dist-tags`]: () => {
34+
throw new Error('not found')
35+
},
36+
}).mocks,
37+
...args.mocks,
38+
},
39+
})
40+
}
41+
2542
t.test('respects publishConfig.registry, runs appropriate scripts', async t => {
2643
const { npm, joinedOutput, prefix } = await loadMockNpm(t, {
2744
config: {
@@ -1069,3 +1086,105 @@ t.test('does not abort when prerelease and authored tag latest', async t => {
10691086
}).reply(200, {})
10701087
await npm.exec('publish', [])
10711088
})
1089+
1090+
t.test('PREVENTS publish when latest dist-tag is HIGHER than publishing version', async t => {
1091+
const latest = '100.0.0'
1092+
const version = '50.0.0'
1093+
1094+
const { npm } = await loadMockNpm(t, {
1095+
config: {
1096+
loglevel: 'silent',
1097+
[`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token',
1098+
},
1099+
prefixDir: {
1100+
'package.json': JSON.stringify({
1101+
...pkgJson,
1102+
version,
1103+
scripts: {
1104+
prepublishOnly: 'touch scripts-prepublishonly',
1105+
prepublish: 'touch scripts-prepublish', // should NOT run this one
1106+
publish: 'touch scripts-publish',
1107+
postpublish: 'touch scripts-postpublish',
1108+
},
1109+
publishConfig: { registry: alternateRegistry },
1110+
}, null, 2),
1111+
},
1112+
mocks: {
1113+
...mockNpmRegistryFetch({
1114+
[`/-/package/${pkg}/dist-tags`]: { latest },
1115+
}).mocks,
1116+
},
1117+
})
1118+
await t.rejects(async () => {
1119+
await npm.exec('publish', [])
1120+
}, new Error('Cannot publish a lower version without an explicit dist tag.'))
1121+
})
1122+
1123+
t.test('ALLOWS publish when latest dist-tag is LOWER than publishing version', async t => {
1124+
const version = '100.0.0'
1125+
const latest = '50.0.0'
1126+
1127+
const { npm } = await loadMockNpm(t, {
1128+
config: {
1129+
loglevel: 'silent',
1130+
[`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token',
1131+
},
1132+
prefixDir: {
1133+
'package.json': JSON.stringify({
1134+
...pkgJson,
1135+
version,
1136+
publishConfig: { registry: alternateRegistry },
1137+
}, null, 2),
1138+
},
1139+
mocks: {
1140+
...mockNpmRegistryFetch({
1141+
[`/-/package/${pkg}/dist-tags`]: { latest },
1142+
}).mocks,
1143+
},
1144+
})
1145+
const registry = new MockRegistry({
1146+
tap: t,
1147+
registry: alternateRegistry,
1148+
authorization: 'test-other-token',
1149+
})
1150+
registry.nock.put(`/${pkg}`, body => {
1151+
return t.match(body, putPackagePayload({
1152+
pkg, alternateRegistry, version,
1153+
}))
1154+
}).reply(200, {})
1155+
await npm.exec('publish', [])
1156+
})
1157+
1158+
t.test('ALLOWS publish when latest dist-tag is missing from response', async t => {
1159+
const version = '100.0.0'
1160+
1161+
const { npm } = await loadMockNpm(t, {
1162+
config: {
1163+
loglevel: 'silent',
1164+
[`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token',
1165+
},
1166+
prefixDir: {
1167+
'package.json': JSON.stringify({
1168+
...pkgJson,
1169+
version,
1170+
publishConfig: { registry: alternateRegistry },
1171+
}, null, 2),
1172+
},
1173+
mocks: {
1174+
...mockNpmRegistryFetch({
1175+
[`/-/package/${pkg}/dist-tags`]: { },
1176+
}).mocks,
1177+
},
1178+
})
1179+
const registry = new MockRegistry({
1180+
tap: t,
1181+
registry: alternateRegistry,
1182+
authorization: 'test-other-token',
1183+
})
1184+
registry.nock.put(`/${pkg}`, body => {
1185+
return t.match(body, putPackagePayload({
1186+
pkg, alternateRegistry, version,
1187+
}))
1188+
}).reply(200, {})
1189+
await npm.exec('publish', [])
1190+
})

0 commit comments

Comments
 (0)