ember-cli-release
Version:
Ember CLI addon for managing release versions.
546 lines (465 loc) • 16.6 kB
JavaScript
/* jshint node:true */
var GitRepo = require('../utils/git');
var NPM = require('../utils/npm');
var tagPrefix = require('../utils/tag-prefix');
var findBy = require('../utils/find-by');
var withArgs = require('../utils/with-args');
var slice = require('../utils/slice');
var fs = require('fs');
var path = require('path');
var chalk = require('chalk');
var merge = require('merge');
var makeArray = require('make-array');
var nopt = require('nopt');
var requireDir = require('require-dir');
var RSVP = require('rsvp');
var SilentError = require('silent-error');
var Promise = RSVP.Promise;
var resolve = RSVP.resolve;
var resolveAll = RSVP.all;
var resolveHash = RSVP.hash;
var denodeify = RSVP.denodeify;
var readFile = denodeify(fs.readFile);
var writeFile = denodeify(fs.writeFile);
var readJSON = function(filePath, options) {
return readFile(filePath, options).then(function(data) {
return JSON.parse(data);
});
};
var writeJSON = function(filePath, object, options) {
var contents = JSON.stringify(object, null, 2) + '\n';
return writeFile(filePath, contents, options);
};
var configPath = 'config/release.js';
var availableHooks = [
'init',
'beforeCommit',
'afterPush',
'afterPublish',
];
var availableOptions = [
{
name: 'local',
type: Boolean,
aliases: [ 'l' ],
default: false,
description: "whether release commit and tags are locally or not (not pushed to a remote)",
validInConfig: true,
},
{
name: 'remote',
type: String,
aliases: [ 'r' ],
default: 'origin',
description: "the git origin to push tags to, ignored if the '--local' option is true",
validInConfig: true,
},
{
name: 'tag',
type: String,
aliases: [ 't' ],
description: "the name of the git tag to create",
},
{
name: 'annotation',
type: String,
aliases: [ 'a' ],
description: "a message passed to the `--message` option of `git tag`, indicating that to the tag should be created with the `--annotated` option (default is lightweight), the string '%@' is replaced with the tag name",
validInConfig: true,
},
{
name: 'message',
type: String,
aliases: [ 'm' ],
default: 'Released %@',
description: "a message passed to the `--message` option of `git commit`, the string '%@' is replaced with the tag name",
validInConfig: true,
},
{
name: 'manifest',
type: Array,
default: [ 'package.json', 'bower.json' ],
description: "a set of JSON files to replace the top-level `version` property in with the new tag name",
validInConfig: true,
},
{
name: 'publish',
type: Boolean,
aliases: [ 'p' ],
default: false,
description: "whether to publish package to NPM after tagging or not",
validInConfig: true,
},
{
name: 'yes',
type: Boolean,
aliases: [ 'y' ],
default: false,
description: "whether to skip confirmation prompts or not (answer 'yes' to all questions)",
},
{
name: 'strategy',
type: String,
aliases: [ 's' ],
default: 'semver',
description: "strategy for auto-generating the tag name, either 'semver' or 'date', ignored if the 'name' option is specified",
validInConfig: true,
},
];
function hasModifications(status) {
var modifications = status.split('\n').filter(function(str) {
return !!str && str.indexOf('??') !== 0;
});
return !!modifications.length;
}
module.exports = {
name: 'release',
description: 'Create a new git tag at HEAD',
works: 'insideProject',
run: function(options) {
var command = this;
var ui = this.ui;
var project = this.project;
var repo = this.git();
var strategies = this.strategies();
var hooks = this.config().hooks;
var pushQueue = [];
function proceedPrompt(message) {
if (options.yes) {
return resolve();
}
return ui.prompt({
type: 'confirm',
name: 'proceed',
message: chalk.yellow(message + ", proceed?"),
choices: [
{ key: 'y', value: true },
{ key: 'n', value: false }
]
}).then(function(response) {
if (!response.proceed) {
throw new SilentError("Aborted.");
}
});
}
function getTaggingStrategy() {
var strategyName = options.strategy;
if (typeof strategyName === 'object') {
return strategyName;
}
if (!(strategyName in strategies)) {
throw new SilentError("Unknown tagging strategy: '" + strategyName + "'");
}
return strategies[strategyName];
}
function abortIfAtTag() {
return repo.currentTag().then(function(currentTag) {
if (currentTag) {
throw new SilentError("Skipped tagging, HEAD already at tag: " + currentTag);
}
});
}
function abortIfCannotPublish() {
if (!options.publish) { return; }
if (options.local) {
throw new SilentError("The --publish and --local options are incompatible.");
}
if (options.strategy !== 'semver') {
throw new SilentError("Publishing to NPM requires SemVer.");
}
var npm = command.npm();
// Second argument prevents the function from printing the username
return npm.whoami([], true).then(function(username) {
var registry = npm.config.get('registry');
ui.writeLine(chalk.green("Using NPM registry " + registry + " as user '" + username + "'"));
}).catch(function(error) {
// Fail gracefully if the user is not logged in
if (error && error.code === 'ENEEDAUTH') {
throw new SilentError("Must be logged in to perform NPM publish.");
}
// Fail hard on an unexpected error
throw error;
});
}
function promptIfWorkingTreeDirty() {
return repo.status().then(function(status) {
if (hasModifications(status)) {
return proceedPrompt("Your working tree contains modifications that will be added to the release commit");
}
});
}
function getTags() {
if (options.tag) {
// Use tag name if specified
return {
next: options.tag
};
} else {
// Otherwise fetch all tags to pass to the tagging strategy
return repo.tags().then(function(tags) {
var strategy = getTaggingStrategy();
var tagNames = tags.map(function(tag) {
return tag.name;
});
return resolveHash({
latest: strategy.getLatestTag ? strategy.getLatestTag(project, tagNames, options): null,
next: strategy.getNextTag(project, tagNames, options),
});
}).then(function(tags) {
if (!tags || typeof tags.next !== 'string') {
throw new SilentError("Tagging strategy must return a non-empty tag name");
}
return tags;
});
}
}
function printLatestTag(latestTag) {
if (latestTag) {
ui.writeLine(chalk.green('Latest tag: ' + latestTag));
}
}
function replaceVersionsInManifests(nextVersion) {
return resolveAll(options.manifest.map(function(fileName) {
var filePath = path.join(project.root, fileName);
return readJSON(filePath, 'utf8').then(function(pkg) {
// Skip replace if 'version' key does not exist
if (pkg.version) {
pkg.version = nextVersion;
return writeJSON(filePath, pkg, 'utf8');
}
}, function(error) {
// Ignore if the file doesn't exist
if (error && error.code === 'ENOENT') { return; }
throw error;
});
}));
}
function createCommit(nextTag) {
return repo.status().then(function(status) {
// Don't bother committing if for some reason the working tree is clean
if (hasModifications(status)) {
return repo.currentBranch().then(function(branchName) {
if (!branchName) {
throw new SilentError("Must have a branch checked out to commit to");
}
// Allow the name to be in the message
var message = options.message.replace(/%@/g, nextTag);
return repo.commitAll(message).then(function() {
pushQueue.push(branchName);
}).then(function() {
ui.writeLine(chalk.green("Successfully committed changes '" + message + "' locally."));
});
});
}
});
}
function promptToCreateTag(nextTag) {
return proceedPrompt("About to create tag '" + nextTag + "'" + (options.local ? "" : " and push to remote '" + options.remote + "'"));
}
function createTag(tagName) {
var message = null;
if (options.annotation) {
// Allow the tag name to be in the message
message = options.annotation.replace(/%@/g, tagName);
}
return repo.createTag(tagName, message).then(function() {
pushQueue.push(tagName);
}).then(function() {
ui.writeLine(chalk.green("Successfully created git tag '" + tagName + "' locally."));
});
}
function pushChanges() {
if (options.local || !pushQueue.length) { return; }
return resolveAll(pushQueue.map(function(treeish) {
return repo.push(options.remote, treeish).then(function() {
ui.writeLine(chalk.green("Successfully pushed '" + treeish + "' to remote '" + options.remote + "'."));
});
}));
}
function performPublish() {
if (!options.publish) { return; }
var npm = command.npm();
var filePath = path.join(project.root, 'package.json');
return readJSON(filePath, 'utf8').then(function(pkg) {
return proceedPrompt("About to publish " + pkg.name + "@" + pkg.version);
}).then(function() {
ui.writeLine(chalk.green("Publishing..."));
return npm.publish([ project.root ]);
}).then(function() {
ui.writeLine(chalk.green("Publish successful."));
});
}
function executeHook(hookName /* ...args */) {
var args = slice(arguments, 1);
var hook = hooks[hookName];
// The first arg to all hooks is the project
args.unshift(project);
if (hook) {
return new Promise(function(resolve, reject) {
// Handle errors thrown directly from hook
try {
resolve(hook.apply(hooks, args));
} catch(error) {
reject(error);
}
}).catch(function(error) {
// Preserve stack traces in hook errors if a real error is thrown,
// otherwise wrap friendly errors in `SilentError`
if (!(error instanceof Error)) {
error = new SilentError(error);
}
// Provide more context in the error message
error.message = "Error encountered in `" + hookName + '` hook: "' + error.message + '"';
throw error;
});
} else {
return resolve();
}
}
return resolve()
.then(abortIfAtTag)
.then(abortIfCannotPublish)
.then(getTags)
.then(function(tags) {
return executeHook('init', tags)
.then(promptIfWorkingTreeDirty)
.then(withArgs(printLatestTag, tags.latest))
.then(withArgs(replaceVersionsInManifests, tagPrefix.strip(tags.next)))
.then(withArgs(executeHook, 'beforeCommit', tags))
.then(withArgs(createCommit, tags.next))
.then(withArgs(promptToCreateTag, tags.next))
.then(withArgs(createTag, tags.next))
.then(pushChanges)
.then(withArgs(executeHook, 'afterPush', tags))
.then(performPublish)
.then(function() {
if (options.publish) {
return executeHook('afterPublish', tags);
}
});
});
},
// Merge options specified on the command line with those defined in the config
init: function() {
this._super.init && this._super.init.apply(this, arguments);
var baseOptions = this.baseOptions();
var optionsFromConfig = this.config().options;
var mergedOptions = baseOptions.map(function(availableOption) {
var option = merge(true, availableOption);
if ((optionsFromConfig[option.name] !== undefined) && (option.default !== undefined)) {
option.default = optionsFromConfig[option.name];
option.description = option.description + ' (configured in ' + configPath + ')';
}
return option;
});
// Merge custom strategy options if specified
var strategy = optionsFromConfig.strategy;
if (typeof strategy === 'object' && Array.isArray(strategy.availableOptions)) {
mergedOptions = mergedOptions.concat(strategy.availableOptions);
}
this.registerOptions({
availableOptions: mergedOptions
});
},
// Combine base options with strategy specific options
baseOptions: function() {
if (this._baseOptions) {
return this._baseOptions;
}
var strategies = this.strategies();
var strategyOptions = Object.keys(strategies).reduce(function(result, strategyName) {
var options = strategies[strategyName].availableOptions;
if (Array.isArray(options)) {
// Add strategy qualifier to option descriptions
options.forEach(function(option) {
option.description = "when strategy is '" + strategyName + "', " + option.description;
});
result = result.concat(options);
}
return result;
}, []);
return this._baseOptions = availableOptions.concat(strategyOptions);
},
strategies: function() {
if (this._strategies) {
return this._strategies;
}
return this._strategies = requireDir('../strategies');
},
git: function() {
if (this._repo) {
return this._repo;
}
return this._repo = new GitRepo(this.project.root);
},
npm: function() {
if (this._npm) {
return this._npm;
}
return this._npm = new NPM({});
},
config: function() {
if (!this._parsedConfig) {
var ui = this.ui;
var fullConfigPath = path.join(this.project.root, configPath);
var config = {};
var strategy;
if (fs.existsSync(fullConfigPath)) {
config = require(fullConfigPath);
}
// Preserve strategy if it's a function
if (typeof config.strategy === 'function') {
strategy = {
getNextTag: config.strategy
};
} else if (typeof config.strategy === 'object') {
if (typeof config.strategy.getNextTag === 'function') {
strategy = config.strategy;
} else {
ui.writeLine(chalk.yellow("Warning: a custom `strategy` object must define a `getNextTag` function, ignoring"));
}
}
// Extract hooks
var hooks = availableHooks.reduce(function(result, hookName){
if (typeof config[hookName] === 'function') {
result[hookName] = config[hookName];
delete config[hookName];
} else if (config[hookName] !== undefined) {
ui.writeLine(chalk.yellow("Warning: `" + hookName + "` is not a function in " + configPath + ", ignoring"));
}
return result;
}, {});
var baseOptions = this.baseOptions();
var configOptions = baseOptions.filter(function(option) {
return option.validInConfig;
});
var optionTypeMap = configOptions.reduce(function(result, option) {
result[option.name] = option.type;
return result;
}, {});
// Extract whitelisted options
var options = Object.keys(config).reduce(function(result, optionName) {
if (findBy(configOptions, 'name', optionName)) {
result[optionName] = optionTypeMap[optionName] === Array ? makeArray(config[optionName]) : config[optionName];
} else if (findBy(baseOptions, 'name', optionName)) {
ui.writeLine(chalk.yellow("Warning: cannot specify option `" + optionName + "` in " + configPath + ", ignoring"));
} else {
ui.writeLine(chalk.yellow("Warning: invalid option `" + optionName + "` in " + configPath + ", ignoring"));
}
return result;
}, {});
// Coerce options into their expected type; this is not done for us since
// the options are not coming from the CLI arg string
nopt.clean(options, optionTypeMap);
// If the strategy was a function, it got stomped on
if (strategy) {
options.strategy = strategy;
}
this._parsedConfig = {
options: options,
hooks: hooks
};
}
return this._parsedConfig;
}
};