diff --git a/lib/commands/dist-tag.js b/lib/commands/dist-tag.js index 3fdecd926a564..4274b982d32fb 100644 --- a/lib/commands/dist-tag.js +++ b/lib/commands/dist-tag.js @@ -102,7 +102,7 @@ class DistTag extends BaseCommand { throw new Error('Tag name must not be a valid SemVer range: ' + t) } - const tags = await this.fetchTags(spec, opts) + const tags = await DistTag.fetchTags(spec, opts) if (tags[t] === version) { log.warn('dist-tag add', t, 'is already set to version', version) return @@ -131,7 +131,7 @@ class DistTag extends BaseCommand { throw this.usageError() } - const tags = await this.fetchTags(spec, opts) + const tags = await DistTag.fetchTags(spec, opts) if (!tags[tag]) { log.info('dist-tag del', tag, 'is not a dist-tag on', spec.name) throw new Error(tag + ' is not a dist-tag on ' + spec.name) @@ -164,7 +164,7 @@ class DistTag extends BaseCommand { spec = npa(spec) try { - const tags = await this.fetchTags(spec, opts) + const tags = await DistTag.fetchTags(spec, opts) const msg = Object.keys(tags).map(k => `${k}: ${tags[k]}`).sort().join('\n') output.standard(msg) @@ -190,7 +190,7 @@ class DistTag extends BaseCommand { } } - async fetchTags (spec, opts) { + static async fetchTags (spec, opts) { const data = await npmFetch.json( `/-/package/${spec.escapedName}/dist-tags`, { ...opts, 'prefer-online': true, spec } diff --git a/lib/commands/publish.js b/lib/commands/publish.js index d15bb5a2eb272..7be3cfe1d0f0d 100644 --- a/lib/commands/publish.js +++ b/lib/commands/publish.js @@ -16,6 +16,7 @@ const { getContents, logTar } = require('../utils/tar.js') const { flatten } = require('@npmcli/config/lib/definitions') const pkgJson = require('@npmcli/package-json') const BaseCommand = require('../base-cmd.js') +const DistTag = require('./dist-tag.js') class Publish extends BaseCommand { static description = 'Publish a package' @@ -131,6 +132,13 @@ class Publish extends BaseCommand { const resolved = npa.resolve(manifest.name, manifest.version) + const latestDistTag = await this.#latestDistTag(resolved) + const latestTagIsGreater = latestDistTag ? semver.gte(latestDistTag, manifest.version) : false + + if (latestTagIsGreater && isDefaultTag) { + throw new Error('Cannot publish a lower version without an explicit dist tag.') + } + // make sure tag is valid, this will throw if invalid npa(`${manifest.name}@${defaultTag}`) @@ -196,6 +204,16 @@ class Publish extends BaseCommand { } } + async #latestDistTag (spec) { + try { + const tags = await DistTag.fetchTags(spec, this.npm.flatOptions) + return tags.latest + } catch (_e) { + // this will fail if the package is new, so just return null + return null + } + } + // if it's a directory, read it from the file system // otherwise, get the full metadata from whatever it is // XXX can't pacote read the manifest from a directory? diff --git a/smoke-tests/test/npm-replace-global.js b/smoke-tests/test/npm-replace-global.js index 3514549aa40cd..4dbb6dbf3152b 100644 --- a/smoke-tests/test/npm-replace-global.js +++ b/smoke-tests/test/npm-replace-global.js @@ -136,6 +136,7 @@ t.test('publish and replace global self', async t => { if (setup.SMOKE_PUBLISH) { await npmPackage() } + registry.nock.get('/-/package/npm/dist-tags').reply(404, 'not found') registry.nock.put('/npm', body => { if (body._id === 'npm' && body.versions[version]) { publishedPackument = body.versions[version] diff --git a/test/fixtures/mock-npm.js b/test/fixtures/mock-npm.js index 6b47079b5da8a..486f8e75104b6 100644 --- a/test/fixtures/mock-npm.js +++ b/test/fixtures/mock-npm.js @@ -459,6 +459,9 @@ const mockNpmRegistryFetch = (tags) => { fetchOpts[url] = [opts] } const find = ({ ...tags })[url] + if (!find) { + throw new Error(`no npm-registry-fetch mock for ${url}`) + } if (typeof find === 'function') { return find() } @@ -466,7 +469,7 @@ const mockNpmRegistryFetch = (tags) => { } const nrf = async (url, opts) => { return { - json: getRequest(url, opts), + json: () => getRequest(url, opts), } } const mock = Object.assign(nrf, npmFetch, { json: getRequest }) @@ -475,9 +478,34 @@ const mockNpmRegistryFetch = (tags) => { return { mocks, mock, fetchOpts, getOpts } } +const putPackagePayload = ({ pkg, alternateRegistry, version }) => ({ + _id: pkg, + name: pkg, + 'dist-tags': { latest: version }, + access: null, + versions: { + [version]: { + name: pkg, + version: version, + _id: `${pkg}@${version}`, + dist: { + shasum: /\.*/, + tarball: `http:${alternateRegistry.slice(6)}/test-package/-/test-package-${version}.tgz`, + }, + publishConfig: { + registry: alternateRegistry, + }, + }, + }, + _attachments: { + [`${pkg}-${version}.tgz`]: {}, + }, +}) + module.exports = setupMockNpm module.exports.load = setupMockNpm module.exports.setGlobalNodeModules = setGlobalNodeModules module.exports.loadNpmWithRegistry = loadNpmWithRegistry module.exports.workspaceMock = workspaceMock module.exports.mockNpmRegistryFetch = mockNpmRegistryFetch +module.exports.putPackagePayload = putPackagePayload diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index d187ae5fec80a..b95d8a95cd387 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1,5 +1,8 @@ const t = require('tap') -const { load: loadMockNpm } = require('../../fixtures/mock-npm') +const { + load: originalLoadMockNpm, + mockNpmRegistryFetch, + putPackagePayload } = require('../../fixtures/mock-npm') const { cleanZlib } = require('../../fixtures/clean-snapshot') const MockRegistry = require('@npmcli/mock-registry') const pacote = require('pacote') @@ -22,6 +25,20 @@ const pkgJson = { t.cleanSnapshot = data => cleanZlib(data) +function loadMockNpm (test, args) { + return originalLoadMockNpm(test, { + ...args, + mocks: { + ...mockNpmRegistryFetch({ + [`/-/package/${pkg}/dist-tags`]: () => { + throw new Error('not found') + }, + }).mocks, + ...args.mocks, + }, + }) +} + t.test('respects publishConfig.registry, runs appropriate scripts', async t => { const { npm, joinedOutput, prefix } = await loadMockNpm(t, { config: { @@ -1068,3 +1085,105 @@ t.test('does not abort when prerelease and authored tag latest', async t => { }).reply(200, {}) await npm.exec('publish', []) }) + +t.test('PREVENTS publish when latest dist-tag is HIGHER than publishing version', async t => { + const latest = '100.0.0' + const version = '50.0.0' + + const { npm } = await loadMockNpm(t, { + config: { + loglevel: 'silent', + [`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token', + }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + version, + scripts: { + prepublishOnly: 'touch scripts-prepublishonly', + prepublish: 'touch scripts-prepublish', // should NOT run this one + publish: 'touch scripts-publish', + postpublish: 'touch scripts-postpublish', + }, + publishConfig: { registry: alternateRegistry }, + }, null, 2), + }, + mocks: { + ...mockNpmRegistryFetch({ + [`/-/package/${pkg}/dist-tags`]: { latest }, + }).mocks, + }, + }) + await t.rejects(async () => { + await npm.exec('publish', []) + }, new Error('Cannot publish a lower version without an explicit dist tag.')) +}) + +t.test('ALLOWS publish when latest dist-tag is LOWER than publishing version', async t => { + const version = '100.0.0' + const latest = '50.0.0' + + const { npm } = await loadMockNpm(t, { + config: { + loglevel: 'silent', + [`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token', + }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + version, + publishConfig: { registry: alternateRegistry }, + }, null, 2), + }, + mocks: { + ...mockNpmRegistryFetch({ + [`/-/package/${pkg}/dist-tags`]: { latest }, + }).mocks, + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: alternateRegistry, + authorization: 'test-other-token', + }) + registry.nock.put(`/${pkg}`, body => { + return t.match(body, putPackagePayload({ + pkg, alternateRegistry, version, + })) + }).reply(200, {}) + await npm.exec('publish', []) +}) + +t.test('ALLOWS publish when latest dist-tag is missing from response', async t => { + const version = '100.0.0' + + const { npm } = await loadMockNpm(t, { + config: { + loglevel: 'silent', + [`${alternateRegistry.slice(6)}/:_authToken`]: 'test-other-token', + }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + version, + publishConfig: { registry: alternateRegistry }, + }, null, 2), + }, + mocks: { + ...mockNpmRegistryFetch({ + [`/-/package/${pkg}/dist-tags`]: { }, + }).mocks, + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: alternateRegistry, + authorization: 'test-other-token', + }) + registry.nock.put(`/${pkg}`, body => { + return t.match(body, putPackagePayload({ + pkg, alternateRegistry, version, + })) + }).reply(200, {}) + await npm.exec('publish', []) +})