Skip to content

Commit c8351fc

Browse files
committed
feat!: no implicit latest tag on publish when latest > version
1 parent ec8b77c commit c8351fc

5 files changed

Lines changed: 172 additions & 6 deletions

File tree

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

0 commit comments

Comments
 (0)