diff --git a/.travis.yml b/.travis.yml index e08cb38b..983141cf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,4 +25,5 @@ script: after_success: - npm run codecov - - npm run semantic-release + - npm install -g semantic-release/travis-deploy-once@4 + - travis-deploy-once "npm run semantic-release" diff --git a/README.md b/README.md index 47dc9633..072d60f3 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ semantic-release These options are currently available: - `branch`: The branch on which releases should happen. Default: `'master'` - `repositoryUrl`: The git repository URL. Default: `repository` property in `package.json` or git origin url. Any valid git url format is supported (See [Git protocols](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols)). If the [Github plugin](https://github.com/semantic-release/github) is used the URL must be a valid Github URL that include the `owner`, the `repository` name and the `host`. The Github shorthand URL is not supported. -- `dry-run`: Dry-run mode, skipping verifyConditions, publishing and release, printing next version and release notes +- `dry-run`: Dry-run mode, skip publishing, print next version and release notes - `extends`: Array of module or files path containing a shareable configuration. Options defined via CLI or in the `release` property will take precedence over the one defined in a shareable configuration. - `debug`: Output debugging information diff --git a/cli.js b/cli.js index cf431b8a..12d97430 100755 --- a/cli.js +++ b/cli.js @@ -48,12 +48,10 @@ module.exports = async () => { await require('.')(pickBy(program.opts(), value => !isUndefined(value))); } } 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 - // Otherwise it's an unexpected error (configuration issue, code issue, plugin issue etc...) and the cli will return 1 + process.exitCode = 1; if (err.semanticRelease) { logger.log(`%s ${err.message}`, err.code); } else { - process.exitCode = 1; logger.error('An error occurred while running semantic-release: %O', err); } } diff --git a/index.js b/index.js index f3057034..964eba1f 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ const marked = require('marked'); const TerminalRenderer = require('marked-terminal'); -const SemanticReleaseError = require('@semantic-release/error'); +const envCi = require('env-ci'); const getConfig = require('./lib/get-config'); const getNextVersion = require('./lib/get-next-version'); const getCommits = require('./lib/get-commits'); @@ -8,19 +8,39 @@ const logger = require('./lib/logger'); const {gitHead: getGitHead, isGitRepo} = require('./lib/git'); module.exports = async opts => { + const {isCi, branch, isPr} = envCi(); + + if (!isCi && !opts.dryRun) { + logger.log('This run was not triggered in a known CI environment, running in dry-run mode.'); + opts.dryRun = true; + } + + if (isCi && isPr) { + logger.log('This run was triggered by a pull request and therefore a new version won’t be published.'); + return; + } + if (!await isGitRepo()) { - throw new SemanticReleaseError('Semantic-release must run from a git repository', 'ENOGITREPO'); + logger.error('Semantic-release must run from a git repository.'); + return; } const config = await getConfig(opts, logger); const {plugins, options} = config; + if (branch !== options.branch) { + logger.log( + `This test run was triggered on the branch ${branch}, while semantic-release is configured to only publish from ${ + options.branch + }, therefore a new version won’t be published.` + ); + return; + } + logger.log('Run automated release from branch %s', options.branch); - if (!options.dryRun) { - logger.log('Call plugin %s', 'verify-conditions'); - await plugins.verifyConditions({options, logger}); - } + logger.log('Call plugin %s', 'verify-conditions'); + await plugins.verifyConditions({options, logger}); logger.log('Call plugin %s', 'get-last-release'); const {commits, lastRelease} = await getCommits( @@ -32,7 +52,8 @@ module.exports = async opts => { logger.log('Call plugin %s', 'analyze-commits'); const type = await plugins.analyzeCommits({options, logger, lastRelease, commits}); if (!type) { - throw new SemanticReleaseError('There are no relevant changes, so no new version is released.', 'ENOCHANGE'); + logger.log('There are no relevant changes, so no new version is released.'); + return; } const version = getNextVersion(type, lastRelease, logger); const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: `v${version}`}; @@ -67,4 +88,5 @@ module.exports = async opts => { }); logger.log('Published release: %s', nextRelease.version); } + return true; }; diff --git a/lib/plugins/definitions.js b/lib/plugins/definitions.js index 815a8b50..6348bfdf 100644 --- a/lib/plugins/definitions.js +++ b/lib/plugins/definitions.js @@ -6,7 +6,7 @@ const validatePluginConfig = conf => isString(conf) || isString(conf.path) || is module.exports = { verifyConditions: { - default: ['@semantic-release/npm', '@semantic-release/github', '@semantic-release/condition-travis'], + default: ['@semantic-release/npm', '@semantic-release/github'], config: { validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), message: diff --git a/package.json b/package.json index 88eb0fb8..9bd6c591 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ }, "dependencies": { "@semantic-release/commit-analyzer": "^5.0.0", - "@semantic-release/condition-travis": "^7.0.0", "@semantic-release/error": "^2.1.0", "@semantic-release/github": "^3.0.0", "@semantic-release/npm": "^2.0.0", @@ -25,6 +24,7 @@ "commander": "^2.11.0", "cosmiconfig": "^3.1.0", "debug": "^3.1.0", + "env-ci": "^1.0.0", "execa": "^0.8.0", "get-stream": "^3.0.0", "git-log-parser": "^1.2.0", diff --git a/test/index.test.js b/test/index.test.js index ac769c42..c256696c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -19,7 +19,6 @@ test.beforeEach(t => { 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}); }); test.afterEach.always(() => { @@ -61,7 +60,11 @@ test.serial('Plugins are called with expected values', async t => { publish, }; - await t.context.semanticRelease(options); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + t.truthy(await semanticRelease(options)); t.is(verifyConditions1.callCount, 1); t.deepEqual(verifyConditions1.args[0][0], config); @@ -140,7 +143,11 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre publish: [publish1, publish2], }; - await t.context.semanticRelease(options); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + t.truthy(await semanticRelease(options)); t.is(generateNotes.callCount, 2); t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease); @@ -154,7 +161,7 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre t.deepEqual(publish2.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes})); }); -test.serial('Dry-run skips verifyConditions and publish', async t => { +test.serial('Dry-run skips 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 @@ -187,9 +194,62 @@ test.serial('Dry-run skips verifyConditions and publish', async t => { publish, }; - await t.context.semanticRelease(options); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + t.truthy(await semanticRelease(options)); - t.is(verifyConditions.callCount, 0); + t.not(t.context.log.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.'); + t.is(verifyConditions.callCount, 1); + t.is(getLastRelease.callCount, 1); + t.is(analyzeCommits.callCount, 1); + t.is(verifyRelease.callCount, 1); + t.is(generateNotes.callCount, 1); + t.is(publish.callCount, 0); +}); + +test.serial('Force a dry-run if not on a CI', 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 verifyConditions = stub().resolves(); + const getLastRelease = stub().resolves(lastRelease); + const analyzeCommits = stub().resolves(nextRelease.type); + const verifyRelease = stub().resolves(); + const generateNotes = stub().resolves(notes); + const publish = stub().resolves(); + + const options = { + dryRun: false, + branch: 'master', + repositoryUrl: 'git@hostname.com:owner/module.git', + verifyConditions, + getLastRelease, + analyzeCommits, + verifyRelease, + generateNotes, + publish, + }; + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: false, branch: 'master'}), + }); + t.truthy(await semanticRelease(options)); + + t.is(t.context.log.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.'); + t.is(verifyConditions.callCount, 1); t.is(getLastRelease.callCount, 1); t.is(analyzeCommits.callCount, 1); t.is(verifyRelease.callCount, 1); @@ -227,7 +287,11 @@ test.serial('Accept "undefined" values for the "getLastRelease" and "generateNot publish, }; - await t.context.semanticRelease(options); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + t.truthy(await semanticRelease(options)); t.is(getLastRelease.callCount, 1); @@ -245,29 +309,121 @@ test.serial('Accept "undefined" values for the "getLastRelease" and "generateNot t.falsy(publish.args[0][1].nextRelease.notes); }); -test.serial('Throw SemanticReleaseError if not running from a git repository', async t => { +test.serial('Returns falsy value if not running from a git repository', async t => { // Set the current working directory to a temp directory process.chdir(tempy.directory()); - const error = await t.throws(t.context.semanticRelease()); - - // Verify error code and type - t.is(error.code, 'ENOGITREPO'); - t.is(error.name, 'SemanticReleaseError'); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + t.falsy(await semanticRelease()); + t.is(t.context.error.args[0][0], 'Semantic-release must run from a git repository.'); }); -test.serial('Throw SemanticReleaseError if repositoryUrl is not set and cannot be found', async t => { +test.serial('Returns falsy value if triggered by a PR', async t => { // Create a git repository, set the current working directory at the root of the repo await gitRepo(); - const error = await t.throws(t.context.semanticRelease()); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: true}), + }); + + t.falsy(await semanticRelease({repositoryUrl: 'git@hostname.com:owner/module.git'})); + t.is( + t.context.log.args[0][0], + 'This run was triggered by a pull request and therefore a new version won’t be published.' + ); +}); + +test.serial('Returns falsy value if not running from the configured branch', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + + const verifyConditions = stub().resolves(); + const getLastRelease = stub().resolves(); + const analyzeCommits = stub().resolves(); + const verifyRelease = stub().resolves(); + const generateNotes = stub().resolves(); + const publish = stub().resolves(); + + const options = { + branch: 'master', + repositoryUrl: 'git@hostname.com:owner/module.git', + verifyConditions: [verifyConditions], + getLastRelease, + analyzeCommits, + verifyRelease, + generateNotes, + publish, + }; + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'other-branch', isPr: false}), + }); + + t.falsy(await semanticRelease(options)); + t.is( + t.context.log.args[0][0], + 'This test run was triggered on the branch other-branch, while semantic-release is configured to only publish from master, therefore a new version won’t be published.' + ); +}); + +test.serial('Returns falsy value if there is no relevant changes', 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']); + + const verifyConditions = stub().resolves(); + const getLastRelease = stub().resolves(); + const analyzeCommits = stub().resolves(); + const verifyRelease = stub().resolves(); + const generateNotes = stub().resolves(); + const publish = stub().resolves(); + + const options = { + branch: 'master', + repositoryUrl: 'git@hostname.com:owner/module.git', + verifyConditions: [verifyConditions], + getLastRelease, + analyzeCommits, + verifyRelease, + generateNotes, + publish, + }; + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + + t.falsy(await semanticRelease(options)); + t.is(analyzeCommits.callCount, 1); + t.is(verifyRelease.callCount, 0); + t.is(generateNotes.callCount, 0); + t.is(publish.callCount, 0); + t.is(t.context.log.args[6][0], 'There are no relevant changes, so no new version is released.'); +}); + +test.serial('Throw SemanticReleaseError if repositoryUrl is not set and cannot be found from repo config', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + const error = await t.throws(semanticRelease()); // Verify error code and type t.is(error.code, 'ENOREPOURL'); t.is(error.name, 'SemanticReleaseError'); }); -test.serial('Throw an Error if returns an unexpected value', async t => { +test.serial('Throw an Error if plugin returns an unexpected value', 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 @@ -287,7 +443,11 @@ test.serial('Throw an Error if returns an unexpected value', async t => { getLastRelease, }; - const error = await t.throws(t.context.semanticRelease(options), Error); + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + const error = await t.throws(semanticRelease(options), Error); // Verify error message t.regex(error.message, new RegExp(DEFINITIONS.getLastRelease.output.message)); diff --git a/test/integration.test.js b/test/integration.test.js index 0d9aed06..04cd3b7a 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -53,6 +53,12 @@ test.beforeEach(() => { delete process.env.GITHUB_URL; delete process.env.GH_PREFIX; delete process.env.GITHUB_PREFIX; + + process.env.TRAVIS = 'true'; + process.env.CI = 'true'; + process.env.TRAVIS_BRANCH = 'master'; + process.env.TRAVIS_PULL_REQUEST = 'false'; + // Delete all `npm_config` environment variable set by CI as they take precedence over the `.npmrc` because the process that runs the tests is started before the `.npmrc` is created for (let i = 0, keys = Object.keys(process.env); i < keys.length; i++) { if (keys[i].startsWith('npm_config')) { @@ -86,7 +92,6 @@ test.serial('Release patch, minor and major versions', async t => { name: packageName, version: '0.0.0-dev', repository: {url: `git+https://github.com/${owner}/${packageName}`}, - release: {verifyConditions: ['@semantic-release/github', '@semantic-release/npm']}, publishConfig: {registry: npmRegistry.url}, }); // Create a npm-shrinkwrap.json file @@ -299,7 +304,6 @@ test.serial('Release versions from a packed git repository, using tags to determ name: packageName, version: '0.0.0-dev', repository: {url: `git@github.com:${owner}/${packageName}.git`}, - release: {verifyConditions: ['@semantic-release/github', '@semantic-release/npm']}, publishConfig: {registry: npmRegistry.url}, }); @@ -453,7 +457,6 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy name: packageName, version: '0.0.0-dev', repository: {url: `git+https://github.com/${owner}/${packageName}`}, - release: {verifyConditions: ['@semantic-release/github', '@semantic-release/npm']}, publishConfig: {registry: npmRegistry.url}, }); @@ -520,7 +523,7 @@ test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', asy ({stderr, stdout, code} = await execa(cli, [], {env, reject: false})); t.log('Log "ENOTINHISTORY" message'); - t.is(code, 0); + t.is(code, 1); t.regex( stderr, new RegExp( @@ -585,6 +588,11 @@ test.serial('Dry-run', async t => { }); /* Initial release */ + const verifyMock = await mockServer.mock( + `/repos/${owner}/${packageName}`, + {headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]}, + {body: {permissions: {push: true}}, method: 'GET'} + ); const version = '1.0.0'; t.log('Commit a feature'); await gitCommits(['feat: Initial commit']); @@ -597,6 +605,7 @@ test.serial('Dry-run', async t => { // Verify package.json and has not been modified t.is((await readJson('./package.json')).version, '0.0.0-dev'); + await mockServer.verify(verifyMock); }); test.serial('Pass options via CLI arguments', async t => { @@ -681,11 +690,7 @@ 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({ - verifyConditions: [{path: '@semantic-release/github'}, '@semantic-release/npm'], - publish: [{path: '@semantic-release/github'}, '@semantic-release/npm'], - debug: true, - }); + await semanticRelease(); // Verify package.json and has been updated t.is((await readJson('./package.json')).version, version); @@ -731,7 +736,7 @@ test.serial('Log unexpected errors from plugins and exit with 1', async t => { t.is(code, 1); }); -test.serial('Log errors inheriting SemanticReleaseError and exit with 0', async t => { +test.serial('Log errors inheriting SemanticReleaseError and exit with 1', async t => { const packageName = 'test-inherited-error'; const owner = 'test-repo'; // Create a git repository, set the current working directory at the root of the repo @@ -752,7 +757,7 @@ test.serial('Log errors inheriting SemanticReleaseError and exit with 0', async const {stdout, code} = await execa(cli, [], {env, reject: false}); // Verify the type and message are logged t.regex(stdout, /EINHERITED Inherited error/); - t.is(code, 0); + t.is(code, 1); }); test.serial('CLI returns error code and prints help if called with a command', async t => {