diff --git a/README.md b/README.md index 426238af..f01d566e 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ This removes the immediate connection between human emotions and version numbers “We fail to follow SemVer – and why it needn’t matter” - “semantic-release Q&A with Kent C. Dodds” + “semantic-release Q&A with Kent C. Dodds” @@ -91,11 +91,8 @@ When pushing new commits with `git push` a CI build is triggered. After running | Get last release | Obtain last release with the [getLastRelease](#getlastrelease) plugin | | Analyze commits | Determine the type of release to do with the [analyzeCommits](#analyzecommits) plugin | | Verify release | Call the [verifyRelease](#verifyrelease) plugin | -| npm publish | Update the version in `package.json` and call `npm publish` | | Generate notes | Generate release notes with plugin [generateNotes](#generatenotes) | -| Github release | A git tag and [Github release](https://help.github.com/articles/about-releases/) is created | - -_Note:_ The current release/tag implementation is tied to GitHub, but could be opened up to Bitbucket, GitLab, et al. Feel free to send PRs for these services. +| Publish | Call the [publish](#publish) plugin | ## Default Commit Message Format @@ -172,9 +169,6 @@ These options are currently available: - `branch`: The branch on which releases should happen. Default: `'master'` - `dry-run`: Dry-run mode, skipping verifyConditions, publishing and release, printing next version and release notes - `debug`: Output debugging information -- `githubToken`: The token used to authenticate with GitHub. Default: `process.env.GH_TOKEN` -- `githubUrl`: Optional. Pass your GitHub Enterprise endpoint. -- `githubApiPathPrefix`: Optional. The path prefix for your GitHub Enterprise API. _A few notes on `npm` config_: 1. The `npm` token can only be defined in the environment as `NPM_TOKEN`, because that’s where `npm` itself is going to read it from. @@ -183,8 +177,6 @@ _A few notes on `npm` config_: 3. If you want to use another dist-tag for your publishes than `'latest'` you can specify that inside the `package.json`’s [`publishConfig`](https://docs.npmjs.com/files/package.json#publishconfig) field. -4. `semantic-release` generally tries to orientate itself towards `npm` – it inherits the loglevel for example. - ## Plugins There are numerous steps where you can customize `semantic-release`’s behaviour using plugins. A plugin is a regular [option](#options), but passed inside the `release` block of `package.json`: @@ -215,7 +207,6 @@ module.exports = function (pluginConfig, config, callback) {} - `pluginConfig`: If the user of your plugin specifies additional plugin config in the `package.json` (see the `verifyConditions` example above) then it’s this object. - `config`: A config object containing a lot of information to act upon. - `env`: All environment variables - - `npm`: Select npm configuration bits like `registry`, `tag` and `auth` - `options`: `semantic-release` options like `debug`, or `branch` - `pkg`: Parsed `package.json` - For certain plugins the `config` object contains even more information. See below. @@ -230,14 +221,13 @@ While it may be tempting to use `'prepatch'`, `'preminor'` & `'prerelease'` as p Have a look at the [default implementation](https://github.com/semantic-release/commit-analyzer/). -### `generateNotes` - -This plugin is responsible for generating release notes. Call the callback with the notes as a string. Have a look at the [default implementation](https://github.com/semantic-release/release-notes-generator/). -It receives a `commits` array, the `lastRelease` and `nextRelease` inside `config`. - ### `verifyConditions` -This plugins is responsible for verifying that a release should happen in the first place. For example, the [default implementation](https://github.com/semantic-release/condition-travis/) verifies that the publish is happening on Travis, that it’s the right branch, and that all other build jobs succeeded. There are more use cases for this, e.g. verifying that test coverage is above a certain threshold or that there are no [vulnerabilities](https://nodesecurity.io/) in your dependencies. Be creative. +This plugins is responsible for verifying that a release should happen in the first place. +The default implementations are: +- [travis](https://github.com/semantic-release/condition-travis/): verifies that the publish is happening on Travis, that it’s the right branch, and that all other build jobs succeeded. +- [github](https://github.com/semantic-release/github/): verifies a Github authentication is set and valid. +- [npm](https://github.com/semantic-release/npm/): verifies an npm authentication is set and valid. Passing an array of plugins will run them in series. @@ -251,6 +241,17 @@ Passing an array of plugins will run them in series. This plugin is responsible for determining a package’s last release version. The [default implementation](https://github.com/semantic-release/last-release-npm) uses the last published version on a npm registry. +### `generateNotes` + +This plugin is responsible for generating release notes. Call the callback with the notes as a string. Have a look at the [default implementation](https://github.com/semantic-release/release-notes-generator/). +It receives a `commits` array, the `lastRelease` and `nextRelease` inside `config`. + +### `publish` + +This plugins is responsible for publishing the release. The default implementations publish on [npm](https://github.com/semantic-release/npm) and [github](https://github.com/semantic-release/github). + +Passing an array of plugins will run them in series. + ## ITYM*FAQ*LT > I think you might frequently ask questions like these diff --git a/cli.js b/cli.js index cf969b24..82be83ef 100755 --- a/cli.js +++ b/cli.js @@ -10,22 +10,20 @@ module.exports = async () => { .name('semantic-release') .description('Run automated package publishing') .option('-b, --branch ', 'Branch to release from') - .option('--github-token ', 'Token to authenticate with Github API') - .option('--github-url ', 'GitHub Enterprise endpoint') - .option('--github-api-path-prefix ', 'Prefix of the GitHub Enterprise endpoint') .option( '--verify-conditions ', - 'Comma separated list of paths or packages name for the verifyConditions plugin', + 'Comma separated list of paths or packages name for the verifyConditions plugin(s)', list ) .option('--get-last-release ', 'Path or package name for the getLastRelease plugin') .option('--analyze-commits ', 'Path or package name for the analyzeCommits plugin') .option( '--verify-release ', - 'Comma separated list of paths or packages name for the verifyRelease plugin', + 'Comma separated list of paths or packages name for the verifyRelease plugin(s)', list ) .option('--generate-notes ', 'Path or package name for the generateNotes plugin') + .option('--publish ', 'Comma separated list of paths or packages name for the publish plugin(s)', list) .option('--debug', 'Output debugging information') .option( '-d, --dry-run', @@ -43,7 +41,7 @@ module.exports = async () => { program.outputHelp(); process.exitCode = 1; } else { - await require('./index')(program); + await require('./index')(program.opts()); } } catch (err) { // If error is a SemanticReleaseError then it's an expected exception case (no release to be done, running on a PR etc..) and the cli will return with 0 diff --git a/index.js b/index.js index 61959d63..4fa04efe 100644 --- a/index.js +++ b/index.js @@ -1,60 +1,66 @@ const marked = require('marked'); const TerminalRenderer = require('marked-terminal'); const SemanticReleaseError = require('@semantic-release/error'); +const {gitHead: getGitHead} = require('./lib/git'); const getConfig = require('./lib/get-config'); const getNextVersion = require('./lib/get-next-version'); -const verifyPkg = require('./lib/verify-pkg'); -const verifyAuth = require('./lib/verify-auth'); const getCommits = require('./lib/get-commits'); -const publishNpm = require('./lib/publish-npm'); -const githubRelease = require('./lib/github-release'); const logger = require('./lib/logger'); module.exports = async opts => { - const config = await getConfig(opts); - const {plugins, env, options, pkg, npm} = config; + const config = await getConfig(opts, logger); + const {plugins, env, options, pkg} = config; - logger.log('Run automated release for %s on branch %s', pkg.name, options.branch); - - verifyPkg(pkg); - if (!options.dryRun) { - verifyAuth(options, env); - } + logger.log('Run automated release for branch %s', options.branch); if (!options.dryRun) { logger.log('Call plugin %s', 'verify-conditions'); - await plugins.verifyConditions({env, options, pkg, npm, logger}); + await plugins.verifyConditions({env, options, pkg, logger}); } logger.log('Call plugin %s', 'get-last-release'); const {commits, lastRelease} = await getCommits( - await plugins.getLastRelease({env, options, pkg, npm, logger}), - options.branch + await plugins.getLastRelease({env, options, pkg, logger}), + options.branch, + logger ); logger.log('Call plugin %s', 'analyze-commits'); - const type = await plugins.analyzeCommits({env, options, pkg, npm, logger, lastRelease, commits}); + const type = await plugins.analyzeCommits({env, options, pkg, logger, lastRelease, commits}); if (!type) { throw new SemanticReleaseError('There are no relevant changes, so no new version is released.', 'ENOCHANGE'); } - const nextRelease = {type, version: getNextVersion(type, lastRelease)}; + const version = getNextVersion(type, lastRelease, logger); + const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: `v${version}`}; logger.log('Call plugin %s', 'verify-release'); - await plugins.verifyRelease({env, options, pkg, npm, logger, lastRelease, commits, nextRelease}); + await plugins.verifyRelease({env, options, pkg, logger, lastRelease, commits, nextRelease}); - if (!options.dryRun) { - await publishNpm(pkg, npm, nextRelease); - } - - logger.log('Call plugin %s', 'generate-notes'); - const notes = await plugins.generateNotes({env, options, pkg, npm, logger, lastRelease, commits, nextRelease}); + const generateNotesParam = {env, options, pkg, logger, lastRelease, commits, nextRelease}; if (options.dryRun) { + logger.log('Call plugin %s', 'generate-notes'); + const notes = await plugins.generateNotes(generateNotesParam); marked.setOptions({renderer: new TerminalRenderer()}); logger.log('Release note for version %s:\n', nextRelease.version); - console.log(marked(notes)); + process.stdout.write(`${marked(notes)}\n`); } else { - const releaseUrl = await githubRelease(pkg, notes, nextRelease.version, options); - logger.log('Published Github release: %s', releaseUrl); + logger.log('Call plugin %s', 'generateNotes'); + nextRelease.notes = await plugins.generateNotes(generateNotesParam); + + logger.log('Call plugin %s', 'publish'); + await plugins.publish({options, pkg, logger, lastRelease, commits, nextRelease}, async prevInput => { + const newGitHead = await getGitHead(); + // If previous publish plugin has created a commit (gitHead changed) + if (prevInput.nextRelease.gitHead !== newGitHead) { + nextRelease.gitHead = newGitHead; + // Regenerate the release notes + logger.log('Call plugin %s', 'generateNotes'); + nextRelease.notes = await plugins.generateNotes(generateNotesParam); + } + // Call the next publish plugin with the updated `nextRelease` + return {options, pkg, logger, lastRelease, commits, nextRelease}; + }); + logger.log('Published release: %s', nextRelease.version); } }; diff --git a/lib/get-commits.js b/lib/get-commits.js index d2f957a9..06cd4379 100644 --- a/lib/get-commits.js +++ b/lib/get-commits.js @@ -1,10 +1,8 @@ -const execa = require('execa'); const gitLogParser = require('git-log-parser'); const getStream = require('get-stream'); const debug = require('debug')('semantic-release:get-commits'); +const {unshallow} = require('./git'); const getVersionHead = require('./get-version-head'); -const {debugShell} = require('./debug'); -const logger = require('./logger'); /** * Commit message. @@ -47,10 +45,11 @@ const logger = require('./logger'); * @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `lastRelease.gitHead` or the commit sha derived from `config.lastRelease.version` is not in the direct history of `branch`. * @throws {SemanticReleaseError} with code `ENOGITHEAD` if `lastRelease.gitHead` is undefined and no commit sha can be found for the `config.lastRelease.version`. */ -module.exports = async ({version, gitHead}, branch) => { +module.exports = async ({version, gitHead}, branch, logger) => { + let gitTag; if (gitHead || version) { try { - gitHead = await getVersionHead(gitHead, version, branch); + ({gitHead, gitTag} = await getVersionHead(gitHead, version, branch)); } catch (err) { if (err.code === 'ENOTINHISTORY') { logger.error(notInHistoryMessage(err.gitHead, branch, version)); @@ -63,8 +62,7 @@ module.exports = async ({version, gitHead}, branch) => { } else { logger.log('No previous release found, retrieving all commits'); // If there is no gitHead nor a version, there is no previous release. Unshallow the repo in order to retrieve all commits - const shell = await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false}); - debugShell('Unshallow repo', shell, debug); + await unshallow(); } Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}}); @@ -77,7 +75,7 @@ module.exports = async ({version, gitHead}, branch) => { ); logger.log('Found %s commits since last release', commits.length); debug('Parsed commits: %o', commits); - return {commits, lastRelease: {version, gitHead}}; + return {commits, lastRelease: {version, gitHead, gitTag}}; }; function noGitHeadMessage(branch, version) { diff --git a/lib/get-config.js b/lib/get-config.js index 08ef2075..77700730 100644 --- a/lib/get-config.js +++ b/lib/get-config.js @@ -1,43 +1,19 @@ -const url = require('url'); const {readJson} = require('fs-extra'); const {defaults} = require('lodash'); -const npmConf = require('npm-conf'); const normalizeData = require('normalize-package-data'); const debug = require('debug')('semantic-release:config'); -const logger = require('./logger'); -const getPlugins = require('./plugins'); -const getRegistry = require('./get-registry'); +const plugins = require('./plugins'); -module.exports = async opts => { +module.exports = async (opts, logger) => { const pkg = await readJson('./package.json'); - const {GH_TOKEN, GITHUB_TOKEN, GH_URL} = process.env; normalizeData(pkg); - const options = defaults(opts, pkg.release, { - branch: 'master', - fallbackTags: {next: 'latest'}, - githubToken: GH_TOKEN || GITHUB_TOKEN, - githubUrl: GH_URL, - }); + const options = defaults(opts, pkg.release, {branch: 'master'}); debug('branch: %O', options.branch); - debug('fallbackTags: %O', options.fallbackTags); debug('analyzeCommits: %O', options.analyzeCommits); debug('generateNotes: %O', options.generateNotes); debug('verifyConditions: %O', options.verifyConditions); debug('verifyRelease: %O', options.verifyRelease); + debug('publish: %O', options.publish); - const plugins = await getPlugins(options); - const conf = npmConf(); - const npm = { - auth: {token: process.env.NPM_TOKEN}, - registry: getRegistry(pkg, conf), - tag: (pkg.publishConfig || {}).tag || conf.get('tag'), - conf, - }; - - // normalize trailing slash - npm.registry = url.format(url.parse(npm.registry)); - - debug('npm registry: %O', npm.registry); - debug('npm tag: %O', npm.tag); - return {env: process.env, pkg, options, plugins, npm, logger}; + return {env: process.env, pkg, options, plugins: await plugins(options, logger), logger}; }; diff --git a/lib/get-next-version.js b/lib/get-next-version.js index cf8164b7..f5ea6d0f 100644 --- a/lib/get-next-version.js +++ b/lib/get-next-version.js @@ -1,8 +1,7 @@ const semver = require('semver'); const SemanticReleaseError = require('@semantic-release/error'); -const logger = require('./logger'); -module.exports = (type, lastRelease) => { +module.exports = (type, lastRelease, logger) => { let version; if (!lastRelease.version) { version = '1.0.0'; diff --git a/lib/get-registry.js b/lib/get-registry.js deleted file mode 100644 index 69ad9209..00000000 --- a/lib/get-registry.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = ({publishConfig, name}, conf) => { - if (publishConfig && publishConfig.registry) { - return publishConfig.registry; - } - - if (name[0] !== '@') { - return conf.get('registry') || 'https://registry.npmjs.org/'; - } - - return conf.get(`${name.split('/')[0]}/registry`) || conf.get('registry') || 'https://registry.npmjs.org/'; -}; diff --git a/lib/get-version-head.js b/lib/get-version-head.js index 99377e5f..a338382e 100644 --- a/lib/get-version-head.js +++ b/lib/get-version-head.js @@ -1,38 +1,6 @@ -const execa = require('execa'); const debug = require('debug')('semantic-release:get-version-head'); const SemanticReleaseError = require('@semantic-release/error'); -const {debugShell} = require('./debug'); - -/** - * Get the commit sha for a given tag. - * - * @param {string} tagName Tag name for which to retrieve the commit sha. - * - * @return {string} The commit sha of the tag in parameter or `null`. - */ -async function gitTagHead(tagName) { - try { - const shell = await execa('git', ['rev-list', '-1', '--tags', tagName]); - debugShell('Get git tag head', shell, debug); - return shell.stdout; - } catch (err) { - debug(err); - return null; - } -} - -/** - * Verify if the commist `sha` is in the direct history of the current branch. - * - * @param {string} sha The sha of the commit to look for. - * - * @return {boolean} `true` if the commit `sha` is in the history of the current branch, `false` otherwise. - */ -async function isCommitInHistory(sha) { - const shell = await execa('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {reject: false}); - debugShell('Check if commit is in history', shell, debug); - return shell.code === 0; -} +const {gitTagHead, gitCommitTag, isCommitInHistory, unshallow} = require('./git'); /** * Get the commit sha for a given version, if it's contained in the given branch. @@ -40,7 +8,7 @@ async function isCommitInHistory(sha) { * @param {string} gitHead The commit sha to look for. * @param {string} version The version corresponding to the commit sha to look for. Used to search in git tags. * - * @return {Promise} A Promise that resolves to the commit sha of the version, either `gitHead` of the commit associated with the `version` tag. + * @return {Promise} A Promise that resolves to an object with the `gitHead` and `gitTag` for the the `version`. * * @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `gitHead` or the commit sha dereived from `version` is not in the direct history of `branch`. * @throws {SemanticReleaseError} with code `ENOGITHEAD` if `gitHead` is undefined and no commit sha can be found for the `version`. @@ -49,17 +17,15 @@ module.exports = async (gitHead, version) => { // Check if gitHead is defined and exists in release branch if (gitHead && (await isCommitInHistory(gitHead))) { debug('Use gitHead: %s', gitHead); - return gitHead; + return {gitHead, gitTag: await gitCommitTag(gitHead)}; } - // Ushallow the repository - const shell = await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false}); - debugShell('Unshallow repo', shell, debug); + await unshallow(); // Check if gitHead is defined and exists in release branch again if (gitHead && (await isCommitInHistory(gitHead))) { debug('Use gitHead: %s', gitHead); - return gitHead; + return {gitHead, gitTag: await gitCommitTag(gitHead)}; } let tagHead; @@ -70,7 +36,7 @@ module.exports = async (gitHead, version) => { // Check if tagHead is found and exists in release branch again if (tagHead && (await isCommitInHistory(tagHead))) { debug('Use tagHead: %s', tagHead); - return tagHead; + return {gitHead: tagHead, gitTag: await gitCommitTag(tagHead)}; } } diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 00000000..d4300356 --- /dev/null +++ b/lib/git.js @@ -0,0 +1,75 @@ +const execa = require('execa'); +const debug = require('debug')('semantic-release:get-version-head'); +const {debugShell} = require('./debug'); + +/** + * Get the commit sha for a given tag. + * + * @param {string} tagName Tag name for which to retrieve the commit sha. + * + * @return {string} The commit sha of the tag in parameter or `null`. + */ +async function gitTagHead(tagName) { + try { + const shell = await execa('git', ['rev-list', '-1', tagName]); + debugShell('Get git tag head', shell, debug); + return shell.stdout; + } catch (err) { + debug(err); + return null; + } +} + +/** + * Get the tag associated with a commit sha. + * + * @param {string} gitHead The commit sha for which to retrieve the associated tag. + * + * @return {string} The tag associatedwith the sha in parameter or `null`. + */ +async function gitCommitTag(gitHead) { + try { + const shell = await execa('git', ['describe', '--tags', '--exact-match', gitHead]); + debugShell('Get git commit tag', shell, debug); + return shell.stdout; + } catch (err) { + debug(err); + return null; + } +} + +/** + * Verify if the commit `sha` is in the direct history of the current branch. + * + * @param {string} sha The sha of the commit to look for. + * + * @return {boolean} `true` if the commit `sha` is in the history of the current branch, `false` otherwise. + */ +async function isCommitInHistory(sha) { + const shell = await execa('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {reject: false}); + debugShell('Check if commit is in history', shell, debug); + return shell.code === 0; +} + +/** + * Unshallow the git repository (retriving every commits and tags). + */ +async function unshallow() { + await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false}); +} + +/** + * @return {string} the sha of the HEAD commit. + */ +async function gitHead() { + try { + const shell = await execa('git', ['rev-parse', 'HEAD']); + debugShell('Get git head', shell, debug); + return shell.stdout; + } catch (err) { + debug(err); + throw new Error(err.stderr); + } +} + +module.exports = {gitTagHead, gitCommitTag, isCommitInHistory, unshallow, gitHead}; diff --git a/lib/github-release.js b/lib/github-release.js deleted file mode 100644 index 4d261a3a..00000000 --- a/lib/github-release.js +++ /dev/null @@ -1,36 +0,0 @@ -const {promisify} = require('util'); -const url = require('url'); -const gitHead = require('git-head'); -const GitHubApi = require('github'); -const parseSlug = require('parse-github-repo-url'); -const debug = require('debug')('semantic-release:github-release'); - -module.exports = async (pkg, notes, version, {branch, githubUrl, githubToken, githubApiPathPrefix}) => { - const [owner, repo] = parseSlug(pkg.repository.url); - let {port, protocol, hostname: host} = githubUrl ? url.parse(githubUrl) : {}; - protocol = (protocol || '').split(':')[0] || null; - const pathPrefix = githubApiPathPrefix || null; - const github = new GitHubApi({port, protocol, host, pathPrefix}); - debug('Github host: %o', host); - debug('Github port: %o', port); - debug('Github protocol: %o', protocol); - debug('Github pathPrefix: %o', pathPrefix); - - github.authenticate({type: 'token', token: githubToken}); - - const name = `v${version}`; - const release = {owner, repo, tag_name: name, name, target_commitish: branch, body: notes}; - debug('release owner: %o', owner); - debug('release repo: %o', repo); - debug('release name: %o', name); - debug('release branch: %o', branch); - - const sha = await promisify(gitHead)(); - const ref = `refs/tags/${name}`; - - debug('Create git tag %o with commit %o', ref, sha); - await github.gitdata.createReference({owner, repo, ref, sha}); - const {data: {html_url: releaseUrl}} = await github.repos.createRelease(release); - - return releaseUrl; -}; diff --git a/lib/plugins.js b/lib/plugins.js deleted file mode 100644 index 7cb37ac7..00000000 --- a/lib/plugins.js +++ /dev/null @@ -1,45 +0,0 @@ -const {promisify} = require('util'); -const relative = require('require-relative'); -const pSeries = require('p-series'); -const logger = require('./logger'); - -module.exports = options => { - const plugins = { - analyzeCommits: normalize(options.analyzeCommits, '@semantic-release/commit-analyzer'), - generateNotes: normalize(options.generateNotes, '@semantic-release/release-notes-generator'), - getLastRelease: normalize(options.getLastRelease, '@semantic-release/last-release-npm'), - }; - ['verifyConditions', 'verifyRelease'].forEach(plugin => { - if (!Array.isArray(options[plugin])) { - plugins[plugin] = normalize( - options[plugin], - plugin === 'verifyConditions' ? '@semantic-release/condition-travis' : './plugin-noop' - ); - } else { - plugins[plugin] = async pluginOptions => { - return pSeries( - options[plugin].map(step => { - return () => normalize(step, './plugin-noop')(pluginOptions); - }) - ); - }; - } - }); - - return plugins; -}; - -const normalize = (pluginConfig, fallback) => { - if (typeof pluginConfig === 'string') { - logger.log('Load plugin %s', pluginConfig); - return promisify(relative(pluginConfig).bind(null, {})); - } - - if (pluginConfig && typeof pluginConfig.path === 'string') { - logger.log('Load plugin %s', pluginConfig.path); - return promisify(relative(pluginConfig.path).bind(null, pluginConfig)); - } - return promisify(require(fallback).bind(null, pluginConfig || {})); -}; - -module.exports.normalize = normalize; diff --git a/lib/plugins/definitions.js b/lib/plugins/definitions.js new file mode 100644 index 00000000..5b4b917d --- /dev/null +++ b/lib/plugins/definitions.js @@ -0,0 +1,78 @@ +const {isString, isObject, isFunction, isArray} = require('lodash'); +const semver = require('semver'); +const conditionTravis = require('@semantic-release/condition-travis'); +const commitAnalyzer = require('@semantic-release/commit-analyzer'); +const releaseNotesGenerator = require('@semantic-release/release-notes-generator'); +const npm = require('@semantic-release/npm'); +const github = require('@semantic-release/github'); + +const RELEASE_TYPE = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease']; + +module.exports = { + verifyConditions: { + default: [npm.verifyConditions, github.verifyConditions, conditionTravis], + config: { + validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + message: + 'The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.', + }, + }, + getLastRelease: { + default: npm.getLastRelease, + config: { + validator: conf => Boolean(conf) && validatePluginConfig(conf), + message: + 'The "getLastRelease" plugin is mandatory, and must be a single plugin definition. A plugin definition is either a string or an object with a path property.', + }, + output: { + validator: output => + !output || + (isObject(output) && !output.version) || + (isString(output.version) && Boolean(semver.valid(semver.clean(output.version)))), + message: + 'The "getLastRelease" plugin output if defined, must be an object with an optionnal valid semver version in the "version" property.', + }, + }, + analyzeCommits: { + default: commitAnalyzer, + config: { + validator: conf => Boolean(conf) && validatePluginConfig(conf), + message: + 'The "analyzeCommits" plugin is mandatory, and must be a single plugin definition. A plugin definition is either a string or an object with a path property.', + }, + output: { + validator: output => !output || RELEASE_TYPE.includes(output), + message: 'The "analyzeCommits" plugin output must be either undefined or a valid semver release type.', + }, + }, + verifyRelease: { + default: false, + config: { + validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + message: + 'The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.', + }, + }, + generateNotes: { + default: releaseNotesGenerator, + config: { + validator: conf => !conf || validatePluginConfig(conf), + message: + 'The "generateNotes" plugin, if defined, must be a single plugin definition. A plugin definition is either a string or an object with a path property.', + }, + output: { + validator: output => isString(output), + message: 'The "generateNotes" plugin output must be a string.', + }, + }, + publish: { + default: [npm.publish, github.publish], + config: { + validator: conf => Boolean(conf) && (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + message: + 'The "publish" plugin is mandatory, and must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.', + }, + }, +}; + +const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf); diff --git a/lib/plugins/index.js b/lib/plugins/index.js new file mode 100644 index 00000000..1ff266f6 --- /dev/null +++ b/lib/plugins/index.js @@ -0,0 +1,24 @@ +const {isArray} = require('lodash'); +const DEFINITIONS = require('./definitions'); +const pipeline = require('./pipeline'); +const normalize = require('./normalize'); + +module.exports = (options, logger) => + Object.keys(DEFINITIONS).reduce((plugins, pluginType) => { + const {config, output, default: def} = DEFINITIONS[pluginType]; + let pluginConfs; + if (options[pluginType]) { + if (config && !config.validator(options[pluginType])) { + throw new Error(config.message); + } + pluginConfs = options[pluginType]; + } else { + pluginConfs = def; + } + + plugins[pluginType] = isArray(pluginConfs) + ? pipeline(pluginConfs.map(conf => normalize(pluginType, conf, logger, output))) + : normalize(pluginType, pluginConfs, logger, output); + + return plugins; + }, {}); diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js new file mode 100644 index 00000000..64b59745 --- /dev/null +++ b/lib/plugins/normalize.js @@ -0,0 +1,34 @@ +const {promisify, inspect} = require('util'); +const {isString, isObject, isFunction, noop, cloneDeep} = require('lodash'); +const importFrom = require('import-from'); + +module.exports = (pluginType, pluginConfig, logger, validator) => { + if (!pluginConfig) { + return noop; + } + const {path, ...config} = isString(pluginConfig) || isFunction(pluginConfig) ? {path: pluginConfig} : pluginConfig; + if (!isFunction(pluginConfig)) { + logger.log('Load plugin %s', path); + } + const plugin = isFunction(path) ? path : importFrom.silent(__dirname, path) || importFrom(process.cwd(), path); + + let func; + if (isFunction(plugin)) { + func = promisify(plugin.bind(null, cloneDeep(config))); + } else if (isObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) { + func = promisify(plugin[pluginType].bind(null, cloneDeep(config))); + } else { + throw new Error( + `The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.` + ); + } + + return async input => { + const result = await func(cloneDeep(input)); + + if (validator && !validator.validator(result)) { + throw new Error(`${validator.message}. Received: ${inspect(result)}`); + } + return result; + }; +}; diff --git a/lib/plugins/pipeline.js b/lib/plugins/pipeline.js new file mode 100644 index 00000000..10ce2720 --- /dev/null +++ b/lib/plugins/pipeline.js @@ -0,0 +1,19 @@ +const {identity} = require('lodash'); +const pReduce = require('p-reduce'); + +module.exports = steps => async (input, getNextInput = identity) => { + const results = []; + await pReduce( + steps, + async (prevResult, nextStep) => { + // Call the next step with the input computed at the end of the previous iteration + const result = await nextStep(prevResult); + // Save intermediary result + results.push(result); + // Prepare input for next step, passing the result of the previous iteration and the current one + return getNextInput(prevResult, result); + }, + input + ); + return results; +}; diff --git a/lib/publish-npm.js b/lib/publish-npm.js deleted file mode 100644 index 0c9ec2c4..00000000 --- a/lib/publish-npm.js +++ /dev/null @@ -1,35 +0,0 @@ -const {appendFile, readJson, writeJson, pathExists} = require('fs-extra'); -const execa = require('execa'); -const nerfDart = require('nerf-dart'); -const debug = require('debug')('semantic-release:publish-npm'); -const {debugShell} = require('./debug'); -const logger = require('./logger'); - -module.exports = async (pkg, {conf, registry, auth}, {version}) => { - const pkgFile = await readJson('./package.json'); - - 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); - } - - await writeJson('./package.json', Object.assign(pkgFile, {version})); - logger.log('Wrote version %s to package.json', version); - - if (process.env.NPM_OLD_TOKEN && process.env.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', `_auth = \${NPM_OLD_TOKEN}\nemail = \${NPM_EMAIL}`); - logger.log('Wrote NPM_OLD_TOKEN and NPM_EMAIL to .npmrc.'); - } else { - await appendFile('./.npmrc', `${nerfDart(registry)}:_authToken = \${NPM_TOKEN}`); - logger.log('Wrote NPM_TOKEN to .npmrc.'); - } - - logger.log('Publishing version %s to npm registry %s', version, registry); - const shell = await execa('npm', ['publish']); - console.log(shell.stdout); - debugShell('Publishing on npm', shell, debug); -}; diff --git a/lib/verify-auth.js b/lib/verify-auth.js deleted file mode 100644 index d323af06..00000000 --- a/lib/verify-auth.js +++ /dev/null @@ -1,11 +0,0 @@ -const SemanticReleaseError = require('@semantic-release/error'); - -module.exports = (options, env) => { - if (!options.githubToken) { - throw new SemanticReleaseError('No github token specified.', 'ENOGHTOKEN'); - } - - if (!(env.NPM_TOKEN || (env.NPM_OLD_TOKEN && env.NPM_EMAIL))) { - throw new SemanticReleaseError('No npm token specified.', 'ENONPMTOKEN'); - } -}; diff --git a/lib/verify-pkg.js b/lib/verify-pkg.js deleted file mode 100644 index 2369b196..00000000 --- a/lib/verify-pkg.js +++ /dev/null @@ -1,11 +0,0 @@ -const SemanticReleaseError = require('@semantic-release/error'); - -module.exports = pkg => { - if (!pkg.name) { - throw new SemanticReleaseError('No "name" found in package.json.', 'ENOPKGNAME'); - } - - if (!pkg.repository || !pkg.repository.url) { - throw new SemanticReleaseError('No "repository" found in package.json.', 'ENOPKGREPO'); - } -}; diff --git a/package.json b/package.json index 475b5a7e..ada2a850 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "@semantic-release/commit-analyzer": "^4.0.0", "@semantic-release/condition-travis": "^6.0.0", "@semantic-release/error": "^2.1.0", - "@semantic-release/last-release-npm": "^2.0.0", + "@semantic-release/github": "^1.0.0", + "@semantic-release/npm": "^1.0.0", "@semantic-release/release-notes-generator": "^5.0.0", "chalk": "^2.3.0", "commander": "^2.11.0", @@ -26,18 +27,13 @@ "execa": "^0.8.0", "fs-extra": "^4.0.2", "get-stream": "^3.0.0", - "git-head": "^1.2.1", "git-log-parser": "^1.2.0", - "github": "^12.0.0", + "import-from": "^2.1.0", "lodash": "^4.0.0", "marked": "^0.3.6", "marked-terminal": "^2.0.0", - "nerf-dart": "^1.0.0", "normalize-package-data": "^2.3.4", - "npm-conf": "^1.1.2", - "p-series": "^1.0.0", - "parse-github-repo-url": "^1.3.0", - "require-relative": "^0.8.7", + "p-reduce": "^1.0.0", "semver": "^5.4.1" }, "devDependencies": { diff --git a/test/fixtures/multi-plugin.js b/test/fixtures/multi-plugin.js new file mode 100644 index 00000000..55baaaa8 --- /dev/null +++ b/test/fixtures/multi-plugin.js @@ -0,0 +1,21 @@ +module.exports = { + verifyConditions(config, options, cb) { + cb(); + }, + + getLastRelease(config, options, cb) { + cb(); + }, + analyzeCommits(config, options, cb) { + cb(); + }, + verifyRelease(config, options, cb) { + cb(); + }, + generateNotes(config, options, cb) { + cb(); + }, + publish(config, options, cb) { + cb(); + }, +}; diff --git a/test/fixtures/plugin-error-b.js b/test/fixtures/plugin-error-b.js deleted file mode 100644 index e50853c9..00000000 --- a/test/fixtures/plugin-error-b.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function(config, options, cb) { - cb(new Error('b')); -}; diff --git a/test/fixtures/plugin-error-a.js b/test/fixtures/plugin-error.js similarity index 100% rename from test/fixtures/plugin-error-a.js rename to test/fixtures/plugin-error.js diff --git a/lib/plugin-noop.js b/test/fixtures/plugin-noop.js similarity index 100% rename from lib/plugin-noop.js rename to test/fixtures/plugin-noop.js diff --git a/test/fixtures/plugin-result-a.js b/test/fixtures/plugin-result-a.js deleted file mode 100644 index a4d3719b..00000000 --- a/test/fixtures/plugin-result-a.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function(config, options, cb) { - cb(null, 'a'); -}; diff --git a/test/fixtures/plugin-result-b.js b/test/fixtures/plugin-result-b.js deleted file mode 100644 index 0d5cb035..00000000 --- a/test/fixtures/plugin-result-b.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function(config, options, cb) { - cb(null, 'b'); -}; diff --git a/test/get-commits.test.js b/test/get-commits.test.js index 0ab3fb48..7046ab1f 100644 --- a/test/get-commits.test.js +++ b/test/get-commits.test.js @@ -1,5 +1,4 @@ import test from 'ava'; -import proxyquire from 'proxyquire'; import {stub} from 'sinon'; import SemanticReleaseError from '@semantic-release/error'; import { @@ -12,6 +11,7 @@ import { gitLog, gitDetachedHead, } from './helpers/git-utils'; +import getCommits from '../lib/get-commits'; test.beforeEach(t => { // Save the current working diretory @@ -19,9 +19,7 @@ test.beforeEach(t => { // Stub the logger functions t.context.log = stub(); t.context.error = stub(); - t.context.getCommits = proxyquire('../lib/get-commits', { - './logger': {log: t.context.log, error: t.context.error}, - }); + t.context.logger = {log: t.context.log, error: t.context.error}; }); test.afterEach.always(t => { @@ -36,7 +34,7 @@ test.serial('Get all commits when there is no last release', async t => { const commits = await gitCommits(['First', 'Second']); // Retrieve the commits with the commits module - const result = await t.context.getCommits({}, 'master'); + const result = await getCommits({}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -54,6 +52,7 @@ test.serial('Get all commits when there is no last release', async t => { t.truthy(result.lastRelease); t.falsy(result.lastRelease.gitHead); t.falsy(result.lastRelease.version); + t.falsy(result.lastRelease.gitTag); }); test.serial('Get all commits with gitTags', async t => { @@ -67,7 +66,7 @@ test.serial('Get all commits with gitTags', async t => { commits = (await gitCommits(['Second'])).concat(commits); // Retrieve the commits with the commits module - const result = await t.context.getCommits({}, 'master'); + const result = await getCommits({}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); t.is(result.commits[0].hash.substring(0, 7), commits[0].hash); @@ -96,7 +95,7 @@ test.serial('Get all commits when there is no last release, including the ones n t.is((await gitLog()).length, 1); // Retrieve the commits with the commits module - const result = await t.context.getCommits({}, 'master'); + const result = await getCommits({}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -114,6 +113,7 @@ test.serial('Get all commits when there is no last release, including the ones n t.truthy(result.lastRelease); t.falsy(result.lastRelease.gitHead); t.falsy(result.lastRelease.version); + t.falsy(result.lastRelease.gitTag); }); test.serial('Get all commits since gitHead (from lastRelease)', async t => { @@ -123,7 +123,7 @@ test.serial('Get all commits since gitHead (from lastRelease)', async t => { const commits = await gitCommits(['First', 'Second', 'Third']); // Retrieve the commits with the commits module, since commit 'First' - const result = await t.context.getCommits({gitHead: commits[commits.length - 1].hash}, 'master'); + const result = await getCommits({gitHead: commits[commits.length - 1].hash}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -141,6 +141,7 @@ test.serial('Get all commits since gitHead (from lastRelease)', async t => { t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead, commits[commits.length - 1].hash); t.falsy(result.lastRelease.version); + t.falsy(result.lastRelease.gitTag); }); test.serial('Get all commits since gitHead (from lastRelease) on a detached head repo', async t => { @@ -152,7 +153,7 @@ test.serial('Get all commits since gitHead (from lastRelease) on a detached head await gitDetachedHead(repo, commits[1].hash); // Retrieve the commits with the commits module, since commit 'First' - const result = await t.context.getCommits({gitHead: commits[commits.length - 1].hash}, 'master'); + const result = await getCommits({gitHead: commits[commits.length - 1].hash}, 'master', t.context.logger); // Verify the module retrieved only the commit 'feat: Second' (included in the detached and after 'fix: First') t.is(result.commits.length, 1); @@ -165,6 +166,7 @@ test.serial('Get all commits since gitHead (from lastRelease) on a detached head t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead, commits[commits.length - 1].hash); t.falsy(result.lastRelease.version); + t.falsy(result.lastRelease.gitTag); }); test.serial('Get all commits since gitHead (from tag) ', async t => { @@ -178,7 +180,7 @@ test.serial('Get all commits since gitHead (from tag) ', async t => { commits = (await gitCommits(['Second', 'Third'])).concat(commits); // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0) - const result = await t.context.getCommits({version: '1.0.0'}, 'master'); + const result = await getCommits({version: '1.0.0'}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -195,6 +197,7 @@ test.serial('Get all commits since gitHead (from tag) ', async t => { // Verify the last release is returned and updated t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash); + t.is(result.lastRelease.gitTag, '1.0.0'); t.is(result.lastRelease.version, '1.0.0'); }); @@ -211,7 +214,7 @@ test.serial('Get all commits since gitHead (from tag) on a detached head repo', await gitDetachedHead(repo, commits[1].hash); // Retrieve the commits with the commits module, since commit 'First' (associated with tag 1.0.0) - const result = await t.context.getCommits({version: '1.0.0'}, 'master'); + const result = await getCommits({version: '1.0.0'}, 'master', t.context.logger); // Verify the module retrieved only the commit 'feat: Second' (included in the detached and after 'fix: First') t.is(result.commits.length, 1); @@ -223,6 +226,7 @@ test.serial('Get all commits since gitHead (from tag) on a detached head repo', // Verify the last release is returned and updated t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash); + t.is(result.lastRelease.gitTag, '1.0.0'); t.is(result.lastRelease.version, '1.0.0'); }); @@ -237,7 +241,7 @@ test.serial('Get all commits since gitHead (from tag formatted like v) commits = (await gitCommits(['Second', 'Third'])).concat(commits); // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0) - const result = await t.context.getCommits({version: '1.0.0'}, 'master'); + const result = await getCommits({version: '1.0.0'}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -254,6 +258,7 @@ test.serial('Get all commits since gitHead (from tag formatted like v) // Verify the last release is returned and updated t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash); + t.is(result.lastRelease.gitTag, 'v1.0.0'); t.is(result.lastRelease.version, '1.0.0'); }); @@ -268,7 +273,7 @@ test.serial('Get commits when last release gitHead is missing but a tag match th commits = (await gitCommits(['Second', 'Third'])).concat(commits); // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0) - const result = await t.context.getCommits({version: '1.0.0', gitHead: 'missing'}, 'master'); + const result = await getCommits({version: '1.0.0', gitHead: 'missing'}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -285,6 +290,7 @@ test.serial('Get commits when last release gitHead is missing but a tag match th // Verify the last release is returned and updated t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash); + t.is(result.lastRelease.gitTag, 'v1.0.0'); t.is(result.lastRelease.version, '1.0.0'); }); @@ -297,7 +303,11 @@ test.serial('Get all commits since gitHead, when gitHead are mising from the sha await gitShallowClone(repo); // Retrieve the commits with the commits module, since commit 'First' - const result = await t.context.getCommits({version: '1.0.0', gitHead: commits[commits.length - 1].hash}, 'master'); + const result = await getCommits( + {version: '1.0.0', gitHead: commits[commits.length - 1].hash}, + 'master', + t.context.logger + ); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -315,6 +325,7 @@ test.serial('Get all commits since gitHead, when gitHead are mising from the sha t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash); t.is(result.lastRelease.version, '1.0.0'); + t.falsy(result.lastRelease.gitTag); }); test.serial('Get all commits since gitHead from tag, when tags are mising from the shallow clone', async t => { @@ -333,7 +344,7 @@ test.serial('Get all commits since gitHead from tag, when tags are mising from t t.is((await gitTags()).length, 0); // Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0) - const result = await t.context.getCommits({version: '1.0.0'}, 'master'); + const result = await getCommits({version: '1.0.0'}, 'master', t.context.logger); // Verify the commits created and retrieved by the module are identical t.is(result.commits.length, 2); @@ -350,6 +361,7 @@ test.serial('Get all commits since gitHead from tag, when tags are mising from t // Verify the last release is returned and updated t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash); + t.is(result.lastRelease.gitTag, 'v1.0.0'); t.is(result.lastRelease.version, '1.0.0'); }); @@ -360,7 +372,7 @@ test.serial('Return empty array if lastRelease.gitHead is the last commit', asyn const commits = await gitCommits(['First', 'Second']); // Retrieve the commits with the commits module, since commit 'Second' (therefore none) - const result = await t.context.getCommits({gitHead: commits[0].hash, version: '1.0.0'}, 'master'); + const result = await getCommits({gitHead: commits[0].hash, version: '1.0.0'}, 'master', t.context.logger); // Verify no commit is retrieved t.deepEqual(result.commits, []); @@ -368,6 +380,7 @@ test.serial('Return empty array if lastRelease.gitHead is the last commit', asyn t.truthy(result.lastRelease); t.is(result.lastRelease.gitHead.substring(0, 7), commits[0].hash); t.is(result.lastRelease.version, '1.0.0'); + t.falsy(result.lastRelease.gitTag); }); test.serial('Return empty array if there is no commits', async t => { @@ -375,7 +388,7 @@ test.serial('Return empty array if there is no commits', async t => { await gitRepo(); // Retrieve the commits with the commits module - const result = await t.context.getCommits({}, 'master'); + const result = await getCommits({}, 'master', t.context.logger); // Verify no commit is retrieved t.deepEqual(result.commits, []); @@ -392,7 +405,7 @@ test.serial('Throws ENOGITHEAD error if the gitHead of the last release cannot b await gitCommits(['First', 'Second']); // Retrieve the commits with the commits module - const error = await t.throws(t.context.getCommits({version: '1.0.0'}, 'master')); + const error = await t.throws(getCommits({version: '1.0.0'}, 'master', t.context.logger)); // Verify error code and type t.is(error.code, 'ENOGITHEAD'); @@ -411,7 +424,7 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in history', async t = await gitCommits(['First', 'Second']); // Retrieve the commits with the commits module - const error = await t.throws(t.context.getCommits({gitHead: 'notinhistory'}, 'master')); + const error = await t.throws(getCommits({gitHead: 'notinhistory'}, 'master', t.context.logger)); // Verify error code and type t.is(error.code, 'ENOTINHISTORY'); @@ -434,7 +447,9 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in branch history but await gitCheckout('master', false); // Retrieve the commits with the commits module - const error = await t.throws(t.context.getCommits({version: '1.0.1', gitHead: commitsBranch[0].hash}, 'master')); + const error = await t.throws( + getCommits({version: '1.0.1', gitHead: commitsBranch[0].hash}, 'master', t.context.logger) + ); // Verify error code and type t.is(error.code, 'ENOTINHISTORY'); @@ -461,7 +476,9 @@ test.serial('Throws ENOTINHISTORY error if gitHead is not in detached head but p await gitDetachedHead(repo, commitsMaster[0].hash); // Retrieve the commits with the commits module, since commit 'Second' - const error = await t.throws(t.context.getCommits({version: '1.0.1', gitHead: commitsBranch[0].hash}, 'master')); + const error = await t.throws( + getCommits({version: '1.0.1', gitHead: commitsBranch[0].hash}, 'master', t.context.logger) + ); // Verify error code and type t.is(error.code, 'ENOTINHISTORY'); @@ -488,7 +505,7 @@ test.serial('Throws ENOTINHISTORY error when a tag is not in branch history but await gitCommits(['Forth']); // Retrieve the commits with the commits module - const error = await t.throws(t.context.getCommits({version: '1.0.0'}, 'master')); + const error = await t.throws(getCommits({version: '1.0.0'}, 'master', t.context.logger)); // Verify error code and type t.is(error.code, 'ENOTINHISTORY'); t.true(error instanceof SemanticReleaseError); diff --git a/test/get-config.test.js b/test/get-config.test.js index 37f5b69e..3e8a5be6 100644 --- a/test/get-config.test.js +++ b/test/get-config.test.js @@ -1,6 +1,5 @@ -import url from 'url'; import test from 'ava'; -import {writeJson, writeFile} from 'fs-extra'; +import {writeJson} from 'fs-extra'; import proxyquire from 'proxyquire'; import {stub} from 'sinon'; import normalizeData from 'normalize-package-data'; @@ -28,7 +27,6 @@ test.serial('Default values', async t => { await gitRepo(); // Create package.json in repository root await writeJson('./package.json', pkg); - process.env.GH_TOKEN = 'GH_TOKEN'; const result = await t.context.getConfig(); @@ -37,12 +35,6 @@ test.serial('Default values', async t => { t.deepEqual(result.pkg, pkg); // Verify the default options are set t.is(result.options.branch, 'master'); - t.is(result.options.githubToken, process.env.GH_TOKEN); - // Verify the default npm options are set - t.is(result.npm.tag, 'latest'); - - // Use the environment variable npm_config_registry as the default so the test works on both npm and yarn - t.is(result.npm.registry, url.format(url.parse(process.env.npm_config_registry || 'https://registry.npmjs.org/'))); }); test.serial('Read package.json configuration', async t => { @@ -61,8 +53,6 @@ test.serial('Read package.json configuration', async t => { await gitRepo(); // Create package.json in repository root await writeJson('./package.json', pkg); - delete process.env.GH_TOKEN; - process.env.GITHUB_TOKEN = 'GITHUB_TOKEN'; const result = await t.context.getConfig(); @@ -77,11 +67,9 @@ test.serial('Read package.json configuration', async t => { t.is(t.context.plugins.firstCall.args[0].generateNotes, release.generateNotes); t.deepEqual(t.context.plugins.firstCall.args[0].getLastRelease, release.getLastRelease); t.is(t.context.plugins.firstCall.args[0].branch, release.branch); - - t.is(result.options.githubToken, process.env.GITHUB_TOKEN); }); -test.serial('Priority cli parameters over package.json configuration', async t => { +test.serial('Prioritise cli parameters over package.json configuration', async t => { const release = { analyzeCommits: 'analyzeCommits', generateNotes: 'generateNotes', @@ -104,8 +92,6 @@ test.serial('Priority cli parameters over package.json configuration', async t = await gitRepo(); // Create package.json in repository root await writeJson('./package.json', pkg); - delete process.env.GH_TOKEN; - process.env.GITHUB_TOKEN = 'GITHUB_TOKEN'; const result = await t.context.getConfig(options); @@ -117,38 +103,3 @@ test.serial('Priority cli parameters over package.json configuration', async t = t.deepEqual(t.context.plugins.firstCall.args[0].getLastRelease, options.getLastRelease); t.is(t.context.plugins.firstCall.args[0].branch, options.branch); }); - -test.serial('Get tag from .npmrc', async t => { - const pkg = {name: 'package_name'}; - - // Create a git repository, set the current working directory at the root of the repo - await gitRepo(); - // Create package.json in repository root - await writeJson('./package.json', pkg); - // Create local .npmrc - await writeFile('.npmrc', 'tag=npmrc_tag'); - - // Make sure to not use the environment variable set by npm when running tests with npm run test - delete process.env.npm_config_tag; - const result = await t.context.getConfig(); - - // Verify the tag used in the one in .npmrc - t.is(result.npm.tag, 'npmrc_tag'); -}); - -test.serial('Get tag from package.json, even if defined in .npmrc', async t => { - const publishConfig = {tag: 'pkg_tag'}; - const pkg = {name: 'package_name', publishConfig}; - - // Create a git repository, set the current working directory at the root of the repo - await gitRepo(); - // Create package.json in repository root - await writeJson('./package.json', pkg); - // Create local .npmrc - await writeFile('.npmrc', 'tag=npmrc_tag'); - - const result = await t.context.getConfig(); - - // Verify the tag used in the one in package.json - t.is(result.npm.tag, 'pkg_tag'); -}); diff --git a/test/get-next-version.test.js b/test/get-next-version.test.js index 26c6470e..32ea3bc1 100644 --- a/test/get-next-version.test.js +++ b/test/get-next-version.test.js @@ -1,36 +1,36 @@ import test from 'ava'; import {stub} from 'sinon'; -import proxyquire from 'proxyquire'; import SemanticReleaseError from '@semantic-release/error'; +import getNextVersion from '../lib/get-next-version'; test.beforeEach(t => { // Stub the logger functions t.context.log = stub(); - t.context.getNextVersion = proxyquire('../lib/get-next-version', {'./logger': {log: t.context.log}}); + t.context.logger = {log: t.context.log}; }); test('Increase version for patch release', t => { - const version = t.context.getNextVersion('patch', {version: '1.0.0'}); + const version = getNextVersion('patch', {version: '1.0.0'}, t.context.logger); t.is(version, '1.0.1'); }); test('Increase version for minor release', t => { - const version = t.context.getNextVersion('minor', {version: '1.0.0'}); + const version = getNextVersion('minor', {version: '1.0.0'}, t.context.logger); t.is(version, '1.1.0'); }); test('Increase version for major release', t => { - const version = t.context.getNextVersion('major', {version: '1.0.0'}); + const version = getNextVersion('major', {version: '1.0.0'}, t.context.logger); t.is(version, '2.0.0'); }); test('Return 1.0.0 if there is no previous release', t => { - const version = t.context.getNextVersion('minor', {}); + const version = getNextVersion('minor', {}, t.context.logger); t.is(version, '1.0.0'); }); test('Return an error if the release type is invalid', t => { - const error = t.throws(() => t.context.getNextVersion('invalid', {version: '1.0.0'})); + const error = t.throws(() => getNextVersion('invalid', {version: '1.0.0'}, t.context.logger)); t.is(error.code, 'EINVALIDTYPE'); t.true(error instanceof SemanticReleaseError); }); diff --git a/test/get-registry.test.js b/test/get-registry.test.js deleted file mode 100644 index ac109dac..00000000 --- a/test/get-registry.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import test from 'ava'; -import {stub} from 'sinon'; -const getRegistry = require('../lib/get-registry'); - -test('Get registry from package.json', t => { - // Retrieve the registry with the get-registry module and verify it returns the one from the package.json in parameter - t.is(getRegistry({name: 'publish-config', publishConfig: {registry: 'a'}}, {}), 'a'); -}); - -test('Prioritize the package.json registry config', t => { - // Stub the npmconf object - const get = stub(); - - // Retrieve the registry with the get-registry module and verify it returns the one from the package.json in parameter - t.is(getRegistry({name: 'publish-config', publishConfig: {registry: 'b'}}, {get}), 'b'); - - // Verify the registry has been retrieved from the package.json without trying the stubbed npmconf - t.true(get.notCalled); -}); - -test('Get registry for regular package name', t => { - // Stub the npmconf object returns 'b' for 'registry' property - const get = stub() - .withArgs('registry') - .returns('b'); - - // Retrieve the registry with the get-registry module and verify it returns the one configured in the stubbed npmconf - t.is(getRegistry({name: 'normal'}, {get}), 'b'); - - // Verify the registry has been retrieved by calling the stubbed npmconf - t.true(get.calledWithExactly('registry')); -}); - -test('Get default registry', t => { - // Stub the npmconf object, returns 'null' - const get = stub().returns(null); - - // Retrieve the registry with the get-registry module and verify it returns default one - t.is(getRegistry({name: 'normal'}, {get}), 'https://registry.npmjs.org/'); - - // Verify the module tried first to retrieve the registry by calling the stubbed npmconf - t.true(get.calledWithExactly('registry')); -}); - -test('Get registry for scoped package name', t => { - // Stub the npmconf object, returns 'c' for '@scoped/registry' property - const get = stub() - .withArgs('@scoped/registry') - .returns('c'); - - // Retrieve the registry with the get-registry module and verify it returns the one configured in the stubbed npmconf - t.is(getRegistry({name: '@scoped/foo'}, {get}), 'c'); - - // Verify the registry for the scope '@scoped' has been retrieved by calling the stubbed npmconf - t.true(get.calledWithExactly('@scoped/registry')); -}); - -test('Get regular registry for scoped package name', t => { - // Stub the npmconf object, returns 'd' for 'registry' property - const get = stub() - .withArgs('registry') - .returns('d'); - - // Retrieve the registry with the get-registry module and verify it returns the regular default one for `@scoped` packages - t.is(getRegistry({name: '@scoped/baz'}, {get}), 'd'); - - // Verify the module tried to retrieve the @scoped registry by calling the stubbed npmconf - t.true(get.calledWithExactly('@scoped/registry')); -}); - -test('Get default registry for scoped package name', t => { - // Stub the npmconf object, returns 'd' for 'registry' property - const get = stub().returns(null); - - // Retrieve the registry with the get-registry module and verify it returns default one for `@scoped` packages - t.is(getRegistry({name: '@scoped/baz'}, {get}), 'https://registry.npmjs.org/'); - - // Verify the module tried to retrieve the @scoped registry by calling the stubbed npmconf - t.true(get.calledWithExactly('@scoped/registry')); - // Verify the module tried to retrieve the regular registry by calling the stubbed npmconf - t.true(get.calledWithExactly('registry')); -}); diff --git a/test/git.test.js b/test/git.test.js new file mode 100644 index 00000000..f0f87a6c --- /dev/null +++ b/test/git.test.js @@ -0,0 +1,95 @@ +import test from 'ava'; +import {gitRepo, gitCommits, gitCheckout, gitTagVersion, gitShallowClone, gitLog} from './helpers/git-utils'; +import {gitTagHead, gitCommitTag, isCommitInHistory, unshallow, gitHead} from '../lib/git'; + +test.beforeEach(t => { + // Save the current working diretory + t.context.cwd = process.cwd(); +}); + +test.afterEach.always(t => { + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.serial('Get the last commit sha', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + const commits = await gitCommits(['First']); + + const result = await gitHead(); + + t.is(result.substring(0, 7), commits[0].hash); +}); + +test.serial('Throw error if the last commit sha cannot be found', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + + await t.throws(gitHead()); +}); + +test.serial('Unshallow repository', async t => { + // Create a git repository, set the current working directory at the root of the repo + const repo = await gitRepo(); + // Add commits to the master branch + await gitCommits(['First', 'Second']); + // Create a shallow clone with only 1 commit + await gitShallowClone(repo); + + // Verify the shallow clone contains only one commit + t.is((await gitLog()).length, 1); + + await unshallow(); + + // Verify the shallow clone contains all the commits + t.is((await gitLog()).length, 2); +}); + +test.serial('Do not throw error when unshallow a complete repository', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + await gitCommits(['First']); + await t.notThrows(unshallow()); +}); + +test.serial('Verify if the commit `sha` is in the direct history of the current branch', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + const commits = await gitCommits(['First']); + // Create the new branch 'other-branch' from master + await gitCheckout('other-branch'); + // Add commits to the 'other-branch' branch + const otherCommits = await gitCommits(['Second']); + await gitCheckout('master', false); + + t.true(await isCommitInHistory(commits[0].hash)); + t.false(await isCommitInHistory(otherCommits[0].hash)); +}); + +test.serial('Get the tag associated with a commit sha or "null" if the commit does not exists', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + let commits = await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + + t.is(await gitCommitTag(commits[0].hash), 'v1.0.0'); + t.falsy(await gitCommitTag('missing_sha')); +}); + +test.serial('Get the commit sha for a given tag or "null" if the tag does not exists', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + let commits = await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + + t.is((await gitTagHead('v1.0.0')).substring(0, 7), commits[0].hash); + t.falsy(await gitTagHead('missing_tag')); +}); diff --git a/test/github-release.test.js b/test/github-release.test.js deleted file mode 100644 index 77f9bbdb..00000000 --- a/test/github-release.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import test from 'ava'; -import {gitRepo, gitCommits, gitHead} from './helpers/git-utils'; -import nock from 'nock'; -import {authenticate} from './helpers/mock-github'; -import githubRelease from '../lib/github-release'; - -test.beforeEach(t => { - // Save the current working diretory - t.context.cwd = process.cwd(); -}); - -test.afterEach.always(t => { - // Restore the current working directory - process.chdir(t.context.cwd); - // Reset nock - nock.cleanAll(); -}); - -test.serial('Github release with default url', async t => { - // Create a git repository, set the current working directory at the root of the repo - await gitRepo(); - // Add commits to the master branch - await gitCommits(['fix: First fix', 'feat: Second feature']); - - const sha = await gitHead(); - const owner = 'test_user'; - const repo = 'test_repo'; - const githubToken = 'github_token'; - const notes = 'Test release note body'; - const version = '1.0.0'; - const branch = 'master'; - const tagName = `v${version}`; - const options = {branch, githubToken}; - const pkg = {version, repository: {url: `git+https://othertesturl.com/${owner}/${repo}.git`}}; - const releaseUrl = `https://othertesturl.com/${owner}/${repo}/releases/${version}`; - - // Mock github API for releases and git/refs endpoints - const github = authenticate({githubToken}) - .post(`/repos/${owner}/${repo}/releases`, { - tag_name: tagName, - target_commitish: branch, - name: tagName, - body: notes, - }) - .reply(200, {html_url: releaseUrl}) - .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${tagName}`, sha}) - .reply({}); - - // Call the post module - t.is(releaseUrl, await githubRelease(pkg, notes, version, options)); - // Verify the releases and git/refs endpoint have been call with expected requests - t.true(github.isDone()); -}); - -test.serial('Github release with custom url', async t => { - // Create a git repository, set the current working directory at the root of the repo - await gitRepo(); - // Add commits to the master branch - await gitCommits(['fix: First fix', 'feat: Second feature']); - - const sha = await gitHead(); - const owner = 'test_user'; - const repo = 'test_repo'; - const githubUrl = 'https://testurl.com:443'; - const githubToken = 'github_token'; - const githubApiPathPrefix = 'prefix'; - const notes = 'Test release note body'; - const version = '1.0.0'; - const branch = 'master'; - const tagName = `v${version}`; - const options = {branch, githubUrl, githubToken, githubApiPathPrefix}; - const pkg = {version, repository: {url: `git@othertesturl.com:${owner}/${repo}.git`}}; - const releaseUrl = `https://othertesturl.com/${owner}/${repo}/releases/${version}`; - - // Mock github API for releases and git/refs endpoints - const github = authenticate({githubUrl, githubToken, githubApiPathPrefix}) - .post(`/repos/${owner}/${repo}/releases`, { - tag_name: tagName, - target_commitish: branch, - name: tagName, - body: notes, - }) - .reply(200, {html_url: releaseUrl}) - .post(`/repos/${owner}/${repo}/git/refs`, {ref: `refs/tags/${tagName}`, sha}) - .reply({}); - - // Call the post module - t.is(releaseUrl, await githubRelease(pkg, notes, version, options)); - // Verify the releases and git/refs endpoint have been call with expected requests - t.true(github.isDone()); -}); diff --git a/test/helpers/git-utils.js b/test/helpers/git-utils.js index b15af1d8..d7b6f22b 100644 --- a/test/helpers/git-utils.js +++ b/test/helpers/git-utils.js @@ -1,4 +1,3 @@ -import {mkdir} from 'fs-extra'; import tempy from 'tempy'; import execa from 'execa'; import pMapSeries from 'p-map-series'; @@ -15,17 +14,13 @@ import pMapSeries from 'p-map-series'; /** * Create a temporary git repository and change the current working directory to the repository root. * - * @method gitCommits - * @param {Array} commits the created commits. - * * @return {string} The path of the repository. */ export async function gitRepo() { const dir = tempy.directory(); process.chdir(dir); - await mkdir('git-templates'); - await execa('git', ['init', '--template=./git-templates']); + await execa('git', ['init']); await gitCheckout('master'); return dir; } diff --git a/test/helpers/mockserver.js b/test/helpers/mockserver.js index 15b99c2b..97075b84 100644 --- a/test/helpers/mockserver.js +++ b/test/helpers/mockserver.js @@ -53,13 +53,13 @@ const url = `http://${MOCK_SERVER_HOST}:${MOCK_SERVER_PORT}`; * @param {Object} response.body The JSON object to respond in the response body. * @return {Object} An object representation the expectation. Pass to the `verify` function to validate the `mockserver` has been called with a `request` matching the expectations. */ -function mock( +async function mock( path, {body: requestBody, headers: requestHeaders}, {method = 'POST', statusCode = 200, body: responseBody} ) { - client.mockAnyResponse({ - httpRequest: {path}, + await client.mockAnyResponse({ + httpRequest: {path, method}, httpResponse: { statusCode, headers: [{name: 'Content-Type', values: ['application/json; charset=utf-8']}], @@ -72,7 +72,9 @@ function mock( method, path, headers: requestHeaders, - body: {type: 'JSON', json: JSON.stringify(requestBody), matchType: 'ONLY_MATCHING_FIELDS'}, + body: requestBody + ? {type: 'JSON', json: JSON.stringify(requestBody), matchType: 'ONLY_MATCHING_FIELDS'} + : undefined, }; } diff --git a/test/index.test.js b/test/index.test.js index 0d748eac..cd74f90c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,278 +1,211 @@ +import {callbackify} from 'util'; import test from 'ava'; +import {writeJson} from 'fs-extra'; import proxyquire from 'proxyquire'; import {stub} from 'sinon'; -import SemanticReleaseError from '@semantic-release/error'; - -const consoleLog = stub(console, 'log'); +import normalizeData from 'normalize-package-data'; +import {gitHead as getGitHead} from '../lib/git'; +import {gitRepo, gitCommits, gitTagVersion} from './helpers/git-utils'; test.beforeEach(t => { // Save the current process.env t.context.env = Object.assign({}, process.env); // Save the current working diretory t.context.cwd = process.cwd(); + // Stub the logger functions + t.context.log = stub(); + t.context.error = stub(); + t.context.logger = {log: t.context.log, error: t.context.error}; + t.context.semanticRelease = proxyquire('../index', {'./lib/logger': t.context.logger}); + + t.context.stdout = stub(process.stdout, 'write'); + t.context.stderr = stub(process.stderr, 'write'); }); test.afterEach.always(t => { - // Restore the current working directory - process.chdir(t.context.cwd); // Restore process.env process.env = Object.assign({}, t.context.env); + // Restore the current working directory + process.chdir(t.context.cwd); + + t.context.stdout.restore(); + t.context.stderr.restore(); }); -test.after.always(t => { - consoleLog.restore(); -}); +test.serial('Plugins are called with expected values', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + let commits = await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + // Add new commits to the master branch + commits = (await gitCommits(['Second'])).concat(commits); -test('Plugins are called with expected values', async t => { - const env = {NPM_TOKEN: 'NPM_TOKEN'}; - const pkgOptions = {branch: 'master'}; - const cliOptions = {githubToken: 'GH_TOKEN'}; - const options = Object.assign({}, pkgOptions, cliOptions); - const pkg = {name: 'available', release: options, repository: {url: 'http://github.com/whats/up.git'}}; - const npm = {registry: 'http://test.registry.com'}; - const lastRelease = {version: '1.0.0', gitHead: 'test_commit_head'}; - const commitsLastRelease = {version: '1.0.0', gitHead: 'tag_head'}; - const commits = [{hash: '1', message: 'fix: First fix'}, {hash: '2', message: 'feat: First feature'}]; - const nextRelease = {type: 'major', version: '2.0.0'}; + const name = 'package-name'; + const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'}; + const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; const notes = 'Release notes'; - // Stub modules - const log = stub(); - const error = stub(); - const logger = {log, error}; - const verifyAuth = stub().returns(); - const publishNpm = stub().resolves(); - const githubRelease = stub().resolves(); - const getCommits = stub().resolves({commits, lastRelease: commitsLastRelease}); - const getNextVersion = stub().returns(nextRelease.version); - // Stub plugins - const verifyConditions = stub().resolves(); + const verifyConditions1 = stub().resolves(); + const verifyConditions2 = stub().resolves(); const getLastRelease = stub().resolves(lastRelease); const analyzeCommits = stub().resolves(nextRelease.type); const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); - const getConfig = stub().resolves({ - plugins: {getLastRelease, analyzeCommits, verifyRelease, verifyConditions, generateNotes}, - env, - options, - pkg, - npm, - }); + const publish = stub().resolves(); - const semanticRelease = proxyquire('../index', { - './lib/logger': logger, - './lib/verify-auth': verifyAuth, - './lib/get-config': getConfig, - './lib/get-commits': getCommits, - './lib/publish-npm': publishNpm, - './lib/github-release': githubRelease, - './lib/get-next-version': getNextVersion, - }); + const options = { + branch: 'master', + verifyConditions: [callbackify(verifyConditions1), callbackify(verifyConditions2)], + getLastRelease: callbackify(getLastRelease), + analyzeCommits: callbackify(analyzeCommits), + verifyRelease: callbackify(verifyRelease), + generateNotes: callbackify(generateNotes), + publish: callbackify(publish), + }; + const pkg = {name, version: '0.0.0-dev'}; + normalizeData(pkg); - // Call the index module - await semanticRelease(cliOptions); + await writeJson('./package.json', pkg); + + await t.context.semanticRelease(options); + + t.true(verifyConditions1.calledOnce); + t.deepEqual(verifyConditions1.firstCall.args[1], {env: process.env, options, pkg, logger: t.context.logger}); + t.true(verifyConditions2.calledOnce); + t.deepEqual(verifyConditions2.firstCall.args[1], {env: process.env, options, pkg, logger: t.context.logger}); - // Verify the sub-modules have been called with expected parameters - t.true(getConfig.calledOnce); - t.true(getConfig.calledWithExactly(cliOptions)); - t.true(verifyAuth.calledOnce); - t.true(verifyAuth.calledWithExactly(options, env)); - t.true(publishNpm.calledOnce); - t.true(publishNpm.calledWithExactly(pkg, npm, nextRelease)); - t.true(githubRelease.calledOnce); - t.true(githubRelease.calledWithExactly(pkg, notes, nextRelease.version, options)); - // Verify plugins have been called with expected parameters - t.true(verifyConditions.calledOnce); - t.true(verifyConditions.calledWithExactly({env, options, pkg, npm, logger})); t.true(getLastRelease.calledOnce); - t.true(getLastRelease.calledWithExactly({env, options, pkg, npm, logger})); + t.deepEqual(getLastRelease.firstCall.args[1], {env: process.env, options, pkg, logger: t.context.logger}); + t.true(analyzeCommits.calledOnce); - t.true(analyzeCommits.calledWithExactly({env, options, pkg, npm, logger, lastRelease: commitsLastRelease, commits})); + t.deepEqual(analyzeCommits.firstCall.args[1].env, process.env); + t.deepEqual(analyzeCommits.firstCall.args[1].options, options); + t.deepEqual(analyzeCommits.firstCall.args[1].pkg, pkg); + t.deepEqual(analyzeCommits.firstCall.args[1].logger, t.context.logger); + t.deepEqual(analyzeCommits.firstCall.args[1].lastRelease, lastRelease); + t.deepEqual(analyzeCommits.firstCall.args[1].commits[0].hash.substring(0, 7), commits[0].hash); + t.deepEqual(analyzeCommits.firstCall.args[1].commits[0].message, commits[0].message); + t.true(verifyRelease.calledOnce); - t.true( - verifyRelease.calledWithExactly({ - env, - options, - pkg, - npm, - logger, - lastRelease: commitsLastRelease, - commits, - nextRelease, - }) - ); + t.deepEqual(verifyRelease.firstCall.args[1].env, process.env); + t.deepEqual(verifyRelease.firstCall.args[1].options, options); + t.deepEqual(verifyRelease.firstCall.args[1].pkg, pkg); + t.deepEqual(verifyRelease.firstCall.args[1].logger, t.context.logger); + t.deepEqual(verifyRelease.firstCall.args[1].lastRelease, lastRelease); + t.deepEqual(verifyRelease.firstCall.args[1].commits[0].hash.substring(0, 7), commits[0].hash); + t.deepEqual(verifyRelease.firstCall.args[1].commits[0].message, commits[0].message); + t.deepEqual(verifyRelease.firstCall.args[1].nextRelease, nextRelease); + t.true(generateNotes.calledOnce); - t.true( - generateNotes.calledWithExactly({ - env, - options, - pkg, - npm, - logger, - lastRelease: commitsLastRelease, - commits, - nextRelease, - }) - ); + t.deepEqual(generateNotes.firstCall.args[1].env, process.env); + t.deepEqual(generateNotes.firstCall.args[1].options, options); + t.deepEqual(generateNotes.firstCall.args[1].pkg, pkg); + t.deepEqual(generateNotes.firstCall.args[1].logger, t.context.logger); + t.deepEqual(generateNotes.firstCall.args[1].lastRelease, lastRelease); + t.deepEqual(generateNotes.firstCall.args[1].commits[0].hash.substring(0, 7), commits[0].hash); + t.deepEqual(generateNotes.firstCall.args[1].commits[0].message, commits[0].message); + t.deepEqual(generateNotes.firstCall.args[1].nextRelease, nextRelease); + + t.true(publish.calledOnce); + t.deepEqual(publish.firstCall.args[1].options, options); + t.deepEqual(publish.firstCall.args[1].pkg, pkg); + t.deepEqual(publish.firstCall.args[1].logger, t.context.logger); + t.deepEqual(publish.firstCall.args[1].lastRelease, lastRelease); + t.deepEqual(publish.firstCall.args[1].commits[0].hash.substring(0, 7), commits[0].hash); + t.deepEqual(publish.firstCall.args[1].commits[0].message, commits[0].message); + t.deepEqual(publish.firstCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes})); }); -test('Dry-run skips verifyAuth, verifyConditions, publishNpm and githubRelease', async t => { - const env = {NPM_TOKEN: 'NPM_TOKEN'}; - const pkgOptions = {branch: 'master'}; - const cliOptions = {githubToken: 'GH_TOKEN', dryRun: true}; - const options = Object.assign({}, pkgOptions, cliOptions); - const pkg = {name: 'available', release: options, repository: {url: 'http://github.com/whats/up.git'}}; - const npm = {registry: 'http://test.registry.com'}; - const lastRelease = {version: '1.0.0', gitHead: 'test_commit_head'}; - const commitsLastRelease = {version: '1.0.0', gitHead: 'tag_head'}; - const commits = [{hash: '1', message: 'fix: First fix'}, {hash: '2', message: 'feat: First feature'}]; - const nextRelease = {type: 'major', version: '2.0.0'}; +test.serial('Use new gitHead, and recreate release notes if a publish plugin create a commit', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + let commits = await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + // Add new commits to the master branch + commits = (await gitCommits(['Second'])).concat(commits); + + const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'}; + const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; + const notes = 'Release notes'; + + const generateNotes = stub().resolves(notes); + const publish1 = stub().callsFake(async () => { + await gitCommits(['Third']); + }); + const publish2 = stub().resolves(); + + const options = { + branch: 'master', + verifyConditions: callbackify(stub().resolves()), + getLastRelease: callbackify(stub().resolves(lastRelease)), + analyzeCommits: callbackify(stub().resolves(nextRelease.type)), + verifyRelease: callbackify(stub().resolves()), + generateNotes: callbackify(generateNotes), + publish: [callbackify(publish1), callbackify(publish2)], + }; + + await writeJson('./package.json', {}); + await t.context.semanticRelease(options); + + t.true(generateNotes.calledTwice); + t.deepEqual(generateNotes.firstCall.args[1].nextRelease, nextRelease); + t.true(publish1.calledOnce); + t.deepEqual(publish1.firstCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes})); + + nextRelease.gitHead = await getGitHead(); + + t.deepEqual(generateNotes.secondCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes})); + t.true(publish2.calledOnce); + t.deepEqual(publish2.firstCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes})); +}); + +test.serial('Dry-run skips verifyConditions and publish', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + let commits = await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + // Add new commits to the master branch + commits = (await gitCommits(['Second'])).concat(commits); + + const name = 'package-name'; + const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'}; + const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; const notes = 'Release notes'; - // Stub modules - const log = stub(); - const error = stub(); - const logger = {log, error}; - const verifyAuth = stub().returns(); - const publishNpm = stub().resolves(); - const githubRelease = stub().resolves(); - const getCommits = stub().resolves({commits, lastRelease: commitsLastRelease}); - const getNextVersion = stub().returns(nextRelease.version); - // Stub plugins const verifyConditions = stub().resolves(); const getLastRelease = stub().resolves(lastRelease); const analyzeCommits = stub().resolves(nextRelease.type); const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); - const getConfig = stub().resolves({ - plugins: {getLastRelease, analyzeCommits, verifyRelease, verifyConditions, generateNotes}, - env, - options, - pkg, - npm, - }); + const publish = stub().resolves(); - const semanticRelease = proxyquire('../index', { - './lib/logger': logger, - './lib/verify-auth': verifyAuth, - './lib/get-config': getConfig, - './lib/get-commits': getCommits, - './lib/publish-npm': publishNpm, - './lib/github-release': githubRelease, - './lib/get-next-version': getNextVersion, - }); + const options = { + dryRun: true, + branch: 'master', + verifyConditions: callbackify(verifyConditions), + getLastRelease: callbackify(getLastRelease), + analyzeCommits: callbackify(analyzeCommits), + verifyRelease: callbackify(verifyRelease), + generateNotes: callbackify(generateNotes), + publish: callbackify(publish), + }; + const pkg = {name, version: '0.0.0-dev'}; + normalizeData(pkg); - // Call the index module - await semanticRelease(cliOptions); + await writeJson('./package.json', pkg); + + await t.context.semanticRelease(options); - // Verify that publishNpm, githubRelease, verifyAuth, verifyConditions have not been called in a dry run - t.true(publishNpm.notCalled); - t.true(githubRelease.notCalled); - t.true(verifyAuth.notCalled); t.true(verifyConditions.notCalled); - // Verify the release notes are logged - t.true(consoleLog.calledWithMatch(notes)); - // Verify the sub-modules have been called with expected parameters - t.true(getConfig.calledOnce); - t.true(getConfig.calledWithExactly(cliOptions)); - // Verify plugins have been called with expected parameters t.true(getLastRelease.calledOnce); - t.true(getLastRelease.calledWithExactly({env, options, pkg, npm, logger})); t.true(analyzeCommits.calledOnce); - t.true(analyzeCommits.calledWithExactly({env, options, pkg, npm, logger, lastRelease: commitsLastRelease, commits})); t.true(verifyRelease.calledOnce); - t.true( - verifyRelease.calledWithExactly({ - env, - options, - pkg, - npm, - logger, - lastRelease: commitsLastRelease, - commits, - nextRelease, - }) - ); t.true(generateNotes.calledOnce); - t.true( - generateNotes.calledWithExactly({ - env, - options, - pkg, - npm, - logger, - lastRelease: commitsLastRelease, - commits, - nextRelease, - }) - ); -}); - -test('Throw SemanticReleaseError if there is no release to be done', async t => { - const env = {NPM_TOKEN: 'NPM_TOKEN'}; - const pkgOptions = {branch: 'master'}; - const cliOptions = {githubToken: 'GH_TOKEN'}; - const options = Object.assign({}, pkgOptions, cliOptions); - const pkg = {name: 'available', release: options, repository: {url: 'http://github.com/whats/up.git'}}; - const npm = {registry: 'http://test.registry.com'}; - const lastRelease = {version: '1.0.0', gitHead: 'test_commit_head'}; - const commitsLastRelease = {version: '1.0.0', gitHead: 'tag_head'}; - const commits = [{hash: '1', message: 'fix: First fix'}, {hash: '2', message: 'feat: First feature'}]; - const nextRelease = {type: undefined}; - - // Stub modules - const log = stub(); - const error = stub(); - const logger = {log, error}; - const verifyAuth = stub().returns(); - const publishNpm = stub().resolves(); - const githubRelease = stub().resolves(); - const getCommits = stub().resolves({commits, lastRelease: commitsLastRelease}); - const getNextVersion = stub().returns(null); - // Stub plugins - const verifyConditions = stub().resolves(); - const getLastRelease = stub().resolves(lastRelease); - const analyzeCommits = stub().resolves(nextRelease.type); - const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(); - const getConfig = stub().resolves({ - plugins: {getLastRelease, analyzeCommits, verifyRelease, verifyConditions, generateNotes}, - env, - options, - pkg, - npm, - }); - - const semanticRelease = proxyquire('../index', { - './lib/logger': logger, - './lib/verify-auth': verifyAuth, - './lib/get-config': getConfig, - './lib/get-commits': getCommits, - './lib/publish-npm': publishNpm, - './lib/github-release': githubRelease, - './lib/get-next-version': getNextVersion, - }); - - // Call the index module - const err = await t.throws(semanticRelease(cliOptions)); - // Verify error code and type - t.is(err.code, 'ENOCHANGE'); - t.true(err instanceof SemanticReleaseError); - // Verify the sub-modules have been called with expected parameters - t.true(getConfig.calledOnce); - t.true(getConfig.calledWithExactly(cliOptions)); - t.true(verifyAuth.calledOnce); - t.true(verifyAuth.calledWithExactly(options, env)); - // Verify plugins have been called with expected parameters - t.true(verifyConditions.calledOnce); - t.true(verifyConditions.calledWithExactly({env, options, pkg, npm, logger})); - t.true(getLastRelease.calledOnce); - t.true(getLastRelease.calledWithExactly({env, options, pkg, npm, logger})); - t.true(analyzeCommits.calledOnce); - t.true(analyzeCommits.calledWithExactly({env, options, pkg, npm, logger, lastRelease: commitsLastRelease, commits})); - // Verify that verifyRelease, publishNpm, generateNotes, githubRelease have not been called when no release is done - t.true(verifyRelease.notCalled); - t.true(generateNotes.notCalled); - t.true(publishNpm.notCalled); - t.true(githubRelease.notCalled); + t.true(publish.notCalled); }); diff --git a/test/integration.test.js b/test/integration.test.js index 9b0a737d..53ca9010 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -11,15 +11,17 @@ import semanticRelease from '..'; const env = { npm_config_registry: registry.uri, GH_TOKEN: 'github_token', - NPM_OLD_TOKEN: 'aW50ZWdyYXRpb246c3VjaHNlY3VyZQ==', + GITHUB_URL: mockServer.url, NPM_EMAIL: 'integration@test.com', + NPM_USERNAME: 'integration', + NPM_PASSWORD: 'suchsecure', }; const cli = require.resolve('../bin/semantic-release'); -const noop = require.resolve('../lib/plugin-noop'); -const pluginError = require.resolve('./fixtures/plugin-error-a'); +const pluginError = require.resolve('./fixtures/plugin-error'); const pluginInheritedError = require.resolve('./fixtures/plugin-error-inherited'); test.before(async t => { + // Start Mock Server await mockServer.start(); // Start the local NPM registry await registry.start(); @@ -33,6 +35,8 @@ test.beforeEach(async t => { t.context.log = stub(console, 'log'); t.context.error = stub(console, 'error'); + t.context.stdout = stub(process.stdout, 'write'); + t.context.stderr = stub(process.stderr, 'write'); }); test.afterEach.always(async t => { @@ -43,6 +47,8 @@ test.afterEach.always(async t => { t.context.log.restore(); t.context.error.restore(); + t.context.stdout.restore(); + t.context.stderr.restore(); }); test.after.always(async t => { @@ -52,8 +58,8 @@ test.after.always(async t => { }); test.serial('Release patch, minor and major versions', async t => { - const packageName = 'test-module'; - const repo = 'test-repo'; + const packageName = 'test-release'; + const owner = 'test-owner'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository and package.json'); await gitRepo(); @@ -61,14 +67,20 @@ test.serial('Release patch, minor and major versions', async t => { await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {verifyConditions: noop, githubUrl: mockServer.url}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, + release: {verifyConditions: ['@semantic-release/github', '@semantic-release/npm']}, + publishConfig: {registry: registry.uri}, }); // Create a npm-shrinkwrap.json file await execa('npm', ['shrinkwrap'], {env}); /** No release **/ + let verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); t.log('Commit a chore'); await gitCommits(['chore: Init repository']); t.log('$ semantic-release'); @@ -78,13 +90,18 @@ test.serial('Release patch, minor and major versions', async t => { /** Initial release **/ let version = '1.0.0'; - let createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + let createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - let createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + let createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -97,7 +114,7 @@ test.serial('Release patch, minor and major versions', async t => { t.log('$ semantic-release'); ({stdout, code} = await execa(cli, [], {env})); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json and npm-shrinkwrap.json have been updated @@ -112,18 +129,24 @@ test.serial('Release patch, minor and major versions', async t => { t.is(releasedGitHead, await gitHead()); t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); /** Patch release **/ version = '1.0.1'; - createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -136,7 +159,7 @@ test.serial('Release patch, minor and major versions', async t => { t.log('$ semantic-release'); ({stdout, code} = await execa(cli, [], {env})); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json and npm-shrinkwrap.json have been updated @@ -145,24 +168,30 @@ test.serial('Release patch, minor and major versions', async t => { // Retrieve the published package from the registry and check version and gitHead [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec( - (await execa('npm', ['show', 'test-module', 'version', 'gitHead'], {env})).stdout + (await execa('npm', ['show', packageName, 'version', 'gitHead'], {env})).stdout ); t.is(releasedVersion, version); t.is(releasedGitHead, await gitHead()); t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); /** Minor release **/ version = '1.1.0'; - createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -175,7 +204,7 @@ test.serial('Release patch, minor and major versions', async t => { t.log('$ semantic-release'); ({stdout, code} = await execa(cli, [], {env})); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json and npm-shrinkwrap.json have been updated @@ -184,24 +213,30 @@ test.serial('Release patch, minor and major versions', async t => { // Retrieve the published package from the registry and check version and gitHead [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec( - (await execa('npm', ['show', 'test-module', 'version', 'gitHead'], {env})).stdout + (await execa('npm', ['show', packageName, 'version', 'gitHead'], {env})).stdout ); t.is(releasedVersion, version); t.is(releasedGitHead, await gitHead()); t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); /** Major release **/ version = '2.0.0'; - createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -214,7 +249,7 @@ test.serial('Release patch, minor and major versions', async t => { t.log('$ semantic-release'); ({stdout, code} = await execa(cli, [], {env})); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json and npm-shrinkwrap.json have been updated @@ -223,19 +258,20 @@ test.serial('Release patch, minor and major versions', async t => { // Retrieve the published package from the registry and check version and gitHead [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec( - (await execa('npm', ['show', 'test-module', 'version', 'gitHead'], {env})).stdout + (await execa('npm', ['show', packageName, 'version', 'gitHead'], {env})).stdout ); t.is(releasedVersion, version); t.is(releasedGitHead, await gitHead()); t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); }); test.serial('Release versions from a packed git repository, using tags to determine last release gitHead', async t => { - const packageName = 'test-module-2'; - const repo = 'test-repo'; + const packageName = 'test-git-packaed'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository'); await gitRepo(); @@ -244,19 +280,25 @@ test.serial('Release versions from a packed git repository, using tags to determ await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git@github.com:${repo}/${packageName}.git`}, - release: {verifyConditions: noop, githubUrl: mockServer.url}, + repository: {url: `git@github.com:${owner}/${packageName}.git`}, + release: {verifyConditions: ['@semantic-release/github', '@semantic-release/npm']}, + publishConfig: {registry: registry.uri}, }); /** Initial release **/ let version = '1.0.0'; - let createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + let verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + let createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - let createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + let createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -270,7 +312,7 @@ test.serial('Release versions from a packed git repository, using tags to determ t.log('$ semantic-release'); let {stdout, code} = await execa(cli, [], {env}); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json has been updated t.is((await readJson('./package.json')).version, version); @@ -278,6 +320,7 @@ test.serial('Release versions from a packed git repository, using tags to determ let releasedVersion = (await execa('npm', ['show', packageName, 'version'], {env})).stdout; t.is(releasedVersion, version); t.log(`+ released ${releasedVersion}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); // Create a tag version so the tag can be used later to determine the commit associated with the version @@ -286,13 +329,18 @@ test.serial('Release versions from a packed git repository, using tags to determ /** Patch release **/ version = '1.0.1'; - createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -304,7 +352,7 @@ test.serial('Release versions from a packed git repository, using tags to determ t.log('$ semantic-release'); ({stdout, code} = await execa(cli, [], {env})); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json has been updated t.is((await readJson('./package.json')).version, version); @@ -313,21 +361,21 @@ test.serial('Release versions from a packed git repository, using tags to determ releasedVersion = (await execa('npm', ['show', packageName, 'version'], {env})).stdout; t.is(releasedVersion, version); t.log(`+ released ${releasedVersion}`); - + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); }); test.serial('Exit with 1 if a plugin is not found', async t => { - const packageName = 'test-module-3'; - const repo = 'test-repo'; + const packageName = 'test-plugin-not-found'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository'); await gitRepo(); await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, release: {analyzeCommits: 'non-existing-path'}, }); @@ -337,8 +385,8 @@ test.serial('Exit with 1 if a plugin is not found', async t => { }); test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', async t => { - const packageName = 'test-module-4'; - const repo = 'test-repo'; + const packageName = 'test-recovery'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository'); await gitRepo(); @@ -347,19 +395,25 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {verifyConditions: noop, githubUrl: mockServer.url}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, + release: {verifyConditions: ['@semantic-release/github', '@semantic-release/npm']}, + publishConfig: {registry: registry.uri}, }); /** Initial release **/ let version = '1.0.0'; - let createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + let verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + let createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - let createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + let createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -371,7 +425,7 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy t.log('$ semantic-release'); let {stderr, stdout, code} = await execa(cli, [], {env}); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json has been updated t.is((await readJson('./package.json')).version, version); @@ -384,6 +438,7 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy t.is(releasedGitHead, head); t.is(releasedVersion, version); t.log(`+ released ${releasedVersion}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); @@ -397,7 +452,11 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy const {hash} = await gitAmmendCommit('feat: Initial commit'); /** Patch release **/ - + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); t.log('Commit a fix'); await gitCommits(['fix: bar']); t.log('$ semantic-release'); @@ -420,13 +479,18 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy await gitTagVersion(`v${version}`, hash); version = '1.0.1'; - createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}], @@ -437,7 +501,7 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy t.log('$ semantic-release'); ({stderr, stdout, code} = await execa(cli, [], {env})); t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json has been updated t.is((await readJson('./package.json')).version, version); @@ -446,13 +510,14 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy releasedVersion = (await execa('npm', ['show', packageName, 'version'], {env})).stdout; t.is(releasedVersion, version); t.log(`+ released ${releasedVersion}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); }); test.serial('Dry-run', async t => { - const packageName = 'test-module-5'; - const repo = 'test-repo'; + const packageName = 'test-dry-run'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository and package.json'); await gitRepo(); @@ -460,8 +525,7 @@ test.serial('Dry-run', async t => { await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {githubUrl: mockServer.url}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, }); /** Initial release **/ @@ -480,9 +544,8 @@ test.serial('Dry-run', async t => { }); test.serial('Pass options via CLI arguments', async t => { - const packageName = 'test-module-6'; - const repo = 'test-repo'; - const githubToken = 'OTHER_TOKEN'; + const packageName = 'test-cli'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository and package.json'); await gitRepo(); @@ -490,36 +553,21 @@ test.serial('Pass options via CLI arguments', async t => { await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {githubUrl: mockServer.url}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, + publishConfig: {registry: registry.uri}, }); /** Initial release **/ let version = '1.0.0'; - let createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, - {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${githubToken}`]}]}, - {body: {ref: `refs/tags/${version}`}} - ); - let createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, - { - body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, - headers: [{name: 'Authorization', values: [`token ${githubToken}`]}], - }, - {body: {html_url: `release-url/${version}`}} - ); - t.log('Commit a feature'); await gitCommits(['feat: Initial commit']); t.log('$ semantic-release'); let {stdout, code} = await execa( cli, - ['--github-token', githubToken, '--verify-conditions', `${noop}, ${noop}`, '--debug'], + ['--verify-conditions', '@semantic-release/npm', '--publish', '@semantic-release/npm', '--debug'], {env} ); - t.regex(stdout, new RegExp(`Published Github release: release-url/${version}`)); - t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry ${registry.uri}`)); + t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); t.is(code, 0); // Verify package.json and has been updated @@ -532,14 +580,11 @@ test.serial('Pass options via CLI arguments', async t => { t.is(releasedVersion, version); t.is(releasedGitHead, await gitHead()); t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`); - - await mockServer.verify(createRefMock); - await mockServer.verify(createReleaseMock); }); test.serial('Run via JS API', async t => { - const packageName = 'test-module-7'; - const repo = 'test-repo'; + const packageName = 'test-js-api'; + const owner = 'test-repo'; const githubToken = 'OTHER_TOKEN'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository and package.json'); @@ -548,19 +593,24 @@ test.serial('Run via JS API', async t => { await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {githubUrl: mockServer.url}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, + publishConfig: {registry: registry.uri}, }); /** Initial release **/ let version = '1.0.0'; - let createRefMock = mockServer.mock( - `/repos/${repo}/${packageName}/git/refs`, + let verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${githubToken}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); + let createRefMock = await mockServer.mock( + `/repos/${owner}/${packageName}/git/refs`, {body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${githubToken}`]}]}, {body: {ref: `refs/tags/${version}`}} ); - let createReleaseMock = mockServer.mock( - `/repos/${repo}/${packageName}/releases`, + let createReleaseMock = await mockServer.mock( + `/repos/${owner}/${packageName}/releases`, { body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`}, headers: [{name: 'Authorization', values: [`token ${githubToken}`]}], @@ -573,10 +623,14 @@ test.serial('Run via JS API', async t => { t.log('Commit a feature'); await gitCommits(['feat: Initial commit']); t.log('$ Call semantic-release via API'); - await semanticRelease({githubToken, verifyConditions: [noop, noop], debug: true}); + await semanticRelease({ + verifyConditions: [{path: '@semantic-release/github', githubToken}, '@semantic-release/npm'], + publish: [{path: '@semantic-release/github', githubToken}, '@semantic-release/npm'], + debug: true, + }); t.true(t.context.log.calledWithMatch(/Published Github release: /, new RegExp(`release-url/${version}`))); - t.true(t.context.log.calledWithMatch(/Publishing version .* to npm registry/, version, registry.uri)); + t.true(t.context.log.calledWithMatch(/Publishing version .* to npm registry/, version)); // Verify package.json and has been updated t.is((await readJson('./package.json')).version, version); @@ -589,37 +643,14 @@ test.serial('Run via JS API', async t => { t.is(releasedGitHead, await gitHead()); t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`); + await mockServer.verify(verifyMock); await mockServer.verify(createRefMock); await mockServer.verify(createReleaseMock); }); -test.serial('Returns and error code if NPM token is invalid', async t => { - const env = {npm_config_registry: registry.uri, GH_TOKEN: 'github_token', NPM_TOKEN: 'wrong_token'}; - const packageName = 'test-module-8'; - const repo = 'test-repo'; - // Create a git repository, set the current working directory at the root of the repo - t.log('Create git repository and package.json'); - await gitRepo(); - // Create package.json in repository root - await writeJson('./package.json', { - name: packageName, - version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {verifyConditions: noop, githubUrl: mockServer.url}, - }); - - t.log('Commit a feature'); - await gitCommits(['feat: Initial commit']); - t.log('$ semantic-release'); - let {stderr, code} = await execa(cli, [], {env, reject: false}); - - t.regex(stderr, /forbidden Please log in before writing to the db/); - t.is(code, 1); -}); - test.serial('Log unexpected errors from plugins and exit with 1', async t => { - const packageName = 'test-module-9'; - const repo = 'test-repo'; + const packageName = 'test-unexpected-error'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository and package.json'); await gitRepo(); @@ -627,8 +658,8 @@ test.serial('Log unexpected errors from plugins and exit with 1', async t => { await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {githubUrl: mockServer.url, verifyConditions: pluginError}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, + release: {verifyConditions: pluginError}, }); /** Initial release **/ @@ -646,8 +677,8 @@ test.serial('Log unexpected errors from plugins and exit with 1', async t => { }); test.serial('Log errors inheriting SemanticReleaseError and exit with 0', async t => { - const packageName = 'test-module-10'; - const repo = 'test-repo'; + const packageName = 'test-inherited-error'; + const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo t.log('Create git repository and package.json'); await gitRepo(); @@ -655,8 +686,8 @@ test.serial('Log errors inheriting SemanticReleaseError and exit with 0', async await writeJson('./package.json', { name: packageName, version: '0.0.0-dev', - repository: {url: `git+https://github.com/${repo}/${packageName}`}, - release: {githubUrl: mockServer.url, verifyConditions: pluginInheritedError}, + repository: {url: `git+https://github.com/${owner}/${packageName}`}, + release: {verifyConditions: pluginInheritedError}, }); /** Initial release **/ diff --git a/test/plugins.test.js b/test/plugins.test.js deleted file mode 100644 index 1938e463..00000000 --- a/test/plugins.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import {promisify} from 'util'; -import test from 'ava'; -import proxyquire from 'proxyquire'; -import {stub, match} from 'sinon'; - -test.beforeEach(t => { - // Stub the logger functions - t.context.log = stub(); - t.context.plugins = proxyquire('../lib/plugins', {'./logger': {log: t.context.log}}); -}); - -test('Export plugins', t => { - // Call the plugin module - const defaultPlugins = t.context.plugins({}); - - // Verify the module returns a function for each plugin - t.is(typeof defaultPlugins.analyzeCommits, 'function'); - t.is(typeof defaultPlugins.generateNotes, 'function'); - t.is(typeof defaultPlugins.verifyConditions, 'function'); - t.is(typeof defaultPlugins.verifyRelease, 'function'); - t.is(typeof defaultPlugins.getLastRelease, 'function'); -}); - -test('Pipeline - Get all results', async t => { - // Call the plugin module with a verifyRelease plugin pipeline - const pipelinePlugins = t.context.plugins({ - verifyRelease: ['./lib/plugin-noop', './test/fixtures/plugin-result-a', './test/fixtures/plugin-result-b'], - }); - - // Call the verifyRelease pipeline - const results = await pipelinePlugins.verifyRelease({}); - - // Verify the pipeline return the expected result for each plugin, in order - t.deepEqual(results, [undefined, 'a', 'b']); - // Verify the logger has been called with the plugins path - t.true(t.context.log.calledWith(match.string, './lib/plugin-noop')); - t.true(t.context.log.calledWith(match.string, './test/fixtures/plugin-result-a')); - t.true(t.context.log.calledWith(match.string, './test/fixtures/plugin-result-b')); -}); - -test('Pipeline - Pass pluginConfig and options to each plugins', async t => { - // Plugin configuration with options (plugin-result-config is a mock plugin returning its pluginConfig and options parameters) - const pluginConfig = {path: './test/fixtures/plugin-result-config', pluginParam: 'param1'}; - // Semantic-release global options - const options = {semanticReleaseParam: 'param2'}; - // Call the plugin module with a verifyRelease plugin pipeline - const pipelinePlugins = t.context.plugins({ - verifyRelease: [pluginConfig, './test/fixtures/plugin-result-config'], - }); - - // Call the verifyRelease pipeline - const results = await pipelinePlugins.verifyRelease(options); - - // Verify the pipeline first result is the pluginConfig and options parameters (to verify the plugin was called with the defined pluginConfig and options parameters) - t.deepEqual(results, [{pluginConfig, options}, {pluginConfig: {}, options}]); - // Verify the logger has been called with the plugins path - t.true(t.context.log.calledWith(match.string, './test/fixtures/plugin-result-config')); -}); - -test('Pipeline - Get first error', async t => { - // Call the plugin module with a verifyRelease plugin pipeline - const pipelinePlugins = t.context.plugins({ - verifyRelease: ['./lib/plugin-noop', './test/fixtures/plugin-error-a', './test/fixtures/plugin-error-b'], - }); - - // Call the verifyRelease pipeline and verify it returns the error thrown by './test/fixtures/plugin-error-a' - await t.throws(pipelinePlugins.verifyRelease({}), 'a'); - // Verify the logger has been called with the plugins path - t.true(t.context.log.calledWith(match.string, './lib/plugin-noop')); - t.true(t.context.log.calledWith(match.string, './test/fixtures/plugin-error-a')); -}); - -test('Normalize and load plugin from string', t => { - // Call the normalize function with a path - const plugin = t.context.plugins.normalize('./lib/plugin-noop'); - - // Verify the plugin is loaded - t.is(typeof plugin, 'function'); - // Verify the logger has been called with the plugins path - t.true(t.context.log.calledWith(match.string, './lib/plugin-noop')); -}); - -test('Normalize and load plugin from object', t => { - // Call the normalize function with an object (with path property) - const plugin = t.context.plugins.normalize({path: './lib/plugin-noop'}); - - // Verify the plugin is loaded - t.is(typeof plugin, 'function'); - // Verify the logger has been called with the plugins path - t.true(t.context.log.calledWith(match.string, './lib/plugin-noop')); -}); - -test('Load from fallback', t => { - // Call the normalize function with a fallback - const plugin = t.context.plugins.normalize(null, '../lib/plugin-noop'); - - // Verify the fallback plugin is loaded - t.is(typeof plugin, 'function'); -}); - -test('Always pass a defined "pluginConfig" for plugin defined with string', async t => { - // Call the normalize function with the path of a plugin that returns its config - const plugin = t.context.plugins.normalize('./test/fixtures/plugin-result-config'); - const pluginResult = await promisify(plugin)({}); - - t.deepEqual(pluginResult.pluginConfig, {}); -}); - -test('Always pass a defined "pluginConfig" for plugin defined with path', async t => { - // Call the normalize function with the path of a plugin that returns its config - const plugin = t.context.plugins.normalize({path: './test/fixtures/plugin-result-config'}); - const pluginResult = await promisify(plugin)({}); - - t.deepEqual(pluginResult.pluginConfig, {path: './test/fixtures/plugin-result-config'}); -}); - -test('Always pass a defined "pluginConfig" for fallback plugin', async t => { - // Call the normalize function with the path of a plugin that returns its config - const plugin = t.context.plugins.normalize(null, '../test/fixtures/plugin-result-config'); - const pluginResult = await promisify(plugin)({}); - - t.deepEqual(pluginResult.pluginConfig, {}); -}); diff --git a/test/plugins/definitions.test.js b/test/plugins/definitions.test.js new file mode 100644 index 00000000..25820267 --- /dev/null +++ b/test/plugins/definitions.test.js @@ -0,0 +1,100 @@ +import test from 'ava'; +import definitions from '../../lib/plugins/definitions'; + +test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => { + t.false(definitions.verifyConditions.config.validator({})); + t.false(definitions.verifyConditions.config.validator({path: null})); + + t.true(definitions.verifyConditions.config.validator({path: 'plugin-path.js'})); + t.true(definitions.verifyConditions.config.validator()); + t.true(definitions.verifyConditions.config.validator('plugin-path.js')); + t.true(definitions.verifyConditions.config.validator(() => {})); + t.true(definitions.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); +}); + +test('The "getLastRelease" plugin is mandatory, and must be a single plugin definition', t => { + t.false(definitions.getLastRelease.config.validator({})); + t.false(definitions.getLastRelease.config.validator({path: null})); + t.false(definitions.getLastRelease.config.validator([])); + t.false(definitions.getLastRelease.config.validator()); + + t.true(definitions.getLastRelease.config.validator({path: 'plugin-path.js'})); + t.true(definitions.getLastRelease.config.validator('plugin-path.js')); + t.true(definitions.getLastRelease.config.validator(() => {})); +}); + +test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => { + t.false(definitions.analyzeCommits.config.validator({})); + t.false(definitions.analyzeCommits.config.validator({path: null})); + t.false(definitions.analyzeCommits.config.validator([])); + t.false(definitions.analyzeCommits.config.validator()); + + t.true(definitions.analyzeCommits.config.validator({path: 'plugin-path.js'})); + t.true(definitions.analyzeCommits.config.validator('plugin-path.js')); + t.true(definitions.analyzeCommits.config.validator(() => {})); +}); + +test('The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition', t => { + t.false(definitions.verifyRelease.config.validator({})); + t.false(definitions.verifyRelease.config.validator({path: null})); + + t.true(definitions.verifyRelease.config.validator({path: 'plugin-path.js'})); + t.true(definitions.verifyRelease.config.validator()); + t.true(definitions.verifyRelease.config.validator('plugin-path.js')); + t.true(definitions.verifyRelease.config.validator(() => {})); + t.true(definitions.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); +}); + +test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => { + t.false(definitions.generateNotes.config.validator({})); + t.false(definitions.generateNotes.config.validator({path: null})); + t.false(definitions.generateNotes.config.validator([])); + + t.true(definitions.generateNotes.config.validator()); + t.true(definitions.generateNotes.config.validator({path: 'plugin-path.js'})); + t.true(definitions.generateNotes.config.validator('plugin-path.js')); + t.true(definitions.generateNotes.config.validator(() => {})); +}); + +test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => { + t.false(definitions.publish.config.validator({})); + t.false(definitions.publish.config.validator({path: null})); + t.false(definitions.publish.config.validator()); + + t.true(definitions.publish.config.validator({path: 'plugin-path.js'})); + t.true(definitions.publish.config.validator('plugin-path.js')); + t.true(definitions.publish.config.validator(() => {})); + t.true(definitions.publish.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); +}); + +test('The "getLastRelease" plugin output if defined, must be an object with an optionnal valid semver version in the "version" property', t => { + t.false(definitions.getLastRelease.output.validator('string')); + t.false(definitions.getLastRelease.output.validator(1)); + t.false(definitions.getLastRelease.output.validator({version: 'invalid'})); + + t.true(definitions.getLastRelease.output.validator()); + t.true(definitions.getLastRelease.output.validator({})); + t.true(definitions.getLastRelease.output.validator({version: 'v1.0.0'})); + t.true(definitions.getLastRelease.output.validator({version: '1.0.0'})); + t.true(definitions.getLastRelease.output.validator({version: null})); +}); + +test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => { + t.false(definitions.analyzeCommits.output.validator('invalid')); + t.false(definitions.analyzeCommits.output.validator(1)); + t.false(definitions.analyzeCommits.output.validator({})); + + t.true(definitions.analyzeCommits.output.validator()); + t.true(definitions.analyzeCommits.output.validator(null)); + t.true(definitions.analyzeCommits.output.validator('major')); +}); + +test('The "generateNotes" plugin output must be a string', t => { + t.false(definitions.generateNotes.output.validator()); + t.false(definitions.generateNotes.output.validator(null)); + t.false(definitions.generateNotes.output.validator(1)); + t.false(definitions.generateNotes.output.validator({})); + + t.true(definitions.generateNotes.output.validator('')); + t.true(definitions.generateNotes.output.validator('string')); +}); diff --git a/test/plugins/normalize.test.js b/test/plugins/normalize.test.js new file mode 100644 index 00000000..22f2ec3d --- /dev/null +++ b/test/plugins/normalize.test.js @@ -0,0 +1,116 @@ +import {callbackify} from 'util'; +import test from 'ava'; +import {noop} from 'lodash'; +import {stub, match} from 'sinon'; +import normalize from '../../lib/plugins/normalize'; + +test.beforeEach(t => { + // Stub the logger functions + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test('Normalize and load plugin from string', t => { + const plugin = normalize('', './test/fixtures/plugin-noop', t.context.logger); + + t.is(typeof plugin, 'function'); + t.true(t.context.log.calledWith(match.string, './test/fixtures/plugin-noop')); +}); + +test('Normalize and load plugin from object', t => { + const plugin = normalize('', {path: './test/fixtures/plugin-noop'}, t.context.logger); + + t.is(typeof plugin, 'function'); + t.true(t.context.log.calledWith(match.string, './test/fixtures/plugin-noop')); +}); + +test('Normalize and load plugin from function', t => { + const plugin = normalize('', () => {}, t.context.logger); + + t.is(typeof plugin, 'function'); +}); + +test('Normalize and load plugin that retuns multiple functions', t => { + const plugin = normalize('verifyConditions', './test/fixtures/multi-plugin', t.context.logger); + + t.is(typeof plugin, 'function'); + t.true(t.context.log.calledWith(match.string, './test/fixtures/multi-plugin')); +}); + +test('Wrap plugin in a function that validate the output of the plugin', async t => { + const pluginFunction = stub().resolves(1); + const plugin = normalize('', callbackify(pluginFunction), t.context.logger, { + validator: output => output === 1, + message: 'The output must be 1', + }); + + await t.notThrows(plugin()); + + pluginFunction.resolves(2); + const error = await t.throws(plugin()); + t.is(error.message, 'The output must be 1. Received: 2'); +}); + +test('Plugin is called with "pluginConfig" (omitting "path") and input', async t => { + const pluginFunction = stub().resolves(); + const conf = {path: callbackify(pluginFunction), conf: 'confValue'}; + const plugin = normalize('', conf, t.context.logger); + await plugin('param'); + + t.true(pluginFunction.calledWith({conf: 'confValue'}, 'param')); +}); + +test('Prevent plugins to modify "pluginConfig"', async t => { + const pluginFunction = stub().callsFake((pluginConfig, options, cb) => { + pluginConfig.conf.subConf = 'otherConf'; + cb(); + }); + const conf = {path: pluginFunction, conf: {subConf: 'originalConf'}}; + const plugin = normalize('', conf, t.context.logger); + await plugin(); + + t.is(conf.conf.subConf, 'originalConf'); +}); + +test('Prevent plugins to modify its input', async t => { + const pluginFunction = stub().callsFake((pluginConfig, options, cb) => { + options.param.subParam = 'otherParam'; + cb(); + }); + const input = {param: {subParam: 'originalSubParam'}}; + const plugin = normalize('', pluginFunction, t.context.logger); + await plugin(input); + + t.is(input.param.subParam, 'originalSubParam'); +}); + +test('Return noop if the plugin is not defined', async t => { + const plugin = normalize(); + + t.is(plugin, noop); +}); + +test('Always pass a defined "pluginConfig" for plugin defined with string', async t => { + // Call the normalize function with the path of a plugin that returns its config + const plugin = normalize('', './test/fixtures/plugin-result-config', t.context.logger); + const pluginResult = await plugin(); + + t.deepEqual(pluginResult.pluginConfig, {}); +}); + +test('Always pass a defined "pluginConfig" for plugin defined with path', async t => { + // Call the normalize function with the path of a plugin that returns its config + const plugin = normalize('', {path: './test/fixtures/plugin-result-config'}, t.context.logger); + const pluginResult = await plugin(); + + t.deepEqual(pluginResult.pluginConfig, {}); +}); + +test('Throws an error if the plugin return an object without the expected plugin function', async t => { + const error = t.throws(() => normalize('inexistantPlugin', './test/fixtures/multi-plugin', t.context.logger)); + + t.is( + error.message, + 'The inexistantPlugin plugin must be a function, or an object with a function in the property inexistantPlugin.' + ); +}); diff --git a/test/plugins/pipeline.test.js b/test/plugins/pipeline.test.js new file mode 100644 index 00000000..ea26f2cf --- /dev/null +++ b/test/plugins/pipeline.test.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {stub} from 'sinon'; +import pipeline from '../../lib/plugins/pipeline'; + +test('Execute each function in series passing the same input', async t => { + const step1 = stub().resolves(1); + const step2 = stub().resolves(2); + const step3 = stub().resolves(3); + + const result = await pipeline([step1, step2, step3])(0); + t.deepEqual(result, [1, 2, 3]); + t.true(step1.calledWith(0)); + t.true(step2.calledWith(0)); + t.true(step3.calledWith(0)); +}); + +test('Execute each function in series passing a transformed input', async t => { + const step1 = stub().resolves(1); + const step2 = stub().resolves(2); + const step3 = stub().resolves(3); + + const result = await pipeline([step1, step2, step3])(0, (prevResult, result) => prevResult + result); + + t.deepEqual(result, [1, 2, 3]); + t.true(step1.calledWith(0)); + t.true(step2.calledWith(1)); + t.true(step3.calledWith(3)); +}); + +test('Stop execution and throw error is a step rejects', async t => { + const step1 = stub().resolves(1); + const step2 = stub().throws(new Error('test error')); + const step3 = stub().resolves(3); + + const error = await t.throws(pipeline([step1, step2, step3])(0)); + t.is(error.message, 'test error'); + t.true(step1.calledWith(0)); + t.true(step2.calledWith(0)); + t.true(step3.notCalled); +}); diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js new file mode 100644 index 00000000..41b8abca --- /dev/null +++ b/test/plugins/plugins.test.js @@ -0,0 +1,49 @@ +import test from 'ava'; +import {stub} from 'sinon'; +import getPlugins from '../../lib/plugins'; + +test.beforeEach(t => { + // Stub the logger functions + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test('Export default plugins', t => { + // Call the plugin module + const plugins = getPlugins({}, t.context.logger); + // Verify the module returns a function for each plugin + t.is(typeof plugins.verifyConditions, 'function'); + t.is(typeof plugins.getLastRelease, 'function'); + t.is(typeof plugins.analyzeCommits, 'function'); + t.is(typeof plugins.verifyRelease, 'function'); + t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.publish, 'function'); +}); + +test('Export plugins based on config', t => { + // Call the plugin module + const plugins = getPlugins( + { + verifyConditions: ['./test/fixtures/plugin-noop', {path: './test/fixtures/plugin-noop'}], + getLastRelease: './test/fixtures/plugin-noop', + analyzeCommits: {path: './test/fixtures/plugin-noop'}, + verifyRelease: () => {}, + }, + t.context.logger + ); + // Verify the module returns a function for each plugin + t.is(typeof plugins.verifyConditions, 'function'); + t.is(typeof plugins.getLastRelease, 'function'); + t.is(typeof plugins.analyzeCommits, 'function'); + t.is(typeof plugins.verifyRelease, 'function'); + t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.publish, 'function'); +}); + +test('Throw an error if plugin configuration is invalid', t => { + const error = t.throws(() => getPlugins({verifyConditions: {}}, t.context.logger)); + t.is( + error.message, + 'The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.' + ); +}); diff --git a/test/verify-auth.test.js b/test/verify-auth.test.js deleted file mode 100644 index 71e1dda1..00000000 --- a/test/verify-auth.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import test from 'ava'; -import SemanticReleaseError from '@semantic-release/error'; -import verify from '../lib/verify-auth'; - -test('Verify npm and github auth', t => { - // Call the verify module with options and env - t.notThrows(() => verify({githubToken: 'sup'}, {NPM_TOKEN: 'yo'})); -}); - -test('Verify npm (old token and mail) and github auth', t => { - // Call the verify module with options and env - t.notThrows(() => verify({githubToken: 'sup'}, {NPM_OLD_TOKEN: 'yo', NPM_EMAIL: 'test@email.com'})); -}); - -test('Return error for missing github token', t => { - // Call the verify module with options and env - const error = t.throws(() => verify({}, {NPM_TOKEN: 'yo'})); - // Verify error code and type - t.is(error.code, 'ENOGHTOKEN'); - t.true(error instanceof SemanticReleaseError); -}); - -test('Return error for missing npm token', t => { - // Call the verify module with options and env - const error = t.throws(() => verify({githubToken: 'sup'}, {})); - // Verify error code and type - t.is(error.code, 'ENONPMTOKEN'); - t.true(error instanceof SemanticReleaseError); -}); - -test('Return error for missing old npm token', t => { - // Call the verify module with options and env - const error = t.throws(() => verify({githubToken: 'sup'}, {NPM_EMAIL: 'test@email.com'})); - // Verify error code and type - t.is(error.code, 'ENONPMTOKEN'); - t.true(error instanceof SemanticReleaseError); -}); - -test('Return error for missing npm email', t => { - // Call the verify module with options and env - const error = t.throws(() => verify({githubToken: 'sup'}, {NPM_OLD_TOKEN: 'yo'})); - // Verify error code and type - t.is(error.code, 'ENONPMTOKEN'); - t.true(error instanceof SemanticReleaseError); -}); diff --git a/test/verify-pkg.test.js b/test/verify-pkg.test.js deleted file mode 100644 index 1c58fa8d..00000000 --- a/test/verify-pkg.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import test from 'ava'; -import SemanticReleaseError from '@semantic-release/error'; -import verify from '../lib/verify-pkg'; - -test('Verify name and repository', t => { - // Call the verify module with package - t.notThrows(() => verify({name: 'package', repository: {url: 'http://github.com/whats/up.git'}})); -}); - -test('Return error for missing package name', t => { - // Call the verify module with package - const error = t.throws(() => verify({repository: {url: 'http://github.com/whats/up.git'}})); - // Verify error code and type - t.is(error.code, 'ENOPKGNAME'); - t.true(error instanceof SemanticReleaseError); -}); - -test('Return error for missing repository', t => { - // Call the verify module with package - const error = t.throws(() => verify({name: 'package'})); - // Verify error code and type - t.is(error.code, 'ENOPKGREPO'); - t.true(error instanceof SemanticReleaseError); -}); - -test('Return error for missing repository url', t => { - // Call the verify module with package - const error = t.throws(() => verify({name: 'package', repository: {}})); - // Verify error code and type - t.is(error.code, 'ENOPKGREPO'); - t.true(error instanceof SemanticReleaseError); -});