refactor: Use ES6, Test with AVA

- Use async/await instead of callbacks
- Use execa to run command line
- Use AVA for tests
- Add several assertions in the unit tests
- Add documentation (comments) in the tests
- Run tests with a real git repo instead of mocking child_process and add test helpers to create repos, commits and checkout
- Simplify test directory structure
- Simplify code readability (mostly with async/await)
- Use eslint for for linting, prettier for formatting
This commit is contained in:
Pierre-Denis Vanduynslager
2017-09-30 13:30:41 -04:00
committed by Pierre Vanduynslager
parent 7fe0890350
commit abf92ad03d
51 changed files with 1362 additions and 1132 deletions
+124 -134
View File
@@ -1,163 +1,153 @@
var fs = require('fs')
var path = require('path')
var url = require('url')
const path = require('path');
const {promisify} = require('util');
const url = require('url');
const {readJson, writeJson} = require('fs-extra');
const {cloneDeep, defaults, mapKeys, camelCase, assign} = require('lodash');
const log = require('npmlog');
const nopt = require('nopt');
const npmconf = require('npmconf');
const normalizeData = require('normalize-package-data');
var _ = require('lodash')
var log = require('npmlog')
var nopt = require('nopt')
var npmconf = require('npmconf')
var normalizeData = require('normalize-package-data')
log.heading = 'semantic-release'
var env = process.env
var pkg = JSON.parse(fs.readFileSync('./package.json'))
var originalPkg = _.cloneDeep(pkg)
normalizeData(pkg)
var knownOptions = {
branch: String,
debug: Boolean,
'github-token': String,
'github-url': String,
'analyze-commits': [path, String],
'generate-notes': [path, String],
'verify-conditions': [path, String],
'verify-release': [path, String]
}
var options = _.defaults(
_.mapKeys(nopt(knownOptions), function (value, key) {
return _.camelCase(key)
}),
pkg.release,
{
branch: 'master',
fallbackTags: {
next: 'latest'
},
debug: !env.CI,
githubToken: env.GH_TOKEN || env.GITHUB_TOKEN,
githubUrl: env.GH_URL
}
)
var plugins = require('../src/lib/plugins')(options)
npmconf.load({}, function (err, conf) {
if (err) {
log.error('init', 'Failed to load npm config.', err)
process.exit(1)
module.exports = async () => {
log.heading = 'semantic-release';
const env = process.env;
const pkg = await readJson('./package.json');
const originalPkg = cloneDeep(pkg);
normalizeData(pkg);
const knownOptions = {
branch: String,
debug: Boolean,
'github-token': String,
'github-url': String,
'analyze-commits': [path, String],
'generate-notes': [path, String],
'verify-conditions': [path, String],
'verify-release': [path, String],
};
const options = defaults(
mapKeys(nopt(knownOptions), (value, key) => {
return camelCase(key);
}),
pkg.release,
{
branch: 'master',
fallbackTags: {next: 'latest'},
debug: !env.CI,
githubToken: env.GH_TOKEN || env.GITHUB_TOKEN,
githubUrl: env.GH_URL,
}
);
const plugins = require('../src/lib/plugins')(options);
let conf;
try {
conf = await promisify(npmconf.load)({});
} catch (err) {
log.error('init', 'Failed to load npm config.', err);
process.exit(1);
}
var npm = {
auth: {
token: env.NPM_TOKEN
},
const npm = {
auth: {token: env.NPM_TOKEN},
cafile: conf.get('cafile'),
loglevel: conf.get('loglevel'),
registry: require('../src/lib/get-registry')(pkg, conf),
tag: (pkg.publishConfig || {}).tag || conf.get('tag') || 'latest'
}
tag: (pkg.publishConfig || {}).tag || conf.get('tag') || 'latest',
};
// normalize trailing slash
npm.registry = url.format(url.parse(npm.registry))
npm.registry = url.format(url.parse(npm.registry));
log.level = npm.loglevel;
log.level = npm.loglevel
const config = {env: env, pkg: pkg, options: options, plugins: plugins, npm: npm};
const hide = {};
if (options.githubToken) hide.githubToken = '***';
var config = {
env: env,
pkg: pkg,
options: options,
plugins: plugins,
npm: npm
}
log.verbose('init', 'options:', assign({}, options, hide));
log.verbose('init', 'Verifying config.');
var hide = {}
if (options.githubToken) hide.githubToken = '***'
log.verbose('init', 'options:', _.assign({}, options, hide))
log.verbose('init', 'Verifying config.')
var errors = require('../src/lib/verify')(config)
errors.forEach(function (err) {
log.error('init', err.message + ' ' + err.code)
})
if (errors.length) process.exit(1)
const errors = require('../src/lib/verify')(config);
errors.forEach(err => {
log.error('init', err.message + ' ' + err.code);
});
if (errors.length) process.exit(1);
if (options.argv.remain[0] === 'pre') {
log.verbose('pre', 'Running pre-script.')
log.verbose('pre', 'Veriying conditions.')
log.verbose('pre', 'Running pre-script.');
log.verbose('pre', 'Veriying conditions.');
try {
await promisify(plugins.verifyConditions)(config);
} catch (err) {
log[options.debug ? 'warn' : 'error']('pre', err.message);
if (!options.debug) process.exit(1);
}
plugins.verifyConditions(config, function (err) {
if (err) {
log[options.debug ? 'warn' : 'error']('pre', err.message)
if (!options.debug) process.exit(1)
}
const nerfDart = require('nerf-dart')(npm.registry);
let wroteNpmRc = false;
var nerfDart = require('nerf-dart')(npm.registry)
var wroteNpmRc = false
if (env.NPM_OLD_TOKEN && 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)
conf.set('_auth', '${NPM_OLD_TOKEN}', 'project'); // eslint-disable-line no-template-curly-in-string
conf.set('email', '${NPM_EMAIL}', 'project'); // eslint-disable-line no-template-curly-in-string
wroteNpmRc = true;
} else if (env.NPM_TOKEN) {
conf.set(nerfDart + ':_authToken', '${NPM_TOKEN}', 'project'); // eslint-disable-line no-template-curly-in-string
wroteNpmRc = true;
}
if (env.NPM_OLD_TOKEN && 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)
conf.set('_auth', '${NPM_OLD_TOKEN}', 'project') // eslint-disable-line no-template-curly-in-string
conf.set('email', '${NPM_EMAIL}', 'project') // eslint-disable-line no-template-curly-in-string
wroteNpmRc = true
} else if (env.NPM_TOKEN) {
conf.set(nerfDart + ':_authToken', '${NPM_TOKEN}', 'project') // eslint-disable-line no-template-curly-in-string
wroteNpmRc = true
}
try {
await promisify(conf.save.bind(conf))('project');
} catch (err) {
return log.error('pre', 'Failed to save npm config.', err);
}
conf.save('project', function (err) {
if (err) return log.error('pre', 'Failed to save npm config.', err)
if (wroteNpmRc) log.verbose('pre', 'Wrote authToken to .npmrc.');
if (wroteNpmRc) log.verbose('pre', 'Wrote authToken to .npmrc.')
let release;
try {
release = await require('../src/pre')(config);
} catch (err) {
log.error('pre', 'Failed to determine new version.');
require('../src/pre')(config, function (err, release) {
if (err) {
log.error('pre', 'Failed to determine new version.')
const args = ['pre', (err.code ? err.code + ' ' : '') + err.message];
if (err.stack) args.push(err.stack);
log.error.apply(log, args);
process.exit(1);
}
var args = ['pre', (err.code ? err.code + ' ' : '') + err.message]
if (err.stack) args.push(err.stack)
log.error.apply(log, args)
process.exit(1)
}
const message = 'Determined version ' + release.version + ' as "' + npm.tag + '".';
var message = 'Determined version ' + release.version + ' as "' + npm.tag + '".'
log.verbose('pre', message);
log.verbose('pre', message)
if (options.debug) {
log.error('pre', message + ' Not publishing in debug mode.', release);
process.exit(1);
}
if (options.debug) {
log.error('pre', message + ' Not publishing in debug mode.', release)
process.exit(1)
}
try {
const shrinkwrap = await readJson('./npm-shrinkwrap.json');
shrinkwrap.version = release.version;
await writeJson('./npm-shrinkwrap.json', shrinkwrap);
log.verbose('pre', 'Wrote version ' + release.version + 'to npm-shrinkwrap.json.');
} catch (e) {
log.silly('pre', "Couldn't find npm-shrinkwrap.json.");
}
try {
var shrinkwrap = JSON.parse(fs.readFileSync('./npm-shrinkwrap.json'))
shrinkwrap.version = release.version
fs.writeFileSync('./npm-shrinkwrap.json', JSON.stringify(shrinkwrap, null, 2))
log.verbose('pre', 'Wrote version ' + release.version + 'to npm-shrinkwrap.json.')
} catch (e) {
log.silly('pre', 'Couldn\'t find npm-shrinkwrap.json.')
}
await writeJson('./package.json', assign(originalPkg, {version: release.version}));
fs.writeFileSync('./package.json', JSON.stringify(_.assign(originalPkg, {
version: release.version
}), null, 2))
log.verbose('pre', 'Wrote version ' + release.version + ' to package.json.')
})
})
})
log.verbose('pre', 'Wrote version ' + release.version + ' to package.json.');
} else if (options.argv.remain[0] === 'post') {
log.verbose('post', 'Running post-script.')
log.verbose('post', 'Running post-script.');
require('../src/post')(config, function (err, published, release) {
if (err) {
log.error('post', 'Failed to publish release notes.', err)
process.exit(1)
}
log.verbose('post', (published ? 'Published' : 'Generated') + ' release notes.', release)
})
let published, release;
try {
({published, release} = await require('../src/post')(config));
log.verbose('post', (published ? 'Published' : 'Generated') + ' release notes.', release);
} catch (err) {
log.error('post', 'Failed to publish release notes.', err);
process.exit(1);
}
} else {
log.error('post', 'Command "' + options.argv.remain[0] + '" not recognized. Use either "pre" or "post"')
log.error('post', 'Command "' + options.argv.remain[0] + '" not recognized. Use either "pre" or "post"');
}
})
};
-79
View File
@@ -1,79 +0,0 @@
var childProcess = require('child_process')
var log = require('npmlog')
var SemanticReleaseError = require('@semantic-release/error')
module.exports = function (config, cb) {
var lastRelease = config.lastRelease
var options = config.options
var branch = options.branch
var from = lastRelease.gitHead
var range = (from ? from + '..' : '') + 'HEAD'
if (!from) return extract()
childProcess.exec('git branch --no-color --contains ' + from, function (err, stdout) {
var inHistory = false
var branches
if (!err && stdout) {
branches = stdout.split('\n')
.map(function (result) {
if (branch === result.replace('*', '').trim()) {
inHistory = true
return null
}
return result.trim()
})
.filter(function (branch) {
return !!branch
})
}
if (!inHistory) {
log.error('commits',
'The commit the last release of this package was derived from is not in the direct history of the "' + branch + '" branch.\n' +
'This means semantic-release can not extract the commits between now and then.\n' +
'This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.\n' +
'You can recover from this error by publishing manually or restoring the commit "' + from + '".' + (branches && branches.length
? '\nHere is a list of branches that still contain the commit in question: \n * ' + branches.join('\n * ')
: ''
))
return cb(new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY'))
}
extract()
})
function extract () {
var child = childProcess.spawn('git', ['log', '-E', '--format=%H==SPLIT==%B==END==', range])
var stdout = ''
var err = ''
child.stdout.on('data', function (data) {
stdout += data
})
child.stderr.on('data', function (data) {
err += data
})
child.on('close', function (code) {
if (err || code) return cb(err)
cb(null, String(stdout).split('==END==\n')
.filter(function (raw) {
return !!raw.trim()
})
.map(function (raw) {
var data = raw.split('==SPLIT==')
return {
hash: data[0],
message: data[1]
}
})
)
})
}
}
+56
View File
@@ -0,0 +1,56 @@
const execa = require('execa');
const log = require('npmlog');
const SemanticReleaseError = require('@semantic-release/error');
module.exports = async ({lastRelease, options}) => {
let stdout;
if (lastRelease.gitHead) {
try {
({stdout} = await execa('git', ['branch', '--no-color', '--contains', lastRelease.gitHead]));
} catch (err) {
throw notInHistoryError(lastRelease.gitHead, options.branch);
}
const branches = stdout
.split('\n')
.map(branch => branch.replace('*', '').trim())
.filter(branch => !!branch);
if (!branches.includes(options.branch)) {
throw notInHistoryError(lastRelease.gitHead, options.branch, branches);
}
}
try {
({stdout} = await execa('git', [
'log',
'--format=%H==SPLIT==%B==END==',
`${lastRelease.gitHead ? lastRelease.gitHead + '..' : ''}HEAD`,
]));
} catch (err) {
return [];
}
return String(stdout)
.split('==END==')
.filter(raw => !!raw.trim())
.map(raw => {
const [hash, message] = raw.trim().split('==SPLIT==');
return {hash, message};
});
};
function notInHistoryError(gitHead, branch, branches) {
log.error(
'commits',
`
The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch.
This means semantic-release can not extract the commits between now and then.
This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.
You can recover from this error by publishing manually or restoring the commit "${gitHead}".
${branches && branches.length
? `\nHere is a list of branches that still contain the commit in question: \n * ${branches.join('\n * ')}`
: ''}
`
);
return new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY');
}
+9 -10
View File
@@ -1,12 +1,11 @@
module.exports = function (pkg, conf) {
if (pkg.publishConfig && pkg.publishConfig.registry) return pkg.publishConfig.registry
module.exports = ({publishConfig, name}, conf) => {
if (publishConfig && publishConfig.registry) {
return publishConfig.registry;
}
if (pkg.name[0] !== '@') return conf.get('registry') || 'https://registry.npmjs.org/'
if (name[0] !== '@') {
return conf.get('registry') || 'https://registry.npmjs.org/';
}
var scope = pkg.name.split('/')[0]
var scopedRegistry = conf.get(scope + '/registry')
if (scopedRegistry) return scopedRegistry
return conf.get('registry') || 'https://registry.npmjs.org/'
}
return conf.get(`${name.split('/')[0]}/registry`) || conf.get('registry') || 'https://registry.npmjs.org/';
};
+14
View File
@@ -0,0 +1,14 @@
const {promisify} = require('util');
const SemanticReleaseError = require('@semantic-release/error');
module.exports = async config => {
const {plugins, lastRelease} = config;
const type = await promisify(plugins.analyzeCommits)(config);
if (!type) {
throw new SemanticReleaseError('There are no relevant changes, so no new version is released.', 'ENOCHANGE');
}
if (!lastRelease.version) return 'initial';
return type;
};
+3 -4
View File
@@ -1,4 +1,3 @@
/* istanbul ignore next */
module.exports = function (config, options, cb) {
cb(null)
}
module.exports = (config, options, cb) => {
cb(null);
};
+32 -32
View File
@@ -1,42 +1,42 @@
var relative = require('require-relative')
var series = require('run-series')
const {promisify} = require('util');
const relative = require('require-relative');
const pSeries = require('p-series');
var exports = module.exports = function (options) {
var plugins = {
analyzeCommits: exports.normalize(options.analyzeCommits, '@semantic-release/commit-analyzer'),
generateNotes: exports.normalize(options.generateNotes, '@semantic-release/release-notes-generator'),
getLastRelease: exports.normalize(options.getLastRelease, '@semantic-release/last-release-npm')
}
;['verifyConditions', 'verifyRelease'].forEach(function (plugin) {
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] = exports.normalize(
plugins[plugin] = normalize(
options[plugin],
plugin === 'verifyConditions'
? '@semantic-release/condition-travis'
: './plugin-noop'
)
return
plugin === 'verifyConditions' ? '@semantic-release/condition-travis' : './plugin-noop'
);
return;
}
plugins[plugin] = function (pluginOptions, cb) {
var tasks = options[plugin].map(function (step) {
return exports.normalize(step, './plugin-noop').bind(null, pluginOptions)
})
plugins[plugin] = async pluginOptions => {
return pSeries(
options[plugin].map(step => {
return () => promisify(normalize(step, './plugin-noop'))(pluginOptions);
})
);
};
});
series(tasks, cb)
}
})
return plugins;
};
return plugins
}
const normalize = (pluginConfig, fallback) => {
if (typeof pluginConfig === 'string') return relative(pluginConfig).bind(null, {});
exports.normalize = function (pluginConfig, fallback) {
if (typeof pluginConfig === 'string') return relative(pluginConfig).bind(null, {})
if (pluginConfig && (typeof pluginConfig.path === 'string')) {
return relative(pluginConfig.path).bind(null, pluginConfig)
if (pluginConfig && typeof pluginConfig.path === 'string') {
return relative(pluginConfig.path).bind(null, pluginConfig);
}
return require(fallback).bind(null, pluginConfig)
}
return require(fallback).bind(null, pluginConfig);
};
module.exports.normalize = normalize;
-21
View File
@@ -1,21 +0,0 @@
var SemanticReleaseError = require('@semantic-release/error')
module.exports = function (config, cb) {
var plugins = config.plugins
var lastRelease = config.lastRelease
plugins.analyzeCommits(config, function (err, type) {
if (err) return cb(err)
if (!type) {
return cb(new SemanticReleaseError(
'There are no relevant changes, so no new version is released.',
'ENOCHANGE'
))
}
if (!lastRelease.version) return cb(null, 'initial')
cb(null, type)
})
}
+14 -29
View File
@@ -1,40 +1,25 @@
var SemanticReleaseError = require('@semantic-release/error')
const SemanticReleaseError = require('@semantic-release/error');
module.exports = function (config) {
var pkg = config.pkg
var options = config.options
var env = config.env
var errors = []
module.exports = ({pkg, options, env}) => {
const errors = [];
if (!pkg.name) {
errors.push(new SemanticReleaseError(
'No "name" found in package.json.',
'ENOPKGNAME'
))
errors.push(new SemanticReleaseError('No "name" found in package.json.', 'ENOPKGNAME'));
}
if (!pkg.repository || !pkg.repository.url) {
errors.push(new SemanticReleaseError(
'No "repository" found in package.json.',
'ENOPKGREPO'
))
errors.push(new SemanticReleaseError('No "repository" found in package.json.', 'ENOPKGREPO'));
}
if (options.debug) return errors
if (!options.debug) {
if (!options.githubToken) {
errors.push(new SemanticReleaseError('No github token specified.', 'ENOGHTOKEN'));
}
if (!options.githubToken) {
errors.push(new SemanticReleaseError(
'No github token specified.',
'ENOGHTOKEN'
))
if (!(env.NPM_TOKEN || (env.NPM_OLD_TOKEN && env.NPM_EMAIL))) {
errors.push(new SemanticReleaseError('No npm token specified.', 'ENONPMTOKEN'));
}
}
if (!(env.NPM_TOKEN || (env.NPM_OLD_TOKEN && env.NPM_EMAIL))) {
errors.push(new SemanticReleaseError(
'No npm token specified.',
'ENONPMTOKEN'
))
}
return errors
}
return errors;
};
+31 -64
View File
@@ -1,71 +1,38 @@
var url = require('url')
const {promisify} = require('util');
const url = require('url');
const gitHead = require('git-head');
const GitHubApi = require('github');
const parseSlug = require('parse-github-repo-url');
var gitHead = require('git-head')
var GitHubApi = require('github')
var parseSlug = require('parse-github-repo-url')
module.exports = async config => {
const {pkg, options: {branch, debug, githubUrl, githubToken, githubApiPathPrefix}, plugins} = config;
const [owner, repo] = parseSlug(pkg.repository.url);
const name = `v${pkg.version}`;
const tag = {owner, repo, ref: `refs/tags/${name}`, sha: await promisify(gitHead)()};
const body = await promisify(plugins.generateNotes)(config);
const release = {owner, repo, tag_name: name, name, target_commitish: branch, draft: !!debug, body};
module.exports = function (config, cb) {
var pkg = config.pkg
var options = config.options
var plugins = config.plugins
var ghConfig = options.githubUrl ? url.parse(options.githubUrl) : {}
if (debug && !githubToken) {
return {published: false, release};
}
var github = new GitHubApi({
port: ghConfig.port,
protocol: (ghConfig.protocol || '').split(':')[0] || null,
host: ghConfig.hostname,
pathPrefix: options.githubApiPathPrefix || null
})
const {port, protocol, hostname} = githubUrl ? url.parse(githubUrl) : {};
const github = new GitHubApi({
port,
protocol: (protocol || '').split(':')[0] || null,
host: hostname,
pathPrefix: githubApiPathPrefix || null,
});
plugins.generateNotes(config, function (err, log) {
if (err) return cb(err)
github.authenticate({type: 'token', token: githubToken});
gitHead(function (err, hash) {
if (err) return cb(err)
if (debug) {
await github.repos.createRelease(release);
return {published: true, release};
}
var ghRepo = parseSlug(pkg.repository.url)
var tag = {
owner: ghRepo[0],
repo: ghRepo[1],
ref: 'refs/tags/v' + pkg.version,
sha: hash
}
var release = {
owner: ghRepo[0],
repo: ghRepo[1],
tag_name: 'v' + pkg.version,
name: 'v' + pkg.version,
target_commitish: options.branch,
draft: !!options.debug,
body: log
}
await github.gitdata.createReference(tag);
await github.repos.createRelease(release);
if (options.debug && !options.githubToken) {
return cb(null, false, release)
}
github.authenticate({
type: 'token',
token: options.githubToken
})
if (options.debug) {
return github.repos.createRelease(release, function (err) {
if (err) return cb(err)
cb(null, true, release)
})
}
github.gitdata.createReference(tag, function (err) {
if (err) return cb(err)
github.repos.createRelease(release, function (err) {
if (err) return cb(err)
cb(null, true, release)
})
})
})
})
}
return {published: true, release};
};
+18 -40
View File
@@ -1,45 +1,23 @@
var _ = require('lodash')
var auto = require('run-auto')
var semver = require('semver')
const {promisify} = require('util');
const {assign} = require('lodash');
const semver = require('semver');
var getCommits = require('./lib/commits')
var getType = require('./lib/type')
const getCommits = require('./lib/get-commits');
const getReleaseType = require('./lib/get-release-type');
module.exports = function (config, cb) {
var plugins = config.plugins
module.exports = async config => {
const {getLastRelease, verifyRelease} = config.plugins;
auto({
lastRelease: plugins.getLastRelease.bind(null, config),
commits: ['lastRelease', function (results, cb) {
getCommits(_.assign({
lastRelease: results.lastRelease
}, config),
cb)
}],
type: ['commits', 'lastRelease', function (results, cb) {
getType(_.assign({
commits: results.commits,
lastRelease: results.lastRelease
}, config),
cb)
}]
}, function (err, results) {
if (err) return cb(err)
const lastRelease = await promisify(getLastRelease)(config);
const commits = await getCommits(assign({lastRelease}, config));
const type = await getReleaseType(assign({commits, lastRelease}, config));
var nextRelease = {
type: results.type,
version: results.type === 'initial'
? '1.0.0'
: semver.inc(results.lastRelease.version, results.type)
}
const nextRelease = {
type: type,
version: type === 'initial' ? '1.0.0' : semver.inc(lastRelease.version, type),
};
plugins.verifyRelease(_.assign({
commits: results.commits,
lastRelease: results.lastRelease,
nextRelease: nextRelease
}, config), function (err) {
if (err) return cb(err)
cb(null, nextRelease)
})
})
}
await promisify(verifyRelease)(assign({commits, lastRelease, nextRelease}, config));
return nextRelease;
};