Skip to content

Commit

Permalink
feat!: no implicit latest tag on publish when latest > version
Browse files Browse the repository at this point in the history
  • Loading branch information
reggi committed Nov 26, 2024
1 parent ec8b77c commit c8351fc
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 6 deletions.
8 changes: 4 additions & 4 deletions lib/commands/dist-tag.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 }
Expand Down
18 changes: 18 additions & 0 deletions lib/commands/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}`)

Expand Down Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions smoke-tests/test/npm-replace-global.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
30 changes: 29 additions & 1 deletion test/fixtures/mock-npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -459,14 +459,17 @@ 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()
}
return find
}
const nrf = async (url, opts) => {
return {
json: getRequest(url, opts),
json: () => getRequest(url, opts),
}
}
const mock = Object.assign(nrf, npmFetch, { json: getRequest })
Expand All @@ -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
121 changes: 120 additions & 1 deletion test/lib/commands/publish.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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: {
Expand Down Expand Up @@ -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', [])
})

0 comments on commit c8351fc

Please sign in to comment.