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 1747c7a
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 2 deletions.
18 changes: 18 additions & 0 deletions lib/commands/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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 +203,17 @@ class Publish extends BaseCommand {
}
}

async #latestDistTag (spec) {
try {
const request = await npmFetch(`/-/package/${spec.escapedName}/dist-tags`)
const json = await request.json()
return json.latest || null
} 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
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 1747c7a

Please sign in to comment.