sphere
Version:
Collection of Grunt tasks to build and release projects built on top of Echo SDK
320 lines (301 loc) • 8.65 kB
JavaScript
module.exports = function(grunt) {
"use strict";
var FtpUploader = require("ftp-uploader");
var _ = require("lodash");
var Q = require("q");
var shell = require("shelljs");
var release = {};
grunt.registerMultiTask("release", "Release", function() {
// don't let release everything at once
_.each(grunt.cli.tasks, function(name) {
if (name === "release") {
grunt.fail.fatal(
"Can't release everything at once. " +
"Please choose only one of the following targets:\n\t" +
_(grunt.config("release"))
.keys()
.filter(function(key) { return key !== "options"; })
.value()
.join("\n\t").cyan
);
}
});
// save release target to allow other tasks to use it
// (we must do this operation before reading options because we use it as well)
grunt.config("release.options.target", this.target);
var options = this.options({
"purgeTitle": "manual purge",
"purgePaths": [],
"deployTargets": [],
"beforeDeploy": [],
"afterDeploy": [],
"skipBuild": false,
"skipPurge": false
});
if (!_.contains(["production", "staging"], options.environment)) {
grunt.fail.fatal(
"Release can be performed only in the \"production\" " +
"and \"staging\" environment."
);
}
if (options.configFile && grunt.file.exists(options.configFile)) {
release.config = grunt.file.readJSON(options.configFile);
} else {
release.config = {};
}
if (_.isEmpty(release.config)) {
grunt.fail.fatal("No release configuration so nothing to do.");
}
release.environment = options.environment;
release.debug = options.debug;
// save to config some indicator that release is in progress
// to allow other tasks to know this fact
grunt.config("release.options.inProgress", true);
release.deploy = {
"location": options.location,
"targets": options.deployTargets
};
if (release.environment === "production") {
release.purge = {
"title": options.purgeTitle,
"paths": options.purgePaths
};
}
var tasks = ["release-steps:prepare", "release-steps:check:before"];
if (!options.skipBuild) {
tasks.push("release-steps:build");
}
tasks = tasks.concat(options.beforeDeploy);
_.each(release.deploy.targets, function(v, target) {
tasks.push("release-steps:deploy:" + target);
});
tasks = tasks.concat(options.afterDeploy);
if (!options.skipPurge) {
tasks.push("release-steps:purge");
}
tasks.push("release-steps:check:after");
console.time("whole release".yellow);
grunt.task.run(tasks);
});
grunt.registerTask("release-steps", function(action) {
if (!grunt.config("release.options.inProgress")) {
grunt.fail.fatal(
"Release steps can't be executed separately " +
"but only as a part of whole release process."
);
}
var handler = function() {};
if (action === "prepare") {
handler = prepareRelease;
} else if (action === "check") {
if (this.args[1] === "before") {
handler = preReleaseCheck;
} else if (this.args[1] === "after") {
handler = postReleaseCheck;
}
} else if (action === "build") {
handler = build;
} else if (action === "deploy") {
handler = deploy;
} else if (action === "purge") {
handler = purge;
}
var task = this.args.join(":");
task && console.time(task.yellow);
var _complete = this.async();
var done = function(success) {
task && console.timeEnd(task.yellow);
if (task === "check:after") {
console.timeEnd("whole release".yellow);
}
_complete(success);
};
handler.call(this, done);
});
function build(done) {
grunt.task.run(["default"]);
done();
}
function prepareRelease(done) {
release.deploy.data = _.reduce(
release.deploy.targets,
function(acc, upload, target) {
acc[target] = {
"src": grunt.file.expand({
"cwd": upload.cwd,
"filter": "isFile"
}, upload.src),
"dest": upload.dest,
"cwd": upload.cwd
};
return acc;
},
{}
);
done();
}
function preReleaseCheck(done) {
var silent = shell.config.silent;
shell.config.silent = true;
var changes = shell.exec("git status -s").output.trim();
if (changes) {
grunt.log.writeln("There are some modified and/or unversioned files:\n".yellow + changes);
grunt.fail.fatal("Please stash them before performing release.");
}
if (
release.environment === "production" &&
shell.exec("git rev-parse --abbrev-ref HEAD").output.trim() !== "master"
) {
grunt.fail.fatal("Only master branch can be released to production.");
}
shell.config.silent = silent;
_.each(release.deploy.data, function(upload, target) {
if (!upload.src.length) {
grunt.log.writeln("Nothing to upload for target ".yellow + target);
done(false);
return;
}
});
var loc = release.deploy.location;
if (_.isEmpty(release.config.auth[loc])) {
grunt.fail.fatal("There is no auth info for \"" + loc + "\".");
}
if (release.debug) {
done();
return;
}
var ftp = new FtpUploader({
"complete": done,
"auth": release.config.auth[loc],
"logger": {
"log": function(text) {
grunt.log.ok("[" + loc + "]: " + text);
},
"error": function(text) {
grunt.fail.fatal("[" + loc + "]: " + text);
}
}
});
ftp.ping();
}
function deploy(done) {
/* jshint validthis:true */
var target = this.args.slice(1).join(":");
/* jshint validthis:false */
var upload = release.deploy.data[target];
grunt.log.writeln(
(release.debug ? "[simulation] ".cyan : "") +
"Releasing to " + release.deploy.location.cyan
);
var ftp = new FtpUploader({
"complete": done,
"auth": release.config.auth[release.deploy.location],
"uploads": [upload],
"debug": release.debug,
"logger": {
"log": _.bind(grunt.log.writeln, grunt.log),
"error": _.bind(grunt.log.error, grunt.log)
}
});
ftp.start();
}
function purge(done) {
var config = release.config.purger;
if (!config || !release.purge || !release.purge.paths.length) {
grunt.log.writeln("Nothing to purge");
done();
return;
}
if (release.debug) {
grunt.log.writeln("Paths to purge: " + release.purge.paths);
}
var API = require("limelight-purge-api");
var api = new API({
"user": config.user,
"apiKey": config.apiKey,
"dryRun": release.debug
});
api.createPurge({
"emailType": "detail",
"emailSubject": "[Limelight] Code pushed to CDN " +
"(" + (release.purge.title || "manual purge") + ")",
"emailTo": config.emailTo,
"emailCC": config.emailCC || "",
"entries": release.purge.paths.map(function(path) {
return {
"shortname": config.shortname,
"url": config.url.replace("{path}", path),
"regex": true
};
})
}).then(function(response) {
if (release.debug) {
grunt.log.writeln(
"The following request data is going to be sent:",
response
);
return Q.reject("No actual purge is performed.");
}
if (!response.id) {
grunt.fail.fatal("Purge request failed");
return;
}
return waitForPurge(response, api);
}).fail(function(reason) {
grunt.log.warn(reason);
}).done(done);
}
function postReleaseCheck(done) {
grunt.log.writeln("Not implemented yet");
done();
}
function waitForPurge(response, api, defer) {
var inRecursion = !!defer;
if (!inRecursion) {
defer = Q.defer();
}
Q.delay(5000)
.then(function() {
grunt.log.write("Checking status... ");
return api.getStatusById(response.id, {"includeDetail": true});
})
.then(function(status) {
var estimates = [];
var statuses = [];
status.entryStatuses.forEach(function(entry) {
statuses.push(
entry.url.yellow + ": " +
(entry.result || entry.status).cyan
);
if (!entry.completed) {
estimates.push(Date.parse(entry.estimatedCompletedDate));
}
});
if (estimates.length) {
var ETA = Math.max.apply(this, estimates);
ETA = Math.ceil((ETA - Date.now()) / 1000);
grunt.log.writeln("ETA: " + ETA + " second(s).");
grunt.log.writeln("\t" + statuses.join("\n\t"));
// don't wait if it's more than 5 minutes, but should be faster anyway
if (ETA > 300) {
defer.reject(
"Too long to wait. The detailed status will " +
"be sent to " + response.emailTo +
(response.emailCC ? " and " + response.emailCC : "") +
" anyway."
);
return;
}
waitForPurge(response, api, defer);
} else {
grunt.log.writeln("Completed".green);
grunt.log.writeln("\t" + statuses.join("\n\t"));
defer.resolve();
}
})
.fail(defer.reject);
if (!inRecursion) {
return defer.promise;
}
}
};