diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fa6f8ca3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ + +# Created by https://www.gitignore.io/api/macos,windows,linux,node + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.gitignore.io/api/macos,windows,linux,node + +package-lock.json +yarn.lock diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..2cf6308c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +language: node_js +services: + - docker +notifications: + email: false +node_js: + - 9 + - 8 + +# Trigger a push build on master and greenkeeper branches + PRs build on every branches +# Avoid double build on PRs (See https://github.com/travis-ci/travis-ci/issues/1147) +branches: + only: + - master + - /^greenkeeper.*$/ + +# Retry install on fail to avoid failing a build on network/disk/external errors +install: + - travis_retry npm install + +script: + - npm run test + +after_success: + - npm run codecov + - npm run semantic-release diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..acaaffdb --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +--install.no-lockfile true diff --git a/LICENSE b/LICENSE index 8864d4a3..8e443427 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 +Copyright (c) 2017 Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 52b2b0ec..d96afa10 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ -# npm -Set of semantic-release plugins to publish to a npm registry +# @semantic-release/npm + +Set of [semantic-release](https://github.com/semantic-release/semantic-release) plugins for publishing to a [npm](https://www.npmjs.com/) registry. + +[![Travis](https://img.shields.io/travis/semantic-release/npm.svg)](https://travis-ci.org/semantic-release/npm) +[![Codecov](https://img.shields.io/codecov/c/github/semantic-release/npm.svg)](https://codecov.io/gh/semantic-release/npm) +[![Greenkeeper badge](https://badges.greenkeeper.io/semantic-release/npm.svg)](https://greenkeeper.io/) + +## verifyConditions + +Verify the presence of the `NPM_TOKEN` environment variable, create or update the `.npmrc` file with the token and verify the token is valid. + +## getLastRelease + +Determine the last release of the package on the `npm` registry. + +## publish + +Publish the package on the `npm` registry. + +## Configuration + +For each plugin, the `npm` authentication token has to be configured with the environment variable `NPM_TOKEN`. + +All the plugins are based on `npm` and will use the configuration from `.npmrc`. Any parameter returned by `npm config list` will be used by each plugin. + +The registry and dist-tag can be configured in the `package.json` and will take precedence on the configuration in `.npmrc`: +```json +{ + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "tag": "latest" + } +} +``` +The plugins are used by default by [semantic-release](https://github.com/semantic-release/semantic-release) so no specific configuration is requiered to use them. + +Each individual plugin can be disabled, replaced or used with other plugins in the `package.json`: +```json +{ + "release": { + "verifyConditions": ["@semantic-release/npm", "verify-other-condition"], + "getLastRelease": "custom-get-last-release", + "publish": ["@semantic-release/npm", "custom-publish"] + } +} +``` diff --git a/index.js b/index.js new file mode 100644 index 00000000..a44964a8 --- /dev/null +++ b/index.js @@ -0,0 +1,33 @@ +const {callbackify} = require('util'); +const verifyNpm = require('./lib/verify'); +const publishNpm = require('./lib/publish'); +const getLastReleaseNpm = require('./lib/get-last-release'); + +let verified; + +async function verifyConditions(pluginConfig, {pkg, logger}) { + await verifyNpm(pkg, logger); + verified = true; +} + +async function getLastRelease(pluginConfig, {pkg, logger}) { + if (!verified) { + await verifyNpm(pkg, logger); + verified = true; + } + return getLastReleaseNpm(pkg, logger); +} + +async function publish(pluginConfig, {pkg, nextRelease: {version}, logger}) { + if (!verified) { + await verifyNpm(pkg, logger); + verified = true; + } + await publishNpm(version, logger); +} + +module.exports = { + verifyConditions: callbackify(verifyConditions), + getLastRelease: callbackify(getLastRelease), + publish: callbackify(publish), +}; diff --git a/lib/get-client-config.js b/lib/get-client-config.js new file mode 100644 index 00000000..299e2c87 --- /dev/null +++ b/lib/get-client-config.js @@ -0,0 +1,26 @@ +module.exports = config => { + // Form https://github.com/npm/npm/blob/d081cc6c8d73f2aa698aab36605377c95e916224/lib/cache/caching-client.js#L194 + return { + proxy: { + http: config.get('proxy'), + https: config.get('https-proxy'), + localAddress: config.get('local-address'), + }, + ssl: { + certificate: config.get('cert'), + key: config.get('key'), + ca: config.get('ca'), + strict: config.get('strict-ssl'), + }, + retry: { + retries: parseInt(config.get('fetch-retries'), 10), + factor: parseInt(config.get('fetch-retry-factor'), 10), + minTimeout: parseInt(config.get('fetch-retry-mintimeout'), 10), + maxTimeout: parseInt(config.get('fetch-retry-maxtimeout'), 10), + }, + userAgent: config.get('user-agent'), + defaultTag: config.get('tag'), + couchToken: config.get('_token'), + maxSockets: parseInt(config.get('maxsockets'), 10), + }; +}; diff --git a/lib/get-last-release.js b/lib/get-last-release.js new file mode 100644 index 00000000..7f4dbcf1 --- /dev/null +++ b/lib/get-last-release.js @@ -0,0 +1,41 @@ +const {promisify} = require('util'); +const {resolve: urlResolve} = require('url'); +const npmConf = require('npm-conf'); +const RegClient = require('npm-registry-client'); +const getClientConfig = require('./get-client-config'); +const getRegistry = require('./get-registry'); + +module.exports = async ({publishConfig, name}, logger) => { + const config = npmConf(); + const tag = (publishConfig || {}).tag || config.get('tag'); + const {NPM_TOKEN, NPM_USERNAME, NPM_PASSWORD, NPM_EMAIL} = process.env; + const client = new RegClient(getClientConfig(config)); + const registry = await getRegistry(publishConfig, name); + + try { + const uri = urlResolve(registry, name.replace('/', '%2F')); + const data = await promisify(client.get.bind(client))(uri, { + auth: NPM_TOKEN ? {token: NPM_TOKEN} : {username: NPM_USERNAME, password: NPM_PASSWORD, email: NPM_EMAIL}, + }); + if (data && !data['dist-tags']) { + logger.log('No version found of package %s found on %s', name, registry); + return {}; + } + const distTags = data['dist-tags']; + let version; + if (distTags[tag]) { + version = distTags[tag]; + logger.log('Found version %s of package %s with dist-tag %s', version, name, tag); + } else { + version = distTags.latest; + logger.log('Found version %s of package %s with dist-tag %s', version, name, 'latest'); + } + return {version, gitHead: data.versions[version].gitHead}; + } catch (err) { + if (err.statusCode === 404 || /not found/i.test(err.message)) { + logger.log('No version found of package %s found on %s', name, registry); + return {}; + } + throw err; + } +}; diff --git a/lib/get-registry.js b/lib/get-registry.js new file mode 100644 index 00000000..d8da0c6c --- /dev/null +++ b/lib/get-registry.js @@ -0,0 +1,4 @@ +const getRegistryUrl = require('registry-auth-token/registry-url'); + +module.exports = async (publishConfig, name) => + publishConfig && publishConfig.registry ? publishConfig.registry : getRegistryUrl(name.split('/')[0]); diff --git a/lib/publish.js b/lib/publish.js new file mode 100644 index 00000000..d2db3e27 --- /dev/null +++ b/lib/publish.js @@ -0,0 +1,10 @@ +const execa = require('execa'); +const updatePackageVersion = require('./update-package-version'); + +module.exports = async (version, logger) => { + await updatePackageVersion(version, logger); + + logger.log('Publishing version %s to npm registry', version); + const shell = await execa('npm', ['publish']); + process.stdout.write(shell.stdout); +}; diff --git a/lib/set-npmrc-auth.js b/lib/set-npmrc-auth.js new file mode 100644 index 00000000..41c88b85 --- /dev/null +++ b/lib/set-npmrc-auth.js @@ -0,0 +1,29 @@ +const {appendFile} = require('fs-extra'); +const getAuthToken = require('registry-auth-token'); +const nerfDart = require('nerf-dart'); +const SemanticReleaseError = require('@semantic-release/error'); +const getRegistry = require('./get-registry'); + +module.exports = async ({publishConfig, name}, logger) => { + const registry = await getRegistry(publishConfig, name); + logger.log('Verify authentication for registry %s', registry); + const {NPM_TOKEN, NPM_USERNAME, NPM_PASSWORD, NPM_EMAIL} = process.env; + + if (getAuthToken(registry)) { + return; + } + if (NPM_USERNAME && NPM_PASSWORD && NPM_EMAIL) { + // Using the old auth token format is not considered part of the public API + // This might go away anytime (i.e. once we have a better testing strategy) + await appendFile( + './.npmrc', + `\n_auth = ${Buffer.from(`${NPM_USERNAME}:${NPM_PASSWORD}`, 'utf8').toString('base64')}\nemail = \${NPM_EMAIL}` + ); + logger.log('Wrote NPM_USERNAME, NPM_PASSWORD and NPM_EMAIL to .npmrc.'); + } else if (NPM_TOKEN) { + await appendFile('./.npmrc', `\n${nerfDart(registry)}:_authToken = \${NPM_TOKEN}`); + logger.log('Wrote NPM_TOKEN to .npmrc.'); + } else { + throw new SemanticReleaseError('No npm token specified.', 'ENONPMTOKEN'); + } +}; diff --git a/lib/update-package-version.js b/lib/update-package-version.js new file mode 100644 index 00000000..261010d6 --- /dev/null +++ b/lib/update-package-version.js @@ -0,0 +1,15 @@ +const {readJson, writeJson, pathExists} = require('fs-extra'); + +module.exports = async (version, logger) => { + const pkg = await readJson('./package.json'); + + await writeJson('./package.json', Object.assign(pkg, {version})); + logger.log('Wrote version %s to package.json', version); + + if (await pathExists('./npm-shrinkwrap.json')) { + const shrinkwrap = await readJson('./npm-shrinkwrap.json'); + shrinkwrap.version = version; + await writeJson('./npm-shrinkwrap.json', shrinkwrap); + logger.log('Wrote version %s to npm-shrinkwrap.json', version); + } +}; diff --git a/lib/verify-pkg.js b/lib/verify-pkg.js new file mode 100644 index 00000000..96d182cc --- /dev/null +++ b/lib/verify-pkg.js @@ -0,0 +1,7 @@ +const SemanticReleaseError = require('@semantic-release/error'); + +module.exports = ({name}) => { + if (!name) { + throw new SemanticReleaseError('No "name" found in package.json.', 'ENOPKGNAME'); + } +}; diff --git a/lib/verify.js b/lib/verify.js new file mode 100644 index 00000000..93974204 --- /dev/null +++ b/lib/verify.js @@ -0,0 +1,14 @@ +const verifyPkg = require('./verify-pkg'); +const setNpmrcAuth = require('./set-npmrc-auth'); +const execa = require('execa'); +const SemanticReleaseError = require('@semantic-release/error'); + +module.exports = async (pkg, logger) => { + verifyPkg(pkg); + await setNpmrcAuth(pkg, logger); + try { + await execa('npm', ['whoami']); + } catch (err) { + throw new SemanticReleaseError('Invalid npm token.', 'EINVALIDNPMTOKEN'); + } +}; diff --git a/package.json b/package.json new file mode 100644 index 00000000..9ea7f6e9 --- /dev/null +++ b/package.json @@ -0,0 +1,116 @@ +{ + "name": "@semantic-release/npm", + "description": "Set of semantic-release plugins to publish to a npm registry", + "version": "0.0.0-development", + "author": "Pierre Vanduynslager (https://twitter.com/@pvdlg_)", + "bugs": { + "url": "https://github.com/semantic-release/npm/issues" + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "contributors": [ + "Stephan Bönnemann (http://boennemann.me)", + "Gregor Martynus (https://twitter.com/gr2m)" + ], + "dependencies": { + "@semantic-release/error": "^2.1.0", + "execa": "^0.8.0", + "fs-extra": "^4.0.2", + "nerf-dart": "^1.0.0", + "npm-conf": "^1.1.3", + "npm-registry-client": "^8.5.0", + "registry-auth-token": "^3.3.1" + }, + "devDependencies": { + "ava": "^0.23.0", + "clear-module": "^2.1.0", + "codecov": "^3.0.0", + "commitizen": "^2.9.6", + "cz-conventional-changelog": "^2.0.0", + "delay": "^2.0.0", + "dockerode": "^2.5.3", + "eslint": "^4.11.0", + "eslint-config-prettier": "^2.5.0", + "eslint-config-standard": "^10.2.1", + "eslint-plugin-import": "^2.7.0", + "eslint-plugin-node": "^5.2.0", + "eslint-plugin-prettier": "^2.3.0", + "eslint-plugin-promise": "^3.5.0", + "eslint-plugin-standard": "^3.0.1", + "get-stream": "^3.0.0", + "got": "^8.0.0", + "nock": "^9.1.0", + "nyc": "^11.2.1", + "prettier": "~1.8.2", + "rimraf": "^2.5.0", + "semantic-release": "^9.1.1", + "sinon": "^4.1.2", + "tempy": "^0.2.1" + }, + "engines": { + "node": ">=4" + }, + "eslintConfig": { + "extends": [ + "standard", + "prettier" + ], + "plugins": [ + "prettier" + ], + "rules": { + "prettier/prettier": 2 + } + }, + "files": [ + "lib", + "index.js" + ], + "homepage": "https://github.com/semantic-release/npm#readme", + "keywords": [ + "npm", + "publish", + "registry", + "semantic-release", + "version" + ], + "license": "MIT", + "main": "index.js", + "nyc": { + "include": [ + "lib/**/*.js", + "index.js" + ], + "reporter": [ + "json", + "text", + "html" + ], + "all": true + }, + "prettier": { + "printWidth": 120, + "singleQuote": true, + "bracketSpacing": false, + "trailingComma": "es5" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/semantic-release/npm.git" + }, + "scripts": { + "clean": "rimraf coverage && rimraf .nyc_output", + "cm": "git-cz", + "codecov": "codecov -f coverage/coverage-final.json", + "lint": "eslint test lib index.js", + "pretest": "npm run clean && npm run lint", + "semantic-release": "semantic-release", + "test": "nyc ava -v" + } +} diff --git a/test/get-last-release.test.js b/test/get-last-release.test.js new file mode 100644 index 00000000..d87f9142 --- /dev/null +++ b/test/get-last-release.test.js @@ -0,0 +1,161 @@ +import {appendFile} from 'fs-extra'; +import test from 'ava'; +import nock from 'nock'; +import {stub} from 'sinon'; +import tempy from 'tempy'; +import {registry, mock, available, unpublished} from './helpers/mock-registry'; +import lastRelease from '../lib/get-last-release'; + +let processStdout; +let processStderr; + +test.before(t => { + // Disable npm logger during tests + processStdout = stub(process.stdout, 'write'); + processStderr = stub(process.stderr, 'write'); +}); + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + process.env.NPM_TOKEN = 'npm_token'; + // Delete all `npm_config` environment variable set by CI as they take precedence over the `.npmrc` because the process that runs the tests is started before the `.npmrc` is created + for (let i = 0, keys = Object.keys(process.env); i < keys.length; i++) { + if (keys[i].startsWith('npm_config')) { + delete process.env[keys[i]]; + } + } + // Save the current working diretory + t.context.cwd = process.cwd(); + // Change current working directory to a temp directory + process.chdir(tempy.directory()); + // Stub the logger + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Clear nock + nock.cleanAll(); + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.after.always(t => { + // Restore stdout and stderr + processStdout.restore(); + processStderr.restore(); +}); + +test.serial('Get release from package name', async t => { + const name = 'available'; + const registryMock = available(name); + const release = await lastRelease({name, publishConfig: {registry}}, t.context.logger); + + t.is(release.version, '1.33.7'); + t.is(release.gitHead, 'HEAD'); + t.true(registryMock.isDone()); +}); + +test.serial("Get release from a tagged package's name", async t => { + const name = 'tagged'; + const registryMock = available(name); + const release = await lastRelease({name, publishConfig: {registry, tag: 'foo'}}, t.context.logger); + + t.is(release.version, '0.8.15'); + t.is(release.gitHead, 'bar'); + t.true(registryMock.isDone()); +}); + +test.serial('Get release from the latest fallback tag', async t => { + const name = 'tagged'; + const registryMock = available(name); + const release = await lastRelease({name, publishConfig: {registry, tag: 'bar'}}, t.context.logger); + + t.is(release.version, '1.33.7'); + t.is(release.gitHead, 'HEAD'); + t.true(registryMock.isDone()); +}); + +test.serial('Get release from scoped package name', async t => { + const name = '@scoped/available'; + const registryMock = available(name); + + const release = await lastRelease({name, publishConfig: {registry}}, t.context.logger); + t.is(release.version, '1.33.7'); + t.is(release.gitHead, 'HEAD'); + t.true(registryMock.isDone()); +}); + +test.serial('Get nothing from completely unpublished package name', async t => { + const name = 'completely-unpublished'; + const registryMock = unpublished(name); + const release = await lastRelease({name, publishConfig: {registry}}, t.context.logger); + + t.is(release.version, undefined); + t.true(registryMock.isDone()); +}); + +test.serial('Get nothing from not yet published package name (unavailable)', async t => { + const name = 'unavailable'; + const registryMock = mock(name).reply(404, {}); + const release = await lastRelease({name, publishConfig: {registry}}, t.context.logger); + + t.is(release.version, undefined); + t.true(registryMock.isDone()); +}); + +test.serial('Get nothing from not yet published package name (unavailable w/o response body)', async t => { + const name = 'unavailable-no-body'; + const registryMock = mock(name).reply(404); + const release = await lastRelease({name, publishConfig: {registry}}, t.context.logger); + + t.is(release.version, undefined); + t.true(registryMock.isDone()); +}); + +test.serial('Get registry from ".npmrc"', async t => { + const name = 'available'; + const registryMock = available(name); + await appendFile('./.npmrc', `registry = ${registry}`); + const release = await lastRelease({name}, t.context.logger); + + t.is(release.version, '1.33.7'); + t.is(release.gitHead, 'HEAD'); + t.true(registryMock.isDone()); +}); + +test.serial('Get nothing from not yet published package name (unavailable w/o status code)', async t => { + const name = 'unavailable-no-404'; + const registryMock = mock(name) + .times(3) + .replyWithError({message: 'not found', statusCode: 500, code: 'E500'}); + await appendFile('./.npmrc', 'fetch-retry-factor = 1\nfetch-retry-mintimeout = 1\nfetch-retry-maxtimeout = 1'); + const release = await lastRelease({name, publishConfig: {registry}}, t.context.logger); + + t.is(release.version, undefined); + t.true(registryMock.isDone()); +}); + +test.serial('Throws error on server error', async t => { + const name = 'server-error'; + const registryMock = mock(name) + .times(3) + .reply(500); + await appendFile('./.npmrc', 'fetch-retry-factor = 1\nfetch-retry-mintimeout = 1\nfetch-retry-maxtimeout = 1'); + await t.throws(lastRelease({name, publishConfig: {registry}}, t.context.logger), /500 Internal Server Error/); + + t.true(registryMock.isDone()); +}); + +test.serial('Handle missing trailing slash on registry URL', async t => { + const name = 'available'; + const registryMock = available(name); + const release = await lastRelease({name, publishConfig: {registry: 'http://registry.npmjs.org'}}, t.context.logger); + + t.is(release.version, '1.33.7'); + t.is(release.gitHead, 'HEAD'); + t.true(registryMock.isDone()); +}); diff --git a/test/helpers/mock-registry.js b/test/helpers/mock-registry.js new file mode 100644 index 00000000..b1b196c5 --- /dev/null +++ b/test/helpers/mock-registry.js @@ -0,0 +1,25 @@ +import nock from 'nock'; + +const REGISTRY_URL = 'http://registry.npmjs.org/'; +const availableModule = { + 'dist-tags': {latest: '1.33.7', foo: '0.8.15'}, + versions: {'0.8.15': {gitHead: 'bar'}, '1.33.7': {gitHead: 'HEAD'}}, +}; +const unpublishedModule = { + name: 'i-am-completely-unpublished', + time: {'2.0.0': '2016-12-01T17:50:30.699Z', unpublished: {time: '2016-12-01T17:53:45.940Z'}}, +}; + +export const registry = REGISTRY_URL; + +export function mock(packageName) { + return nock(REGISTRY_URL).get(`/${packageName.replace('/', '%2F')}`); +} + +export function available(packageName) { + return mock(packageName).reply(200, availableModule); +} + +export function unpublished(packageName) { + return mock(packageName).reply(200, unpublishedModule); +} diff --git a/test/helpers/npm-registry.js b/test/helpers/npm-registry.js new file mode 100644 index 00000000..842afe6e --- /dev/null +++ b/test/helpers/npm-registry.js @@ -0,0 +1,55 @@ +import Docker from 'dockerode'; +import getStream from 'get-stream'; +import got from 'got'; +import delay from 'delay'; + +const SERVER_PORT = 5984; +const SERVER_HOST = 'localhost'; +const NPM_USERNAME = 'integration'; +const NPM_PASSWORD = 'suchsecure'; +const NPM_EMAIL = 'integration@test.com'; +const docker = new Docker(); +let container; + +async function start() { + await getStream(await docker.pull('npmjs/npm-docker-couchdb:1.6.1')); + + container = await docker.createContainer({ + Image: 'npmjs/npm-docker-couchdb:1.6.1', + PortBindings: {[`${SERVER_PORT}/tcp`]: [{HostPort: `${SERVER_PORT}`}]}, + }); + + await container.start(); + // Add a delay as the registry take some time to be ready even if the docker container is started + await delay(5000); + + // Create user + await got(`http://${SERVER_HOST}:${SERVER_PORT}/_users/org.couchdb.user:${NPM_USERNAME}`, { + json: true, + auth: 'admin:admin', + method: 'PUT', + body: { + _id: `org.couchdb.user:${NPM_USERNAME}`, + name: NPM_USERNAME, + roles: [], + type: 'user', + password: NPM_PASSWORD, + email: NPM_EMAIL, + }, + }); +} + +const url = `http://${SERVER_HOST}:${SERVER_PORT}/registry/_design/app/_rewrite/`; + +const authEnv = { + npm_config_registry: url, + NPM_USERNAME, + NPM_PASSWORD, + NPM_EMAIL, +}; + +async function stop() { + return container.stop(); +} + +export default {start, stop, authEnv, url}; diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 00000000..1107fad1 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,184 @@ +import {promisify} from 'util'; +import {writeJson, readFile, appendFile} from 'fs-extra'; +import test from 'ava'; +import execa from 'execa'; +import {stub} from 'sinon'; +import tempy from 'tempy'; +import clearModule from 'clear-module'; +import SemanticReleaseError from '@semantic-release/error'; +import npmRegistry from './helpers/npm-registry'; + +let processStderr; +let processStdout; + +test.before(async t => { + // Start the local NPM registry + await npmRegistry.start(); + // Disable npm logger during tests + processStderr = stub(process.stderr, 'write'); + processStdout = stub(process.stdout, 'write'); +}); + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env paramaters that could have been set on the machine running the tests + delete process.env.NPM_TOKEN; + delete process.env.NPM_USERNAME; + delete process.env.NPM_PASSWORD; + delete process.env.NPM_EMAIL; + // Delete all `npm_config` environment variable set by CI as they take precedence over the `.npmrc` because the process that runs the tests is started before the `.npmrc` is created + for (let i = 0, keys = Object.keys(process.env); i < keys.length; i++) { + if (keys[i].startsWith('npm_config')) { + delete process.env[keys[i]]; + } + } + // Save the current working diretory + t.context.cwd = process.cwd(); + // Change current working directory to a temp directory + process.chdir(tempy.directory()); + // Prevent to use `.npmrc` from the home directory in case there is a valid token set there + process.env.HOME = process.cwd(); + process.env.USERPROFILE = process.cwd(); + // Clear npm cache to refresh the module state + clearModule('../index'); + t.context.m = require('../index'); + // Stub the logger + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(async t => { + // Clear `rc` from the npm cache as it cache the relative path of .npmrc files, preventing to load a new file after changing current working directory + clearModule('rc'); + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.after.always(async t => { + // Stop the local NPM registry + await npmRegistry.stop(); + // Restore stdout and stderr + processStderr.restore(); + processStdout.restore(); +}); + +test.serial('Throws error if NPM token is invalid', async t => { + process.env.NPM_TOKEN = 'wrong_token'; + const error = await t.throws( + promisify(t.context.m.verifyConditions)({}, {pkg: {name: 'invalid-token'}, logger: t.context.logger}) + ); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'EINVALIDNPMTOKEN'); + t.is(error.message, 'Invalid npm token.'); + + const npmrc = (await readFile('.npmrc')).toString(); + t.regex(npmrc, /:_authToken/); +}); + +test.serial('Verify npm auth and package', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'valid-token', publishConfig: {registry: npmRegistry.url}}; + await t.notThrows(promisify(t.context.m.verifyConditions)({}, {pkg, logger: t.context.logger})); + + const npmrc = (await readFile('.npmrc')).toString(); + t.regex(npmrc, /_auth =/); + t.regex(npmrc, /email =/); +}); + +test.serial('Return nothing if no version if published', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'not-published', publishConfig: {registry: npmRegistry.url}}; + const nextRelease = await promisify(t.context.m.getLastRelease)({}, {pkg, logger: t.context.logger}); + + t.deepEqual(nextRelease, {}); +}); + +test.serial('Return last version published', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'published', version: '1.0.0', publishConfig: {registry: npmRegistry.url}}; + await writeJson('./package.json', pkg); + + await appendFile( + './.npmrc', + `_auth = ${Buffer.from(`${process.env.NPM_USERNAME}:${process.env.NPM_PASSWORD}`, 'utf8').toString( + 'base64' + )}\nemail = ${process.env.NPM_EMAIL}` + ); + + await execa('npm', ['publish']); + + const nextRelease = await promisify(t.context.m.getLastRelease)({}, {pkg, logger: t.context.logger}); + t.is(nextRelease.version, '1.0.0'); +}); + +test.serial('Return last version published on a dist-tag', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'published-next', version: '1.0.0', publishConfig: {registry: npmRegistry.url, tag: 'next'}}; + await writeJson('./package.json', pkg); + + await appendFile( + './.npmrc', + `_auth = ${Buffer.from(`${process.env.NPM_USERNAME}:${process.env.NPM_PASSWORD}`, 'utf8').toString( + 'base64' + )}\nemail = ${process.env.NPM_EMAIL}` + ); + + // Publish version 1.0.0 on latest and next + await execa('npm', ['publish', '--tag=next']); + pkg.version = '1.1.0'; + await writeJson('./package.json', pkg); + // Publish version 1.1.0 on next + await execa('npm', ['publish', '--tag=next']); + + const nextRelease = await promisify(t.context.m.getLastRelease)({}, {pkg, logger: t.context.logger}); + t.is(nextRelease.version, '1.1.0'); +}); + +test.serial('Return nothing for an unpublished package', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'unpublished', version: '1.0.0', publishConfig: {registry: npmRegistry.url}}; + await writeJson('./package.json', pkg); + + await appendFile( + './.npmrc', + `_auth = ${Buffer.from(`${process.env.NPM_USERNAME}:${process.env.NPM_PASSWORD}`, 'utf8').toString( + 'base64' + )}\nemail = ${process.env.NPM_EMAIL}` + ); + + await execa('npm', ['publish']); + await execa('npm', ['unpublish', 'unpublished', '--force']); + + const nextRelease = await promisify(t.context.m.getLastRelease)({}, {pkg, logger: t.context.logger}); + t.deepEqual(nextRelease, {}); +}); + +test.serial('Publish a package', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'publish', version: '1.0.0', publishConfig: {registry: npmRegistry.url}}; + await writeJson('./package.json', pkg); + + await promisify(t.context.m.publish)({}, {pkg, logger: t.context.logger, nextRelease: {version: '1.0.0'}}); + + t.is((await execa('npm', ['view', 'publish', 'version'])).stdout, '1.0.0'); +}); + +test.serial('Verify token and set up auth only on the fist call', async t => { + Object.assign(process.env, npmRegistry.authEnv); + const pkg = {name: 'test-module', version: '0.0.0-dev', publishConfig: {registry: npmRegistry.url}}; + await writeJson('./package.json', pkg); + + await t.notThrows(promisify(t.context.m.verifyConditions)({}, {pkg, logger: t.context.logger})); + + let nextRelease = await promisify(t.context.m.getLastRelease)({}, {pkg, logger: t.context.logger}); + t.deepEqual(nextRelease, {}); + + await promisify(t.context.m.publish)({}, {pkg, logger: t.context.logger, nextRelease: {version: '1.0.0'}}); + + nextRelease = await promisify(t.context.m.getLastRelease)({}, {pkg, logger: t.context.logger}); + t.is(nextRelease.version, '1.0.0'); +}); diff --git a/test/set-npmrc-auth.test.js b/test/set-npmrc-auth.test.js new file mode 100644 index 00000000..8de8b487 --- /dev/null +++ b/test/set-npmrc-auth.test.js @@ -0,0 +1,134 @@ +import {readFile, appendFile} from 'fs-extra'; +import test from 'ava'; +import {stub} from 'sinon'; +import tempy from 'tempy'; +import clearModule from 'clear-module'; +import SemanticReleaseError from '@semantic-release/error'; +import setNpmrcAuth from '../lib/set-npmrc-auth'; + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env paramaters that could have been set on the machine running the tests + delete process.env.NPM_TOKEN; + delete process.env.NPM_USERNAME; + delete process.env.NPM_PASSWORD; + delete process.env.NPM_EMAIL; + // Save the current working diretory + t.context.cwd = process.cwd(); + // Change current working directory to a temp directory + process.chdir(tempy.directory()); + // Prevent to use `.npmrc` from the home directory in case there is a valid token set there + process.env.HOME = process.cwd(); + process.env.USERPROFILE = process.cwd(); + // Stub the logger + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(t => { + // Clear `rc` from the npm cache as it cache the relative path of .npmrc files, preventing to load a new file after changing current working directory + clearModule('rc'); + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.serial('Set auth with "NPM_TOKEN" and default registry', async t => { + process.env.NPM_TOKEN = 'npm_token'; + await setNpmrcAuth({name: 'package-name'}, t.context.logger); + + const npmrc = (await readFile('.npmrc')).toString(); + t.regex(npmrc, /\/\/registry.npmjs.org\/:_authToken = \$\{NPM_TOKEN\}/); + t.true(t.context.log.calledWith('Wrote NPM_TOKEN to .npmrc.')); +}); + +test.serial('Set auth with "NPM_TOKEN" and custom registry', async t => { + process.env.NPM_TOKEN = 'npm_token'; + await setNpmrcAuth({name: 'package-name', publishConfig: {registry: 'http://custom.registry.com'}}, t.context.logger); + + const npmrc = (await readFile('.npmrc')).toString(); + t.regex(npmrc, /\/\/custom.registry.com\/:_authToken = \$\{NPM_TOKEN\}/); + t.true(t.context.log.calledWith('Wrote NPM_TOKEN to .npmrc.')); +}); + +test.serial('Set auth with "NPM_USERNAME", "NPM_PASSWORD" and "NPM_EMAIL"', async t => { + process.env.NPM_USERNAME = 'npm_username'; + process.env.NPM_PASSWORD = 'npm_pasword'; + process.env.NPM_EMAIL = 'npm_email'; + + await setNpmrcAuth({name: 'package-name'}, t.context.logger); + + const npmrc = (await readFile('.npmrc')).toString(); + t.regex( + npmrc, + new RegExp( + `_auth = ${Buffer.from('npm_username:npm_pasword', 'utf8').toString('base64')}\\W+email = \\\${NPM_EMAIL}` + ) + ); + + t.true(t.context.log.calledWith('Wrote NPM_USERNAME, NPM_PASSWORD and NPM_EMAIL to .npmrc.')); +}); + +test.serial('Do not modify ".npmrc" if auth is already configured', async t => { + await appendFile('./.npmrc', `//registry.npmjs.org/:_authToken = \${NPM_TOKEN}`); + await setNpmrcAuth({name: 'package-name'}, t.context.logger); + + t.true(t.context.log.calledOnce); +}); + +test.serial('Do not modify ".npmrc" if auth is already configured with custom registry', async t => { + await appendFile('./.npmrc', `//custom.registry.com/:_authToken = \${NPM_TOKEN}`); + await setNpmrcAuth({name: 'package-name', publishConfig: {registry: 'http://custom.registry.com'}}, t.context.logger); + + t.true(t.context.log.calledOnce); +}); + +test.serial('Do not modify ".npmrc" is auth is already configured for a scoped package', async t => { + await appendFile( + './.npmrc', + `@scope:registry=http://custom.registry.com\n//custom.registry.com/:_authToken = \${NPM_TOKEN}` + ); + await setNpmrcAuth({name: '@scope/package-name'}, t.context.logger); + + t.true(t.context.log.calledOnce); +}); + +test.serial('Throw error if "NPM_TOKEN" is missing', async t => { + const error = await t.throws(setNpmrcAuth({name: 'package-name'}, t.context.logger)); + + t.true(error instanceof SemanticReleaseError); + t.is(error.message, 'No npm token specified.'); + t.is(error.code, 'ENONPMTOKEN'); +}); + +test.serial('Throw error if "NPM_USERNAME" is missing', async t => { + process.env.NPM_PASSWORD = 'npm_pasword'; + process.env.NPM_EMAIL = 'npm_email'; + const error = await t.throws(setNpmrcAuth({name: 'package-name'}, t.context.logger)); + + t.true(error instanceof SemanticReleaseError); + t.is(error.message, 'No npm token specified.'); + t.is(error.code, 'ENONPMTOKEN'); +}); + +test.serial('Throw error if "NPM_USERNAME" is missing', async t => { + process.env.NPM_USERNAME = 'npm_username'; + process.env.NPM_EMAIL = 'npm_email'; + const error = await t.throws(setNpmrcAuth({name: 'package-name'}, t.context.logger)); + + t.true(error instanceof SemanticReleaseError); + t.is(error.message, 'No npm token specified.'); + t.is(error.code, 'ENONPMTOKEN'); +}); + +test.serial('Throw error if "NPM_EMAIL" is missing', async t => { + process.env.NPM_USERNAME = 'npm_username'; + process.env.NPM_PASSWORD = 'npm_password'; + const error = await t.throws(setNpmrcAuth({name: 'package-name'}, t.context.logger)); + + t.true(error instanceof SemanticReleaseError); + t.is(error.message, 'No npm token specified.'); + t.is(error.code, 'ENONPMTOKEN'); +}); diff --git a/test/update-package-version.test.js b/test/update-package-version.test.js new file mode 100644 index 00000000..f5ce5f81 --- /dev/null +++ b/test/update-package-version.test.js @@ -0,0 +1,47 @@ +import {writeJson, readJson} from 'fs-extra'; +import test from 'ava'; +import tempy from 'tempy'; +import execa from 'execa'; +import {stub} from 'sinon'; +import updatePackageVersion from '../lib/update-package-version'; + +test.beforeEach(async t => { + t.context.cwd = process.cwd(); + process.chdir(tempy.directory()); + + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(t => { + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.serial('Updade package.json', async t => { + // Create package.json in repository root + await writeJson('./package.json', {version: '0.0.0-dev'}); + + await updatePackageVersion('1.0.0', t.context.logger); + + // Verify package.json has been updated + t.is((await readJson('./package.json')).version, '1.0.0'); + // Verify the logger has been called with the version updated + t.true(t.context.log.calledWithMatch(/package.json/, '1.0.0')); +}); + +test.serial('Updade package.json and npm-shrinkwrap.json', async t => { + // Create package.json in repository root + await writeJson('./package.json', {version: '0.0.0-dev'}); + // Create a npm-shrinkwrap.json file + await execa('npm', ['shrinkwrap']); + + await updatePackageVersion('1.0.0', t.context.logger); + + // Verify package.json and npm-shrinkwrap.json have been updated + t.is((await readJson('./package.json')).version, '1.0.0'); + t.is((await readJson('./npm-shrinkwrap.json')).version, '1.0.0'); + // Verify the logger has been called with the version updated + t.true(t.context.log.calledWithMatch(/package.json/, '1.0.0')); + t.true(t.context.log.calledWithMatch(/npm-shrinkwrap.json/, '1.0.0')); +}); diff --git a/test/verify-pkg.test.js b/test/verify-pkg.test.js new file mode 100644 index 00000000..edd325b0 --- /dev/null +++ b/test/verify-pkg.test.js @@ -0,0 +1,14 @@ +import test from 'ava'; +import SemanticReleaseError from '@semantic-release/error'; +import verify from '../lib/verify-pkg'; + +test('Verify name and repository', t => { + t.notThrows(() => verify({name: 'package'})); +}); + +test('Return error for missing package name', t => { + const error = t.throws(() => verify({repository: {url: 'http://github.com/whats/up.git'}})); + + t.true(error instanceof SemanticReleaseError); + t.is(error.code, 'ENOPKGNAME'); +});