├── dist └── .gitkeep ├── tests ├── e2e │ ├── folder-to-deploy │ │ ├── index.html │ │ └── exclude-this │ │ │ └── 01.txt │ ├── rollback.js │ └── deploy.js ├── utils.spec.js └── ssh-deploy-release.spec.js ├── .npmignore ├── .gitignore ├── LICENSE-MIT ├── .gitlab-ci.yml ├── src ├── Release.js ├── utils.js ├── Archiver.js ├── logger.js ├── Options.js ├── Remote.js └── ssh-deploy-release.js ├── package.json ├── CHANGELOG.md └── README.md /dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/e2e/folder-to-deploy/index.html: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /tests/e2e/folder-to-deploy/exclude-this/01.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | yarn.lock 5 | release.tar.gz 6 | release.zip 7 | src/ 8 | tests/ 9 | .git* 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | 3 | # MPM 4 | /node_modules 5 | /npm-debug.log 6 | 7 | # Test files 8 | /release.tar.gz 9 | /release.zip 10 | 11 | /tests/e2e/server-config.js 12 | 13 | # Transpiled files 14 | /dist/* 15 | -------------------------------------------------------------------------------- /tests/e2e/rollback.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Deployer = require('../../dist/ssh-deploy-release'); 4 | const server = require('./server-config'); 5 | 6 | 7 | const options = { 8 | localPath: 'tests/e2e/folder-to-deploy', 9 | host: server.host, 10 | username: server.username, 11 | password: server.password, 12 | deployPath: '/var/www/vhosts/test/httpdocs', 13 | // mode:'archive', 14 | // archiveType: 'zip', 15 | debug: true, 16 | 17 | share: { 18 | 'target-folder': 'link-name' 19 | }, 20 | create: [ 21 | 'this-folder', 'and-this' 22 | ], 23 | makeWritable: [ 24 | 'this-file' 25 | ], 26 | onAfterDeployExecute: (context) => { 27 | context.logger.subhead('Remote ls'); 28 | return [ 29 | 'ls -la ' + context.options.deployPath 30 | ] 31 | }, 32 | exclude: ['exclude-this/**'] 33 | }; 34 | 35 | 36 | const deployer = new Deployer(options); 37 | deployer.rollbackToPreviousRelease(); -------------------------------------------------------------------------------- /tests/e2e/deploy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Deployer = require('../../dist/ssh-deploy-release'); 4 | const server = require('./server-config'); 5 | 6 | 7 | const options = { 8 | localPath: 'tests/e2e/folder-to-deploy', 9 | host: server.host, 10 | username: server.username, 11 | password: server.password, 12 | deployPath: '/var/www/vhosts/test/httpdocs', 13 | 14 | debug: true, 15 | 16 | share: { 17 | 'target-folder': 'link-name', 18 | }, 19 | create: [ 20 | 'this-folder', 'and-this', 'this-file' 21 | ], 22 | makeWritable: [ 23 | 'this-file' 24 | ], 25 | onAfterDeployExecute: (context) => { 26 | context.logger.subhead('Remote ls'); 27 | return [ 28 | 'ls -la ' + context.options.deployPath 29 | ] 30 | }, 31 | exclude: ['exclude-this/**'] 32 | }; 33 | 34 | 35 | const deployer = new Deployer(options); 36 | deployer.deployRelease(); 37 | 38 | 39 | // const deployerRemove = new Deployer(options); 40 | // deployerRemove.removeRelease(); 41 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person 2 | obtaining a copy of this software and associated documentation 3 | files (the "Software"), to deal in the Software without 4 | restriction, including without limitation the rights to use, 5 | copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | copies of the Software, and to permit persons to whom the 7 | Software is furnished to do so, subject to the following 8 | conditions: 9 | 10 | The above copyright notice and this permission notice shall be 11 | included in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 20 | OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | default: 2 | tags: 3 | - lhs 4 | 5 | image: node:18 6 | 7 | before_script: 8 | - npm ci 9 | 10 | cache: 11 | paths: 12 | - node_modules/ 13 | 14 | 15 | stages: 16 | - build 17 | - test 18 | - deploy 19 | 20 | 21 | Build: 22 | stage: build 23 | script: 24 | - npm run build 25 | artifacts: 26 | paths: 27 | - dist 28 | expire_in: 1 week 29 | 30 | 31 | Test: 32 | stage: test 33 | script: 34 | - npm run test 35 | 36 | 37 | Publish NPM package: 38 | stage: deploy 39 | only: 40 | - tags 41 | except: 42 | - /^\d+\.\d+\.\d+-.+$/ # Exclude tags having a stability tag (ex: 1.2.3-beta.1) 43 | script: 44 | - npm version --no-git-tag-version --allow-same-version $CI_BUILD_TAG 45 | - echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}'>.npmrc 46 | - npm publish 47 | 48 | 49 | Publish NPM pre-release package): 50 | stage: deploy 51 | only: 52 | - tags 53 | except: 54 | - /^\d+\.\d+\.\d+$/ # Exclude tags NOT having a stability tag (ex: 1.2.3) 55 | script: 56 | - npm version --no-git-tag-version --allow-same-version $CI_BUILD_TAG 57 | - echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}'>.npmrc 58 | - npm publish --tag beta 59 | -------------------------------------------------------------------------------- /src/Release.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | /** 4 | * Release 5 | * @type {{}} 6 | */ 7 | module.exports = class { 8 | 9 | constructor(options, defaultOptions) { 10 | this.options = options; 11 | this.defaultOptions = defaultOptions; 12 | 13 | this.tag = this.getReleaseTag(); 14 | this.path = this.getReleasePath(); 15 | } 16 | 17 | /** 18 | * Get release tag 19 | * @returns {*|string} 20 | */ 21 | getReleaseTag() { 22 | let releaseTag = this.options.tag; 23 | 24 | // Execute function if needed 25 | if (typeof this.options.tag == 'function') { 26 | releaseTag = this.options.tag() 27 | } 28 | 29 | // Just a security check, avoiding empty tags that could mess up the file system 30 | if (releaseTag == '') { 31 | releaseTag = this.defaultOptions.tag; 32 | } 33 | return releaseTag; 34 | } 35 | 36 | 37 | /** 38 | * Get releases path 39 | * @returns {string} 40 | */ 41 | getReleasePath() { 42 | return path.posix.join( 43 | this.options.deployPath, 44 | this.options.releasesFolder, 45 | this.tag 46 | ); 47 | } 48 | 49 | 50 | }; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | /** 4 | * Return upwardPath from downwardPath 5 | * @example return "../../.." for "path/to/something" 6 | * @param downwardPath 7 | * @returns {XML|string|void|*} 8 | */ 9 | getReversePath(downwardPath) { 10 | return downwardPath.replace(/([^\/]+)/g, '..'); 11 | }, 12 | 13 | 14 | /** 15 | * Realpath 16 | * @param path 17 | * @returns {string} 18 | */ 19 | /** 20 | * Realpath 21 | * @param path 22 | * @returns {string} 23 | */ 24 | realpath(path) { 25 | // Explode the given path into it's parts 26 | var arr = path.split('/'); // The path is an array now 27 | 28 | path = []; // Foreach part make a check 29 | for (var k in arr) { 30 | // This is'nt really interesting 31 | if (arr[k] === '.') { 32 | continue; 33 | } 34 | // This reduces the realpath 35 | if (arr[k] === '..') { 36 | path.pop(); 37 | } else { 38 | // This adds parts to the realpath 39 | // But only if the part is not empty or the uri 40 | // (the first three parts ar needed) was not 41 | // saved 42 | if (path.length < 2 || arr[k] !== '') { 43 | path.push(arr[k]); 44 | } 45 | } 46 | } 47 | 48 | // Returns the absloute path as a string 49 | return path.join('/'); 50 | } 51 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.0.0", 3 | "name": "ssh-deploy-release", 4 | "description": "Deploy release on remote server over ssh.", 5 | "author": { 6 | "name": "La Haute Société", 7 | "url": "http://www.lahautesociete.com", 8 | "email": "dev@lahautesociete.com" 9 | }, 10 | "repository": "https://github.com/la-haute-societe/ssh-deploy-release", 11 | "license": "MIT", 12 | "main": "dist/ssh-deploy-release.js", 13 | "babel": { 14 | "presets": [ 15 | [ 16 | "@babel/preset-env", 17 | { 18 | "targets": { 19 | "node": "8" 20 | } 21 | } 22 | ] 23 | ] 24 | }, 25 | "dependencies": { 26 | "any-shell-escape": "^0.1.1", 27 | "archiver": "^3.0.3", 28 | "async": "^3.1.0", 29 | "chai": "^4.2.0", 30 | "chalk": "^2.4.2", 31 | "cli-progress": "^3.3.1", 32 | "extend": "^3.0.2", 33 | "filesize": "^4.1.2", 34 | "human-format": "^0.10.1", 35 | "lodash": "^4.17.15", 36 | "moment": "^2.24.0", 37 | "ora": "^3.4.0", 38 | "ssh2": "^1.10.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.5.5", 42 | "@babel/core": "^7.5.5", 43 | "@babel/node": "^7.5.5", 44 | "@babel/preset-env": "^7.5.5", 45 | "@babel/register": "^7.5.5", 46 | "jsdom": "^15.1.1", 47 | "jsdom-global": "^3.0.2", 48 | "mocha": "^6.2.0", 49 | "sinon": "^7.3.2" 50 | }, 51 | "scripts": { 52 | "prepare": "npm run build", 53 | "test": "mocha --require @babel/register tests/**/*.spec.js", 54 | "build": "npx babel src --out-dir dist" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Archiver.js: -------------------------------------------------------------------------------- 1 | const archiver = require('archiver'); 2 | const fs = require('fs'); 3 | const filesize = require('filesize'); 4 | 5 | /** 6 | * Archiver 7 | * @type {Archiver} 8 | */ 9 | module.exports = class { 10 | 11 | 12 | /** 13 | * 14 | * @param type string 'zip' | 'tar' 15 | * @param fileName string 16 | * @param src string Source to compress 17 | * @param excludeList [] List of path to exclude 18 | */ 19 | constructor(type, fileName, src, excludeList) { 20 | this.type = type; 21 | this.src = src; 22 | this.excludeList = excludeList; 23 | this.fileName = fileName; 24 | } 25 | 26 | 27 | /** 28 | * Compress 29 | * @param onSuccess fn(filesize) 30 | * @param onError fn(err) 31 | */ 32 | compress(onSuccess, onError) { 33 | const archiverOptions = this.type === 'tar' ? { gzip: true } : {}; // TODO: add a setting to customize the compression level 34 | const archive = archiver(this.type, archiverOptions); 35 | this.output = fs.createWriteStream(this.fileName); 36 | 37 | // On success 38 | this.output.on('close', () => { 39 | const fileSize = filesize(archive.pointer()); 40 | onSuccess(fileSize); 41 | }); 42 | 43 | // On error 44 | archive.on('error', (err) => { 45 | onError(err); 46 | }); 47 | 48 | archive.pipe(this.output); 49 | archive.glob('**/*', { 50 | expand: true, 51 | cwd: this.src, 52 | ignore: this.excludeList, 53 | dot: true, 54 | }); 55 | archive.finalize(); 56 | } 57 | 58 | }; 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | 10 | ## [4.0.1] - 2022-06-02 11 | ### Fixed 12 | - Bug introduced in 4.0.0 that broke deploying in archive mode 13 | 14 | 15 | ## [4.0.0] - 2022-06-02 [YANKED] 16 | ### Breaking change 17 | - fix the `remote.upload()` function (fixes [#29], closes [#30], thanks [@xstable]). 18 | Read [the documentation](https://github.com/la-haute-societe/ssh-deploy-release#context-object) for more details 19 | ### Added 20 | - Ability to provide a custom connection using the `onBeforeConnect` callback 21 | - Ability to use the password authentication feature of ssh2 (see [#27] thanks [@Sibyx]) 22 | ### Updated 23 | - Bump ssh2 to 1.10.0 24 | 25 | 26 | ## [3.0.5] - 2021-06-24 27 | ## [3.0.4] - 2020-02-27 28 | ## [3.0.3] - 2020-01-28 29 | ## [3.0.2] - 2020-01-28 30 | ## [3.0.1] - 2020-01-16 31 | ## [3.0.0] - 2019-10-23 32 | 33 | [#27]: https://github.com/la-haute-societe/ssh-deploy-release/issues/27 34 | [#29]: https://github.com/la-haute-societe/ssh-deploy-release/issues/29 35 | [#30]: https://github.com/la-haute-societe/ssh-deploy-release/pull/30 36 | [@xstable]: https://github.com/xstable 37 | [@Sibyx]: https://github.com/Sibyx 38 | 39 | [Unreleased]: https://github.com/la-haute-societe/ssh-deploy-release/compare/4.0.0...HEAD 40 | [4.0.0]: https://github.com/la-haute-societe/ssh-deploy-release/compare/3.0.5...4.0.0 41 | [3.0.5]: https://github.com/la-haute-societe/ssh-deploy-release/compare/3.0.4...3.0.5 42 | [3.0.4]: https://github.com/la-haute-societe/ssh-deploy-release/compare/3.0.3...3.0.4 43 | [3.0.3]: https://github.com/la-haute-societe/ssh-deploy-release/compare/3.0.2...3.0.3 44 | [3.0.2]: https://github.com/la-haute-societe/ssh-deploy-release/compare/3.0.1...3.0.2 45 | [3.0.1]: https://github.com/la-haute-societe/ssh-deploy-release/compare/3.0.0...3.0.1 46 | [3.0.0]: https://github.com/la-haute-societe/ssh-deploy-release/releases/tag/3.0.0 47 | -------------------------------------------------------------------------------- /tests/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | import sinon from 'sinon'; 4 | import async from 'async'; 5 | 6 | import utils from '../src/utils'; 7 | 8 | 9 | 10 | describe('ReversePath', function () { 11 | 12 | it('should have getReversePath method', function () { 13 | assert.property(utils, 'getReversePath'); 14 | }); 15 | 16 | it('test a/b/c', function () { 17 | let results = utils.getReversePath('a/b/c'); 18 | assert.equal(results, '../../..'); 19 | }); 20 | it('test a/b/c/', function () { 21 | let results = utils.getReversePath('a/b/c/'); 22 | assert.equal(results, '../../../'); 23 | }); 24 | it('test a/b', function () { 25 | let results = utils.getReversePath('a/b'); 26 | assert.equal(results, '../..'); 27 | }); 28 | it('test a/b/', function () { 29 | let results = utils.getReversePath('a/b/'); 30 | assert.equal(results, '../../'); 31 | }); 32 | it('test a/', function () { 33 | let results = utils.getReversePath('a/'); 34 | assert.equal(results, '../'); 35 | }); 36 | it('test a', function () { 37 | let results = utils.getReversePath('a'); 38 | assert.equal(results, '..'); 39 | }); 40 | it('test /a', function () { 41 | let results = utils.getReversePath('/a'); 42 | assert.equal(results, '/..'); 43 | }); 44 | it('test /a/', function () { 45 | let results = utils.getReversePath('/a/'); 46 | assert.equal(results, '/../'); 47 | }); 48 | 49 | }); 50 | 51 | 52 | describe('realpath', function () { 53 | 54 | it('should have realpath method', function () { 55 | assert.property(utils, 'realpath'); 56 | }); 57 | 58 | it('test /a/../c', function () { 59 | let results = utils.realpath('/a/../c'); 60 | assert.equal(results, '/c'); 61 | }); 62 | 63 | it('test /a/b/../c', function () { 64 | let results = utils.realpath('/a/b/../c'); 65 | assert.equal(results, '/a/c'); 66 | }); 67 | 68 | it('test /a/b/c/..', function () { 69 | let results = utils.realpath('/a/b/c/..'); 70 | assert.equal(results, '/a/b'); 71 | }); 72 | 73 | it('test /a/b/c/./..', function () { 74 | let results = utils.realpath('/a/b/c/./..'); 75 | assert.equal(results, '/a/b'); 76 | }); 77 | 78 | 79 | }); 80 | 81 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const os = require('os'); 3 | const ora = require('ora'); 4 | 5 | 6 | let debug = false; 7 | let enabled = true; 8 | 9 | /** 10 | * Enable log 11 | * @param enable 12 | */ 13 | function setEnabled(enable) { 14 | enabled = enable; 15 | } 16 | 17 | /** 18 | * Set debug mode 19 | * @param enableDebug 20 | */ 21 | function setDebug(enableDebug) { 22 | debug = enableDebug; 23 | } 24 | 25 | 26 | /** 27 | * Log fatal error and exit process 28 | * @param message 29 | */ 30 | function logFatal(message) { 31 | console.log(os.EOL + chalk.red.bold('Fatal error : ' + message)); 32 | process.exit(1); 33 | } 34 | 35 | 36 | /** 37 | * Log subhead 38 | * @param message 39 | */ 40 | function logSubhead(message) { 41 | if (!enabled) return; 42 | console.log(os.EOL + chalk.bold.underline(message)); 43 | } 44 | 45 | 46 | /** 47 | * Log "ok" message 48 | * @param message 49 | */ 50 | function logOk(message) { 51 | if (!enabled) return; 52 | console.log(chalk.green(message)); 53 | } 54 | 55 | 56 | /** 57 | * Log warning message 58 | * @param message 59 | */ 60 | function logWarning(message) { 61 | if (!enabled) return; 62 | console.log(chalk.yellow(message)); 63 | } 64 | 65 | 66 | /** 67 | * Log error message 68 | * @param message 69 | */ 70 | function logError(message) { 71 | if (!enabled) return; 72 | console.log(chalk.red(message)); 73 | } 74 | 75 | 76 | /** 77 | * Log only if debug is enabled 78 | * @param message 79 | */ 80 | function logDebug(message) { 81 | if (!debug) { 82 | return; 83 | } 84 | 85 | if (!enabled) return; 86 | console.log(os.EOL + chalk.cyan(message)); 87 | } 88 | 89 | 90 | /** 91 | * Simple log 92 | * @param message 93 | */ 94 | function log(message) { 95 | if (!enabled) return; 96 | console.log(message); 97 | } 98 | 99 | /** 100 | * Start spinner 101 | * @param message 102 | * @returns {{stop: (function())}} 103 | */ 104 | function startSpinner(message) { 105 | if (!enabled) { 106 | return { 107 | stop: () => { 108 | } 109 | } 110 | } 111 | 112 | return ora(message).start(); 113 | } 114 | 115 | 116 | module.exports = { 117 | fatal: logFatal, 118 | subhead: logSubhead, 119 | log: log, 120 | debug: logDebug, 121 | ok: logOk, 122 | warning: logWarning, 123 | error: logError, 124 | startSpinner: startSpinner, 125 | setDebug: setDebug, 126 | setEnabled: setEnabled, 127 | }; 128 | -------------------------------------------------------------------------------- /src/Options.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const extend = require('extend'); 3 | const timestamp = moment().utc().format('YYYY-MM-DD-HH-mm-ss-SSS-UTC'); 4 | 5 | 6 | /** 7 | * Options 8 | * @type {Options} 9 | */ 10 | module.exports = class Options{ 11 | 12 | static defaultOptions() { 13 | return { 14 | debug: false, 15 | 16 | // Deployment mode ('archive' or 'synchronize') 17 | mode: 'archive', 18 | archiveName: 'release.tar.gz', 19 | 20 | // Archive type : 'zip' or 'tar' 21 | archiveType: 'tar', 22 | gzip: { 23 | gzip: true, 24 | gzipOptions: { 25 | level: 5 26 | } 27 | }, 28 | deleteLocalArchiveAfterDeployment: true, 29 | 30 | // SSH / SCP connection 31 | port: 22, 32 | host: '', 33 | username: '', 34 | password: '', 35 | privateKeyFile: null, 36 | readyTimeout: 20000, 37 | 38 | // Folders / link 39 | currentReleaseLink: 'www', 40 | sharedFolder: 'shared', 41 | releasesFolder: 'releases', 42 | localPath: 'www', 43 | deployPath: '', 44 | synchronizedFolder: 'synchronized', 45 | rsyncOptions: '', 46 | compression: true, 47 | 48 | // Release 49 | releasesToKeep: '3', 50 | tag: timestamp, 51 | 52 | // Excluded files 53 | exclude: [], 54 | 55 | // Folders to share 56 | share: {}, 57 | 58 | // Directories to create 59 | create: [], 60 | 61 | // File to make writable 62 | makeWritable: [], 63 | 64 | // Files to make executable 65 | makeExecutable: [], 66 | 67 | // Allow remove release on remote 68 | // Warning !! 69 | allowRemove: false, 70 | 71 | // Callback 72 | onBeforeDeploy: null, 73 | onBeforeLink: null, 74 | onBeforeRollback: null, 75 | onAfterRollback: null, 76 | onAfterDeploy: null, 77 | } 78 | }; 79 | 80 | 81 | /** 82 | * Return configuration 83 | * merge default prop 84 | */ 85 | constructor(appOptions) { 86 | this.options = extend( 87 | {}, 88 | Options.defaultOptions(), 89 | appOptions 90 | ); 91 | this.options = this.manageAlias(this.options); 92 | } 93 | 94 | get() { 95 | return this.options; 96 | } 97 | 98 | /** 99 | * Manage options alias 100 | * @param options 101 | */ 102 | manageAlias(options) { 103 | // Fix : "makeWriteable" is an alias of "makeWritable" 104 | if (options.makeWriteable) { 105 | options.makeWritable = options.makeWriteable; 106 | } 107 | 108 | return options; 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /src/Remote.js: -------------------------------------------------------------------------------- 1 | const {Client} = require('ssh2'); 2 | const fs = require('fs'); 3 | const exec = require('child_process').exec; 4 | const async = require('async'); 5 | const path = require('path'); 6 | const shellEscape = require('any-shell-escape'); 7 | const utils = require('./utils'); 8 | const cliProgress = require('cli-progress'); 9 | const chalk = require('chalk'); 10 | const humanFormat = require('human-format'); 11 | 12 | 13 | /** 14 | * Remote 15 | * @type {{}} 16 | */ 17 | module.exports = class { 18 | constructor(options, logger, onError) { 19 | this.options = options; 20 | this.logger = logger; 21 | this.onError = onError; 22 | 23 | if (options.privateKeyFile) { 24 | options.privateKey = fs.readFileSync(options.privateKeyFile); 25 | } 26 | } 27 | 28 | 29 | /** 30 | * Initiate SSH and SCP connection 31 | * @param onReady 32 | * @param onError 33 | * @param onClose 34 | */ 35 | connect(onReady, onError, onClose) { 36 | 37 | // Instantiate connection 38 | this.connection = new Client(); 39 | 40 | // Register events 41 | this.connection.on('ready', onReady); 42 | this.connection.on('error', onError); 43 | this.connection.on('close', onClose); 44 | 45 | if (this.options.onKeyboardInteractive) { 46 | this.connection.on('keyboard-interactive', this.options.onKeyboardInteractive); 47 | } 48 | 49 | const connectionOptions = Object.assign({}, this.options); 50 | 51 | // If debug is enabled, proxy output to console.log 52 | if (connectionOptions.debug) { 53 | connectionOptions.debug = console.log; 54 | } 55 | 56 | // Connect 57 | this.connection.connect(connectionOptions); 58 | } 59 | 60 | 61 | /** 62 | * Exec command on remote using SSH connection 63 | * @param command 64 | * @param {Function} done A NodeJS error first-callback. The second argument is a string representing the command output. 65 | * @param log Log result 66 | */ 67 | exec(command, done, log) { 68 | this.connection.exec(command, (error, stream) => { 69 | const stdout = []; 70 | const stderr = []; 71 | 72 | if (error) { 73 | this.onError(command, error); 74 | } 75 | 76 | stream.stderr.on('data', data => { 77 | stderr.push(data.toString()); 78 | this.logger.error(`STDERR: ${data}`); 79 | }); 80 | 81 | stream.on('data', data => { 82 | stdout.push(data.toString()); 83 | if (log) { 84 | this.logger.log(`STDOUT: ${data}`); 85 | return; 86 | } 87 | 88 | this.logger.debug(`STDOUT: ${data}`); 89 | }); 90 | 91 | stream.on('close', (exitCode, exitSignal) => { 92 | 93 | // Error 94 | if (exitCode !== 0) { 95 | this.logger.fatal('This command returns an error : "' + command + '"'); 96 | } 97 | 98 | this.logger.debug(`Remote command : ${command}`); 99 | done(null, exitCode, exitSignal, stdout, stderr); 100 | }); 101 | }); 102 | } 103 | 104 | 105 | /** 106 | * Exec multiple commands on remote using SSH connection 107 | * @param commands 108 | * @param done 109 | * @param log Log result 110 | */ 111 | execMultiple(commands, done, log) { 112 | 113 | async.eachSeries(commands, (command, itemCallback) => { 114 | 115 | this.exec(command, (error, exitCode, exitSignal, stdout, stderr) => { 116 | itemCallback(); 117 | }, log); 118 | 119 | }, done); 120 | } 121 | 122 | 123 | /** 124 | * Upload file on remote 125 | * @param src 126 | * @param target 127 | * @param done 128 | */ 129 | upload(src, target, done) { 130 | this.connection.sftp((err, sftp) => { 131 | if (err) { 132 | return done(err); 133 | } 134 | 135 | this.logger.debug(`Uploading file ${src} => ${target}`); 136 | 137 | const progressBar = new cliProgress.SingleBar({ 138 | format: `|${chalk.cyan('{bar}')}| {bytesTransferred}/{bytesTotal} || {percentage}% || Elapsed: {duration_formatted}`, 139 | hideCursor: true 140 | }, cliProgress.Presets.shades_classic); 141 | 142 | progressBar.start(100, 0, { 143 | bytesTotal: null, 144 | bytesTransferred: 0, 145 | }); 146 | 147 | sftp.fastPut( 148 | src, 149 | target, 150 | { 151 | chunkSize: 500, 152 | step: (bytesTransferred, chunkSize, bytesTotal) => { 153 | progressBar.update(bytesTransferred / bytesTotal * 100, { 154 | bytesTransferred: humanFormat(bytesTransferred, { scale: 'binary', unit: 'B' }), 155 | bytesTotal: humanFormat(bytesTotal, { scale: 'binary', unit: 'B' }), 156 | }); 157 | } 158 | }, 159 | (err) => { 160 | progressBar.stop(); 161 | done(err); 162 | } 163 | ); 164 | }); 165 | } 166 | 167 | /** 168 | * Synchronize local folder src to remote folder target 169 | * @param src 170 | * @param synchronizedFolder 171 | * @param done 172 | */ 173 | synchronize(src, target, synchronizedFolder, done) { 174 | const source = src + '/'; 175 | const fullTarget = this.options.username + '@' + this.options.host + ':' + synchronizedFolder; 176 | const escapedUsername = shellEscape(this.options.username); 177 | 178 | // Construct rsync command 179 | let remoteShell = ''; 180 | 181 | // Use password 182 | if (this.options.password != '') { 183 | const escapedPassword = shellEscape(this.options.password); 184 | remoteShell = `--rsh='sshpass -p "${escapedPassword}" ssh -l ${escapedUsername} -p ${this.options.port} -o StrictHostKeyChecking=no'`; 185 | } 186 | 187 | // Use privateKey 188 | else if (this.options.privateKeyFile != null) { 189 | const escapedPrivateKeyFile = shellEscape(this.options.privateKeyFile); 190 | 191 | let passphrase = ''; 192 | if (this.options.passphrase) { 193 | passphrase = `sshpass -p'${(shellEscape(this.options.passphrase))}' -P"assphrase for key"`; 194 | } 195 | 196 | remoteShell = `--rsh='${passphrase} ssh -l ${escapedUsername} -i ${escapedPrivateKeyFile} -p ${this.options.port} -o StrictHostKeyChecking=no'`; 197 | } 198 | 199 | // Excludes 200 | const excludes = this.options.exclude.map(path => `--exclude=${shellEscape(path)}`); 201 | 202 | // Compression 203 | let compression = this.options.compression !== false ? '--compress': ''; 204 | if (typeof this.options.compression === 'number') { 205 | compression += ` --compress-level=${this.options.compression}`; 206 | } 207 | 208 | // Concat 209 | const synchronizeCommand = [ 210 | 'rsync', 211 | remoteShell, 212 | ...this.options.rsyncOptions, 213 | compression, 214 | ...excludes, 215 | '--delete-excluded', 216 | '-a', 217 | '--stats', 218 | '--delete', 219 | source, 220 | fullTarget 221 | ].join(' '); 222 | 223 | // Exec ! 224 | this.logger.debug(`Local command : ${synchronizeCommand}`); 225 | exec(synchronizeCommand, (error, stdout, stderr) => { 226 | 227 | if (error) { 228 | this.logger.fatal(error); 229 | return; 230 | } 231 | 232 | if (stdout) { 233 | this.logger.log(stdout); 234 | } 235 | 236 | if (stderr) { 237 | this.logger.log(stderr); 238 | } 239 | 240 | this.synchronizeRemote( 241 | this.options.deployPath + '/' + this.options.synchronizedFolder, 242 | target, 243 | done 244 | ); 245 | }); 246 | } 247 | 248 | 249 | /** 250 | * Synchronize two remote folders 251 | * @param src 252 | * @param target 253 | * @param done 254 | */ 255 | synchronizeRemote(src, target, done) { 256 | const copy = 'rsync -a ' + src + '/ ' + target; 257 | 258 | this.exec(copy, () => { 259 | done(); 260 | }); 261 | } 262 | 263 | 264 | /** 265 | * Create symbolic link on remote 266 | * @param target 267 | * @param link 268 | * @param done 269 | */ 270 | createSymboliclink(target, link, done) { 271 | link = utils.realpath(link); 272 | const symlinkTarget = utils.realpath(link + '/../' + target); 273 | 274 | const commands = [ 275 | `mkdir -p \`dirname ${link}\``, // Create the parent of the symlink 276 | `if test ! -e ${symlinkTarget}; then mkdir -p ${symlinkTarget}; fi`, // Create the symlink target, if it doesn't exist 277 | `ln -nfs ${target} ${link}` 278 | ]; 279 | 280 | this.execMultiple(commands, done); 281 | } 282 | 283 | /** 284 | * Chmod path on remote 285 | * @param path 286 | * @param mode 287 | * @param done 288 | */ 289 | chmod(path, mode, done) { 290 | const command = 'chmod ' + mode + ' ' + path; 291 | 292 | this.exec(command, function () { 293 | done(); 294 | }); 295 | } 296 | 297 | /** 298 | * Create folder on remote 299 | * @param path 300 | * @param done 301 | */ 302 | createFolder(path, done) { 303 | const commands = [ 304 | 'mkdir -p ' + path, 305 | 'chmod ugo+w ' + path 306 | ]; 307 | this.execMultiple(commands, done); 308 | } 309 | 310 | 311 | /** 312 | * Remove old folders on remote 313 | * @param folder 314 | * @param numberToKeep 315 | * @param done 316 | */ 317 | removeOldFolders(folder, numberToKeep, done) { 318 | const commands = [ 319 | "cd " + folder + " && rm -rf `ls -r " + folder + " | awk 'NR>" + numberToKeep + "'`" 320 | ]; 321 | 322 | 323 | this.execMultiple(commands, () => { 324 | done(); 325 | }); 326 | } 327 | 328 | close(done) { 329 | this.connection.end(); 330 | done(); 331 | } 332 | 333 | getPenultimateRelease(done) { 334 | return new Promise((resolve, reject) => { 335 | const releasesPath = path.posix.join(this.options.deployPath, this.options.releasesFolder); 336 | const getPreviousReleaseDirectoryCommand = `ls -r -d ${releasesPath}/*/ | grep -v rollbacked | awk 'NR==2'`; 337 | 338 | this.exec(getPreviousReleaseDirectoryCommand, (err, exitCode, exitSignal, stdout, stderr) => { 339 | if (err) { 340 | reject(err); 341 | return; 342 | } 343 | 344 | const penultimateRelease = stdout[0]; 345 | if (!penultimateRelease) { 346 | reject('No previous release to rollback to'); 347 | return; 348 | } 349 | 350 | resolve(penultimateRelease); 351 | }); 352 | }); 353 | } 354 | }; 355 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-deploy-release 2 | 3 | [![NPM version](https://badge.fury.io/js/ssh-deploy-release.svg)](https://badge.fury.io/js/deployator) 4 | ![npm (tag)](https://img.shields.io/npm/v/ssh-deploy-release/beta) 5 | ![](https://img.shields.io/npm/dm/ssh-deploy-release.svg) 6 | 7 | > Deploy releases over SSH with rsync, archive ZIP / TAR, symlinks, SCP ... 8 | 9 | 10 | Example : 11 | 12 | ```` 13 | /deployPath 14 | | 15 | ├── www --> symlink to ./releases/ 16 | | 17 | ├── releases 18 | | ├── 2017-02-08-17-14-21-867-UTC 19 | | ├── ... 20 | | └── 2017-02-09-18-01-10-765-UTC 21 | | ├── ... 22 | | └── logs --> symlink to shared/logs 23 | | 24 | ├── synchronized --> folder synchronized with rsync 25 | | 26 | └── shared 27 | └── logs 28 | ```` 29 | 30 | 31 | - [Installation](#installation) 32 | - [Usage](#usage) 33 | - [Options](#options) 34 | - [Known issues](#known-issues) 35 | - [Contributing](#contributing) 36 | 37 | 38 | ## Installation 39 | 40 | `npm install ssh-deploy-release` 41 | 42 | 43 | 44 | ## Usage 45 | 46 | ### Deploy release 47 | 48 | ````js 49 | const Application = require('ssh-deploy-release'); 50 | 51 | const options = { 52 | localPath: 'src', 53 | host: 'my.server.com', 54 | username: 'username', 55 | password: 'password', 56 | deployPath: '/var/www/vhost/path/to/project' 57 | }; 58 | 59 | const deployer = new Application(options); 60 | deployer.deployRelease(() => { 61 | console.log('Ok !') 62 | }); 63 | ```` 64 | 65 | 66 | ### Remove release 67 | 68 | ````js 69 | const Application = require('ssh-deploy-release'); 70 | 71 | const options = { 72 | localPath: 'src', 73 | host: 'my.server.com', 74 | username: 'username', 75 | password: 'password', 76 | deployPath: '/var/www/vhost/path/to/project', 77 | allowRemove: true 78 | }; 79 | 80 | const deployer = new Application(options); 81 | deployer.removeRelease(() => { 82 | console.log('Ok !') 83 | }); 84 | ```` 85 | 86 | 87 | ### Rollback to previous release 88 | ````js 89 | const Application = require('ssh-deploy-release'); 90 | 91 | const options = { 92 | localPath: 'src', 93 | host: 'my.server.com', 94 | username: 'username', 95 | password: 'password', 96 | deployPath: '/var/www/vhost/path/to/project' 97 | }; 98 | 99 | const deployer = new Application(options); 100 | deployer.rollbackToPreviousRelease(() => { 101 | console.log('Ok !') 102 | }); 103 | ```` 104 | 105 | The previous release will be renamed before updating the symlink of the current version, for example 106 | `2019-01-09-10-53-35-265-UTC` will become 107 | `2019-01-09-13-46-45-457-UTC_rollback-to_2019-01-09-10-53-35-265-UTC`. 108 | 109 | If `rollbackToPreviousRelease` is called several times, the current version 110 | will switch between the last two releases. 111 | `current date + "_rollbackTo_"` will be prepended to the release name on each call of 112 | `rollbackToPreviousRelease` so be careful not to exceed the size limit of the folder name. 113 | 114 | 115 | ### Use with Grunt 116 | 117 | Use [grunt-ssh-deploy-release](https://github.com/la-haute-societe/grunt-ssh-deploy-release). 118 | 119 | 120 | ### Platform support 121 | 122 | You can use this to deploy from any platform supporting Node >= 8 to Linux servers (most UNIX systems should work as well but this hasn't been tested). 123 | 124 | Due to how we implemented the deployment process on the remote environment (using shell command execution), supporting Windows would required a lot of specific code, which would make this package harder to maintain. We decided to focus on supporting Linux as its the platform most widely used by hosting providers. 125 | 126 | 127 | ## Options 128 | 129 | ssh-deploy-release uses ssh2 to handle SSH connections. 130 | The `options` object is forwarded to `ssh2` methods, 131 | which means you can set all `ssh2` options: 132 | 133 | - [ssh2 documentation](https://github.com/mscdex/ssh2) 134 | 135 | #### options.debug 136 | If `true`, will display all commands. 137 | 138 | Default : `false` 139 | 140 | 141 | #### options.port 142 | Port used to connect to the remote server. 143 | 144 | Default : `22` 145 | 146 | 147 | #### options.host 148 | Remote server hostname. 149 | 150 | 151 | #### options.username 152 | Username used to connect to the remote server. 153 | 154 | 155 | #### options.password 156 | Password used to connect to the remote server. 157 | 158 | Default: `null` 159 | 160 | 161 | #### options.privateKeyFile 162 | 163 | Default: `null` 164 | 165 | 166 | #### options.passphrase 167 | For an encrypted private key, this is the passphrase used to decrypt it. 168 | 169 | Default: `null` 170 | 171 | 172 | #### options.agent 173 | To connect using the machine's ssh-agent. The value must be the path to the ssh-agent socket (usually available in the 174 | `SSH_AUTH_SOCK` environment variable). 175 | 176 | 177 | #### options.mode 178 | `archive` : Deploy an archive and decompress it on the remote server. 179 | 180 | `synchronize` : Use *rsync*. Files are synchronized in the `options.synchronized` folder on the remote server. 181 | 182 | Default : `archive` 183 | 184 | 185 | #### options.archiveType 186 | `zip` : Use *zip* compression (`unzip` command on remote) 187 | 188 | `tar` : Use *tar gz* compression (`tar` command on remote) 189 | 190 | Default : `tar` 191 | 192 | 193 | #### options.archiveName 194 | Name of the archive. 195 | 196 | Default : `release.tar.gz` 197 | 198 | 199 | #### options.deleteLocalArchiveAfterDeployment 200 | Delete the local archive after the deployment. 201 | 202 | Default : `true` 203 | 204 | 205 | #### options.readyTimeout 206 | SCP connection timeout duration. 207 | 208 | Default : `20000` 209 | 210 | 211 | #### options.onKeyboardInteractive 212 | Callback passed to `ssh2` [client event](https://github.com/mscdex/ssh2/blob/v0.8.x/README.md#client-events) 213 | `keyboard-interactive`. 214 | 215 | Type: `function (name, descr, lang, prompts, finish)` 216 | 217 | ### Path 218 | #### options.currentReleaseLink 219 | Name of the current release symbolic link. Relative to `deployPath`. 220 | 221 | Defaut : `www` 222 | 223 | 224 | #### options.sharedFolder 225 | Name of the folder containing shared folders. Relative to `deployPath`. 226 | 227 | Default : `shared` 228 | 229 | 230 | #### options.releasesFolder 231 | Name of the folder containing releases. Relative to `deployPath`. 232 | 233 | Default : `releases` 234 | 235 | 236 | #### options.localPath 237 | Name of the local folder to deploy. 238 | 239 | Default : `www` 240 | 241 | ⚠ ️In case you need to deploy your whole project directory, do NOT set `localPath` 242 | to an empty string, `null` or `.`. Use `process.cwd()` to have node generate an 243 | absolute path. In addition to this, if you use the [archive mode](#optionsmode), 244 | don't forget to [exclude](#optionsexclude) the generated archive 245 | (you can define its name using [options.archiveName](#optionsarchivename)). 246 | 247 | Example: 248 | 249 | ```js 250 | const Application = require('ssh-deploy-release'); 251 | const process = require('process'); 252 | 253 | const deployer = new Application({ 254 | localPath: process.cwd(), 255 | exclude: ['release.tar.gz'], 256 | archiveName: 'release.tar.gz', 257 | host: 'my.server.com', 258 | username: 'username', 259 | password: 'password', 260 | deployPath: '/var/www/vhost/path/to/project', 261 | }); 262 | ``` 263 | 264 | 265 | #### options.deployPath 266 | Absolute path on the remote server where releases will be deployed. 267 | Do not specify *currentReleaseLink* (or *www* folder) in this path. 268 | 269 | 270 | #### options.synchronizedFolder 271 | Name of the remote folder where *rsync* synchronize release. 272 | Used when `mode` is 'synchronize'. 273 | 274 | Default : `www` 275 | 276 | 277 | #### options.rsyncOptions 278 | Additional options for rsync process. 279 | 280 | Default : `''` 281 | 282 | ````js 283 | rsyncOptions : '--exclude-from="exclude.txt" --delete-excluded' 284 | ```` 285 | 286 | 287 | #### options.compression 288 | Enable the rsync --compression flag. This can be set to a boolean or 289 | an integer to explicitly set the compression level (--compress-level=NUM). 290 | 291 | Default : `true` 292 | 293 | 294 | ### Releases 295 | 296 | #### options.releasesToKeep 297 | Number of releases to keep on the remote server. 298 | 299 | Default : `3` 300 | 301 | #### options.tag 302 | Name of the release. Must be different for each release. 303 | 304 | Default : Use current timestamp. 305 | 306 | #### options.exclude 307 | List of paths to **not** deploy. 308 | 309 | Paths must be relative to `localPath`. 310 | 311 | The format slightly differ depending on the `mode`: 312 | 313 | * *glob* format for `mode: 'archive'` 314 | In order to exclude a folder, you have to explicitly ignore all its descending files using `**`. 315 | For example: `exclude: ['my-folder/**']` 316 | > Read [*glob* documentation](https://github.com/isaacs/node-glob) for more information. 317 | * *rsync exclude pattern* format for `mode: 'synchronize'` 318 | In order to exclude a folder, you simply have to list it, all of its descendants will be excluded as well. 319 | For example: `exclude: ['my-folder']` 320 | 321 | For maximum portability, it's strongly advised to use both syntaxes when excluding folders. 322 | For example: `exclude: ['my-folder/**', 'my-folder']` 323 | 324 | Default : `[]` 325 | 326 | #### options.share 327 | List of folders to "share" between releases. A symlink will be created for each item. 328 | Item can be either a string or an object (to specify the mode to set to the symlink target). 329 | 330 | ````js 331 | share: { 332 | 'images': 'assets/images', 333 | 'upload': { 334 | symlink: 'app/upload', 335 | mode: '777' // Will chmod 777 shared/upload 336 | } 337 | } 338 | ```` 339 | 340 | Keys = Folder to share (relative to `sharedFolder`) 341 | 342 | Values = Symlink path (relative to release folder) 343 | 344 | Default : `{}` 345 | 346 | #### options.create 347 | List of folders to create on the remote server. 348 | 349 | Default : `[]` 350 | 351 | 352 | #### options.makeWritable 353 | List of files to make writable on the remote server. (chmod ugo+w) 354 | 355 | Default : `[]` 356 | 357 | #### options.makeExecutable 358 | List of files to make executable on the remote server. (chmod ugo+x) 359 | 360 | Default : `[]` 361 | 362 | #### options.allowRemove 363 | If true, the remote release folder can be deleted with `removeRelease` method. 364 | 365 | Default: `false` 366 | 367 | 368 | 369 | ### Callbacks 370 | 371 | ##### context object 372 | The following object is passed to `onXXX` callbacks : 373 | ````js 374 | { 375 | // Loaded configuration 376 | options: { }, 377 | 378 | // Release 379 | release: { 380 | // Current release name 381 | tag: '2017-01-25-08-40-15-138-UTC', 382 | 383 | // Current release path on the remote server 384 | path: '/opt/.../releases/2017-01-25-08-40-15-138-UTC', 385 | }, 386 | 387 | // Logger methods 388 | logger: { 389 | // Log fatal error and stop process 390 | fatal: (message) => {}, 391 | 392 | // Log 'subhead' message 393 | subhead: (message) => {}, 394 | 395 | // Log 'ok' message 396 | ok: (message) => {}, 397 | 398 | // Log 'error' message and continue process 399 | error: (message) => {}, 400 | 401 | // Log message, only if options.debug is true 402 | debug: (message) => {}, 403 | 404 | // Log message 405 | log: (message) => {}, 406 | 407 | // Start a spinner and display message 408 | // return a stop() 409 | startSpinner: (message) => { return {stop: () => {}}}, 410 | }, 411 | 412 | // Remote server methods 413 | remote: { 414 | // Excute command on the remote server 415 | exec: (command, done, showLog) => {}, 416 | 417 | // Excute multiple commands (array) on the remote server 418 | execMultiple: (commands, done, showLog) => {}, 419 | 420 | /* 421 | * Upload local src file to target on the remote server. 422 | * @param {string} src The path to the file to upload. 423 | * May be either absolute or relative to the current working directory. 424 | * @param {string} target The path of the uploaded file on the remote server. 425 | * Must include the filename. The full directory hierarchy to the target must already exist. 426 | * May be either absolute or relative to the remote user home directory. 427 | * We strongly encourage you to use `options.deployPath` in your target path to produce an absolute path. 428 | */ 429 | upload: (src, target, done) => {}, 430 | 431 | // Create a symbolic link on the remote server 432 | createSymboliclink: (target, link, done) => {}, 433 | 434 | // Chmod path on the remote server 435 | chmod: (path, mode, done) => {}, 436 | 437 | // Create folder on the remote server 438 | createFolder: (path, done) => {}, 439 | } 440 | } 441 | ```` 442 | 443 | ##### Examples 444 | *onBeforeDeploy, onBeforeLink, onAfterDeploy, onBeforeRollback, onAfterRollback options.* 445 | 446 | ###### Single command executed on remote 447 | 448 | ````js 449 | onAfterDeploy: 'apachectl graceful' 450 | ```` 451 | 452 | **Or** with a function : 453 | 454 | ````js 455 | onBeforeLink: context => `chgrp -R www ${context.release.path}` 456 | ```` 457 | 458 | 459 | ###### List of commands executed on remote 460 | 461 | ````js 462 | onAfterDeploy: [ 463 | 'do something on the remote server', 464 | 'and another thing' 465 | ] 466 | ```` 467 | 468 | **Or** with a function : 469 | 470 | ````js 471 | onBeforeLink: (context) => { 472 | context.logger.subhead('Fine tuning permissions on newly deployed release'); 473 | return [ 474 | `chgrp -R www ${context.release.path}`, 475 | `chmod g+w ${context.release.path}/some/path/that/needs/to/be/writable/by/www/group`, 476 | ]; 477 | } 478 | ```` 479 | 480 | 481 | ###### Custom callback 482 | 483 | ````js 484 | onAfterDeploy: context => { 485 | return Promise((resolve, reject) => { 486 | setTimeout(function () { 487 | // Do something 488 | resolve(); 489 | }, 5000); 490 | }); 491 | } 492 | ```` 493 | 494 | 495 | 496 | 497 | #### options.onBeforeConnect 498 | Executed before connecting to the SSH server to let you initiate a custom 499 | connection. It must return a ssh2 Client instance, and call `onReady` when that 500 | connection is ready. 501 | 502 | Type: `function(context, onReady, onError, onClose): Client` 503 | 504 | Example: SSH jumps (connecting to your deployment server through a bastion) 505 | 506 | ````js 507 | onBeforeConnect: (context, onReady, onError, onClose) => { 508 | const bastion = new Client(); 509 | const connection = new Client(); 510 | 511 | bastion.on('error', onError); 512 | bastion.on('close', onClose); 513 | bastion.on('ready', () => { 514 | bastion.forwardOut( 515 | '127.0.0.1', 516 | 12345, 517 | 'www.example.com', 518 | 22, 519 | (err, stream) => { 520 | if (err) { 521 | context.logger.fatal(`Error connection to the bastion: ${err}`); 522 | bastion.end(); 523 | onClose(); 524 | return; 525 | } 526 | 527 | connection.connect({ 528 | sock: stream, 529 | user: 'www-user', 530 | password: 'www-password', 531 | }); 532 | } 533 | ); 534 | }); 535 | 536 | connection.on('error', (err) => { 537 | context.logger.error(err); 538 | bastion.end(); 539 | }); 540 | connection.on('close', () => { 541 | bastion.end(); 542 | }); 543 | connection.on('ready', onReady); 544 | 545 | bastion.connect({ 546 | host: 'bastion.example.com', 547 | user: 'bastion-user', 548 | password: 'bastion-password', 549 | }); 550 | 551 | return connection; 552 | } 553 | ```` 554 | 555 | #### options.onBeforeDeploy 556 | Executed before deployment. 557 | 558 | Type: `string | string[] | function(context, done): Promise | undefined` 559 | 560 | 561 | #### options.onBeforeLink 562 | Executed before symlink creation. 563 | 564 | Type: `string | string[] | function(context, done): Promise | undefined` 565 | 566 | 567 | #### options.onAfterDeploy 568 | Executed after deployment. 569 | 570 | Type: `string | string[] | function(context, done): Promise | undefined` 571 | 572 | 573 | #### options.onBeforeRollback 574 | Executed before rollback to previous release. 575 | 576 | Type: `string | string[] | function(context, done): Promise | undefined` 577 | 578 | 579 | #### options.onAfterRollback 580 | Executed after rollback to previous release. 581 | 582 | Type: `string | string[] | function(context, done): Promise | undefined` 583 | 584 | 585 | 586 | 587 | ## Known issues 588 | 589 | ### Command not found or not executed 590 | 591 | A command on a callback method is not executed or not found. 592 | Try to add `set -i && source ~/.bashrc &&` before your commmand : 593 | 594 | ```` 595 | onAfterDeploy:[ 596 | 'set -i && source ~/.bashrc && my command' 597 | ] 598 | ```` 599 | 600 | See this issue : https://github.com/mscdex/ssh2/issues/77 601 | 602 | 603 | 604 | ## Contributing 605 | 606 | ````bash 607 | # Build (with Babel) 608 | npm run build 609 | 610 | # Build + watch (with Babel) 611 | npm run build -- --watch 612 | 613 | # Launch tests (Mocha + SinonJS) 614 | npm test 615 | 616 | # Launch tests + watch (Mocha + SinonJS) 617 | npm test -- --watch 618 | ```` 619 | -------------------------------------------------------------------------------- /tests/ssh-deploy-release.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | import sinon from 'sinon'; 4 | 5 | import Deployer from '../src/ssh-deploy-release'; 6 | import Options from '../src/Options'; 7 | import Release from '../src/Release'; 8 | 9 | 10 | describe('API', function () { 11 | 12 | it('should have deployRelease method', function () { 13 | let deployer = new Deployer({}); 14 | assert.property(deployer, 'deployRelease'); 15 | }); 16 | 17 | it('should have removeRelease method', function () { 18 | let deployer = new Deployer({}); 19 | assert.property(deployer, 'removeRelease'); 20 | }); 21 | 22 | it('should have rollbackToPreviousRelease method', function () { 23 | let deployer = new Deployer({}); 24 | assert.property(deployer, 'rollbackToPreviousRelease'); 25 | }); 26 | }); 27 | 28 | describe('Options', function () { 29 | 30 | it('should set default options', function () { 31 | let deployer = new Deployer({}); 32 | let defaultOptions = Options.defaultOptions(); 33 | assert.deepEqual(deployer.options, defaultOptions); 34 | }); 35 | 36 | it('should override default options', function () { 37 | let deployer = new Deployer({ 38 | debug: true 39 | }); 40 | assert.equal(deployer.options.debug, true); 41 | }); 42 | }); 43 | 44 | describe('Release', function () { 45 | 46 | it('should instance release object', function () { 47 | let deployer = new Deployer({}); 48 | assert.property(deployer, 'release'); 49 | assert.instanceOf(deployer.release, Release); 50 | }); 51 | 52 | it('should have a tag (string)', function () { 53 | let deployer = new Deployer({ 54 | tag: 'test' 55 | }); 56 | assert.equal(deployer.release.tag, 'test'); 57 | }); 58 | 59 | it('should have a tag (function)', function () { 60 | let deployer = new Deployer({ 61 | tag: () => { 62 | return 'test' 63 | } 64 | }); 65 | assert.equal(deployer.release.tag, 'test'); 66 | }); 67 | 68 | it('should have a tag even if not defined', function () { 69 | let deployer = new Deployer({ 70 | tag: '' 71 | }); 72 | assert.notEqual(deployer.release.tag, ''); 73 | }); 74 | 75 | it('should have a path', function () { 76 | let deployer = new Deployer({ 77 | deployPath: 'deployPath', 78 | releaseFolder: 'releaseFolder', 79 | tag: 'tag' 80 | }); 81 | assert.equal(deployer.release.path, 'deployPath/releases/tag'); 82 | }); 83 | }); 84 | 85 | describe('Deploy - Tasks', function () { 86 | 87 | it('should call all required tasks', function (done) { 88 | const requiredTasks = [ 89 | 'onBeforeDeployTask', 90 | 'onBeforeDeployExecuteTask', 91 | 'compressReleaseTask', 92 | 'onBeforeConnectTask', 93 | 'connectToRemoteTask', 94 | 'createReleaseFolderOnRemoteTask', 95 | 'uploadArchiveTask', 96 | 'uploadReleaseTask', 97 | 'decompressArchiveOnRemoteTask', 98 | 'onBeforeLinkTask', 99 | 'onBeforeLinkExecuteTask', 100 | 'updateSharedSymbolicLinkOnRemoteTask', 101 | 'createFolderTask', 102 | 'makeDirectoriesWritableTask', 103 | 'makeFilesExecutableTask', 104 | 'updateCurrentSymbolicLinkOnRemoteTask', 105 | 'onAfterDeployTask', 106 | 'onAfterDeployExecuteTask', 107 | 'remoteCleanupTask', 108 | 'deleteLocalArchiveTask', 109 | 'closeConnectionTask', 110 | ]; 111 | 112 | let deployer = new Deployer(); 113 | 114 | const stubs = requiredTasks.map(taskName => { 115 | let stub = sinon.stub(deployer, taskName).yieldsAsync(); 116 | return { 117 | stub: stub, 118 | name: taskName 119 | }; 120 | }); 121 | 122 | deployer.deployRelease(() => { 123 | stubs.forEach(stub => { 124 | assert(stub.stub.called, stub.name + ' method not called'); 125 | stub.stub.restore(); 126 | }); 127 | done(); 128 | }); 129 | }); 130 | 131 | }); 132 | 133 | describe('Remove release - Tasks', function () { 134 | 135 | it('should call all required tasks', function (done) { 136 | const requiredTasks = [ 137 | 'onBeforeConnectTask', 138 | 'connectToRemoteTask', 139 | 'removeReleaseTask', 140 | 'closeConnectionTask', 141 | ]; 142 | 143 | let deployer = new Deployer({ allowRemove: true }); 144 | 145 | const stubs = requiredTasks.map(taskName => { 146 | let stub = sinon.stub(deployer, taskName).yieldsAsync(); 147 | return { 148 | stub: stub, 149 | name: taskName 150 | }; 151 | }); 152 | 153 | deployer.removeRelease(() => { 154 | stubs.forEach(stub => { 155 | assert(stub.stub.called, stub.name + ' method not called'); 156 | stub.stub.restore(); 157 | }); 158 | done(); 159 | }); 160 | }); 161 | 162 | }); 163 | 164 | describe('Remove release - Tasks', function () { 165 | 166 | it('should fail if removing is not allowed', function (done) { 167 | const forbiddenTasks = [ 168 | 'onBeforeConnectTask', 169 | 'connectToRemoteTask', 170 | 'removeReleaseTask', 171 | 'closeConnectionTask', 172 | ]; 173 | 174 | let deployer = new Deployer(); 175 | 176 | const stubs = forbiddenTasks.map(taskName => { 177 | let stub = sinon.stub(deployer, taskName).yieldsAsync(); 178 | return { 179 | stub: stub, 180 | name: taskName 181 | }; 182 | }); 183 | 184 | const consoleWarnSpy = sinon.spy(console, 'warn'); 185 | 186 | deployer.removeRelease(() => { 187 | stubs.forEach(stub => { 188 | assert(!stub.stub.called, stub.name + ' method called'); 189 | stub.stub.restore(); 190 | }); 191 | 192 | const message = 'Removing is not allowed on this environment. Aborting…'; 193 | assert(consoleWarnSpy.calledWith(message), `console.warn('${message}') not called`); 194 | done(); 195 | }); 196 | }); 197 | 198 | }); 199 | 200 | describe('RollbackToPreviousRelease - Tasks', function () { 201 | 202 | it('should call all required tasks', function (done) { 203 | const requiredTasks = [ 204 | 'onBeforeConnectTask', 205 | 'connectToRemoteTask', 206 | 'onBeforeRollbackTask', 207 | 'onBeforeRollbackExecuteTask', 208 | 'populatePenultimateReleaseNameTask', 209 | 'renamePenultimateReleaseTask', 210 | 'updateCurrentSymbolicLinkOnRemoteTask', 211 | 'onAfterRollbackTask', 212 | 'onAfterRollbackExecuteTask', 213 | 'closeConnectionTask', 214 | ]; 215 | 216 | let deployer = new Deployer(); 217 | 218 | const stubs = requiredTasks.map(taskName => { 219 | let stub = sinon.stub(deployer, taskName).yieldsAsync(); 220 | return { 221 | stub: stub, 222 | name: taskName 223 | }; 224 | }); 225 | 226 | deployer.rollbackToPreviousRelease(() => { 227 | stubs.forEach(stub => { 228 | assert(stub.stub.called, stub.name + ' method not called'); 229 | stub.stub.restore(); 230 | }); 231 | done(); 232 | }); 233 | }); 234 | 235 | }); 236 | 237 | describe('Deploy - Middleware callbacks', function () { 238 | 239 | [ 240 | 'onBeforeDeploy', 241 | 'onBeforeLink', 242 | 'onAfterDeploy', 243 | 'onAfterRollback', 244 | ].forEach(taskName => { 245 | it('should call ' + taskName + ' middleware callback', function (done) { 246 | const spy = sinon.spy(function (context, done) { 247 | done(); 248 | }); 249 | let options = {}; 250 | options[taskName] = spy; 251 | const deployer = new Deployer(options); 252 | 253 | deployer[taskName + 'Task'](() => { 254 | assert(spy.calledOnce, taskName + 'callback not called once'); 255 | done(); 256 | }); 257 | }); 258 | }); 259 | 260 | 261 | [ 262 | 'onBeforeDeployExecute', 263 | 'onBeforeLinkExecute', 264 | 'onAfterDeployExecute', 265 | 'onAfterRollbackExecute', 266 | ].forEach(taskName => { 267 | 268 | it('should call ' + taskName + ' middleware callback - no command', function (done) { 269 | const deployer = new Deployer(); 270 | deployer[taskName + 'Task'](done); 271 | }); 272 | 273 | it('should call ' + taskName + ' middleware callback - function return no command', function (done) { 274 | const spy = sinon.spy(function (context) { 275 | return []; 276 | }); 277 | 278 | let options = {}; 279 | options[taskName] = spy; 280 | const deployer = new Deployer(options); 281 | deployer[taskName + 'Task'](() => { 282 | assert(spy.calledOnce); 283 | done(); 284 | }); 285 | }); 286 | 287 | it('should call ' + taskName + ' middleware callback - function return command', function (done) { 288 | let options = {}; 289 | options[taskName] = function (context) { 290 | return ['first']; 291 | }; 292 | const deployer = new Deployer(options); 293 | deployer.logger.setEnabled(false); 294 | 295 | const stub = sinon.stub(deployer.remote, 'exec'); 296 | 297 | deployer[taskName + 'Task'](() => { 298 | done(); 299 | }); 300 | stub.callArg(1); 301 | }); 302 | 303 | it('should call ' + taskName + ' middleware callback - array command', function (done) { 304 | let options = {}; 305 | options[taskName] = ['first']; 306 | const deployer = new Deployer(options); 307 | deployer.logger.setEnabled(false); 308 | 309 | const stub = sinon.stub(deployer.remote, 'exec'); 310 | 311 | deployer[taskName + 'Task'](() => { 312 | done(); 313 | }); 314 | stub.callArg(1); 315 | }); 316 | }); 317 | }); 318 | 319 | describe('Deploy - archive', function () { 320 | 321 | it('should instanciate Archiver', function (done) { 322 | 323 | const deployer = new Deployer({ 324 | mode: 'archive' 325 | }); 326 | deployer.logger.setEnabled(false); 327 | 328 | let spy = sinon.spy(deployer, 'createArchiver'); 329 | 330 | deployer.compressReleaseTask(() => { 331 | assert(spy.called, 'Archive mode should create archive'); 332 | done(); 333 | }) 334 | }); 335 | 336 | it('should upload archive', function (done) { 337 | 338 | const deployer = new Deployer({ 339 | mode: 'archive' 340 | }); 341 | deployer.logger.setEnabled(false); 342 | 343 | let stub = sinon.stub(deployer.remote, 'upload'); 344 | 345 | deployer.uploadArchiveTask(() => { 346 | assert(stub.called, 'Archive mode should upload archive'); 347 | done(); 348 | }); 349 | 350 | stub.callArg(2); 351 | }); 352 | 353 | it('should decompress archive on remote', function (done) { 354 | 355 | const deployer = new Deployer({ 356 | mode: 'archive' 357 | }); 358 | deployer.logger.setEnabled(false); 359 | 360 | let stub = sinon.stub(deployer.remote, 'exec'); 361 | 362 | stub.onCall(0).callsArg(1); 363 | stub.onCall(1).callsArg(1); 364 | 365 | deployer.decompressArchiveOnRemoteTask(() => { 366 | done(); 367 | }); 368 | }); 369 | }); 370 | 371 | describe('Deploy - synchronize', function () { 372 | it('should not instanciate Archiver', function (done) { 373 | 374 | const deployer = new Deployer({ 375 | mode: 'synchronize' 376 | }); 377 | deployer.logger.setEnabled(false); 378 | 379 | let spy = sinon.spy(deployer, 'createArchiver'); 380 | 381 | deployer.compressReleaseTask(() => { 382 | assert(spy.notCalled, 'Synchronize mode should not compress archive'); 383 | done(); 384 | }) 385 | }); 386 | 387 | it('should not upload archive', function (done) { 388 | 389 | const deployer = new Deployer({ 390 | mode: 'synchronize' 391 | }); 392 | deployer.logger.setEnabled(false); 393 | 394 | let stub = sinon.stub(deployer.remote, 'upload'); 395 | 396 | deployer.uploadArchiveTask(() => { 397 | assert(stub.notCalled, 'Synchronize mode should not upload archive'); 398 | done(); 399 | }); 400 | 401 | stub.callArg(2); 402 | }); 403 | 404 | it('should not decompress archive on remote', function (done) { 405 | 406 | const deployer = new Deployer({ 407 | mode: 'synchronize' 408 | }); 409 | deployer.logger.setEnabled(false); 410 | 411 | let stub = sinon.spy(deployer.remote, 'exec'); 412 | 413 | deployer.decompressArchiveOnRemoteTask(() => { 414 | assert(stub.notCalled, 'Synchronize mode should not decompress archive on remote'); 415 | done(); 416 | }); 417 | }); 418 | }); 419 | 420 | describe('Deploy - Shared symlinks', function () { 421 | 422 | it('should not create shared symlinks', function (done) { 423 | const deployer = new Deployer(); 424 | deployer.logger.setEnabled(false); 425 | 426 | const createSymboliclinkStub = sinon.stub(deployer.remote, 'createSymboliclink'); 427 | 428 | deployer.updateSharedSymbolicLinkOnRemoteTask(() => { 429 | assert(createSymboliclinkStub.notCalled, 'Should call Remote.createSymboliclink method'); 430 | done(); 431 | }); 432 | 433 | createSymboliclinkStub.callArg(2); 434 | }); 435 | 436 | 437 | // Test multiple config 438 | [ 439 | // 440 | { 441 | share: { 442 | 'target': 'link' 443 | } 444 | }, 445 | // 446 | { 447 | share: { 448 | 'target': { 449 | 'symlink': 'link' 450 | } 451 | } 452 | }, 453 | ].forEach(config => { 454 | it('should create shared symlinks', function (done) { 455 | 456 | const deployer = new Deployer(config); 457 | deployer.logger.setEnabled(false); 458 | 459 | const createSymboliclinkStub = sinon.stub(deployer.remote, 'createSymboliclink'); 460 | const chmodStub = sinon.stub(deployer.remote, 'chmod'); 461 | 462 | deployer.updateSharedSymbolicLinkOnRemoteTask(() => { 463 | assert(createSymboliclinkStub.called, 'Should call Remote.createSymboliclink method'); 464 | assert(chmodStub.notCalled, 'Should not call Remote.chmod method'); 465 | done(); 466 | }); 467 | 468 | createSymboliclinkStub.callArg(2); 469 | }); 470 | }); 471 | 472 | 473 | it('should create shared symlinks and set chmod', function (done) { 474 | 475 | const deployer = new Deployer({ 476 | share: { 477 | 'target': { 478 | symlink: 'link', 479 | mode: '0777' 480 | } 481 | } 482 | }); 483 | deployer.logger.setEnabled(false); 484 | 485 | const createSymboliclinkStub = sinon.stub(deployer.remote, 'createSymboliclink'); 486 | const chmodStub = sinon.stub(deployer.remote, 'chmod'); 487 | 488 | deployer.updateSharedSymbolicLinkOnRemoteTask(() => { 489 | assert(createSymboliclinkStub.called, 'Should call Remote.createSymboliclink method'); 490 | assert(chmodStub.called, 'Should call Remote.chmod method'); 491 | done(); 492 | }); 493 | 494 | createSymboliclinkStub.callArg(2); 495 | chmodStub.callArg(2); 496 | }); 497 | 498 | }); 499 | 500 | describe('Deploy - create folder', function () { 501 | it('should not create folder on remote', function (done) { 502 | 503 | const deployer = new Deployer(); 504 | deployer.logger.setEnabled(false); 505 | 506 | const createFolderStub = sinon.stub(deployer.remote, 'createFolder'); 507 | 508 | deployer.createFolderTask(() => { 509 | assert(createFolderStub.notCalled, 'Should not call Remote.createFolder method'); 510 | done(); 511 | }); 512 | 513 | createFolderStub.callArg(1); 514 | }); 515 | 516 | it('should create folder on remote', function (done) { 517 | 518 | const deployer = new Deployer({ 519 | create: ['test'] 520 | }); 521 | deployer.logger.setEnabled(false); 522 | 523 | const createFolderStub = sinon.stub(deployer.remote, 'createFolder'); 524 | 525 | deployer.createFolderTask(() => { 526 | assert(createFolderStub.called, 'Should call Remote.createFolder method'); 527 | done(); 528 | }); 529 | 530 | createFolderStub.callArg(1); 531 | }); 532 | }); 533 | 534 | describe('Deploy - make writable folder', function () { 535 | it('should not make writable folder on remote', function (done) { 536 | 537 | const deployer = new Deployer(); 538 | deployer.logger.setEnabled(false); 539 | 540 | const chmodStub = sinon.stub(deployer.remote, 'chmod'); 541 | 542 | deployer.makeDirectoriesWritableTask(() => { 543 | assert(chmodStub.notCalled, 'Should not call Remote.chmod method'); 544 | done(); 545 | }); 546 | 547 | chmodStub.callArg(2); 548 | }); 549 | 550 | it('should make writable folder on remote', function (done) { 551 | 552 | const deployer = new Deployer({ 553 | makeWritable: ['test'] 554 | }); 555 | deployer.logger.setEnabled(false); 556 | 557 | const chmodStub = sinon.stub(deployer.remote, 'chmod'); 558 | 559 | deployer.makeDirectoriesWritableTask(() => { 560 | assert(chmodStub.called, 'Should call Remote.chmod method'); 561 | done(); 562 | }); 563 | 564 | chmodStub.callArg(2); 565 | }); 566 | 567 | }); 568 | 569 | describe('Deploy - make file executable', function () { 570 | it('should not make file executable on remote', function (done) { 571 | const deployer = new Deployer(); 572 | deployer.logger.setEnabled(false); 573 | 574 | const execStub = sinon.stub(deployer.remote, 'exec'); 575 | 576 | deployer.makeFilesExecutableTask(() => { 577 | assert(execStub.notCalled, 'Should not call Remote.exec method'); 578 | done(); 579 | }); 580 | 581 | execStub.callArg(1); 582 | }); 583 | 584 | it('should make file executable on remote', function (done) { 585 | const deployer = new Deployer({ 586 | makeExecutable: ['test'] 587 | }); 588 | deployer.logger.setEnabled(false); 589 | 590 | const execStub = sinon.stub(deployer.remote, 'exec'); 591 | 592 | deployer.makeFilesExecutableTask(() => { 593 | assert(execStub.called, 'Should call Remote.exec method'); 594 | done(); 595 | }); 596 | 597 | execStub.callArg(1); 598 | }); 599 | }); 600 | 601 | describe('Deploy - update current symlink', function () { 602 | it('should update current symlink on remote', function (done) { 603 | const deployer = new Deployer(); 604 | deployer.logger.setEnabled(false); 605 | 606 | const createSymboliclinkStub = sinon.stub(deployer.remote, 'createSymboliclink'); 607 | 608 | deployer.updateCurrentSymbolicLinkOnRemoteTask(() => { 609 | assert(createSymboliclinkStub.called, 'Should call Remote.createSymboliclink method'); 610 | done(); 611 | }); 612 | 613 | createSymboliclinkStub.callArg(2); 614 | }); 615 | }); 616 | 617 | describe('Clean up on remote', function () { 618 | it('should clean up releases on remote', function (done) { 619 | const deployer = new Deployer(); 620 | deployer.logger.setEnabled(false); 621 | 622 | const removeOldFoldersStub = sinon.stub(deployer.remote, 'removeOldFolders'); 623 | 624 | deployer.remoteCleanupTask(() => { 625 | assert(removeOldFoldersStub.called, 'Should call Remote.removeOldFolders method'); 626 | done(); 627 | }); 628 | 629 | removeOldFoldersStub.callArg(2); 630 | }); 631 | }); 632 | 633 | describe('Delete local archive', function () { 634 | it('should delete local archive', function (done) { 635 | const deployer = new Deployer(); 636 | deployer.logger.setEnabled(false); 637 | 638 | const fs = require('fs'); 639 | const unlinkStub = sinon.stub(fs, 'unlinkSync'); 640 | 641 | deployer.deleteLocalArchiveTask(() => { 642 | assert(unlinkStub.called, 'Should call fs.unlink method'); 643 | unlinkStub.restore(); 644 | done(); 645 | }); 646 | }); 647 | 648 | it('should not delete local archive on synchronize mode', function (done) { 649 | const deployer = new Deployer({ 650 | mode: 'synchronize' 651 | }); 652 | deployer.logger.setEnabled(false); 653 | 654 | const fs = require('fs'); 655 | const unlinkStub = sinon.stub(fs, 'unlinkSync'); 656 | 657 | deployer.deleteLocalArchiveTask(() => { 658 | assert(unlinkStub.notCalled, 'Should call fs.unlink method'); 659 | unlinkStub.restore(); 660 | done(); 661 | }); 662 | }); 663 | 664 | it('should not delete local archive on synchronize mode', function (done) { 665 | const deployer = new Deployer({ 666 | mode: 'archive', 667 | deleteLocalArchiveAfterDeployment: false, 668 | }); 669 | deployer.logger.setEnabled(false); 670 | 671 | const fs = require('fs'); 672 | const unlinkStub = sinon.stub(fs, 'unlinkSync'); 673 | 674 | deployer.deleteLocalArchiveTask(() => { 675 | assert(unlinkStub.notCalled, 'Should call fs.unlink method'); 676 | unlinkStub.restore(); 677 | done(); 678 | }); 679 | }); 680 | }); 681 | -------------------------------------------------------------------------------- /src/ssh-deploy-release.js: -------------------------------------------------------------------------------- 1 | const async = require('async'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const _ = require('lodash'); 5 | 6 | const Options = require('./Options'); 7 | const Release = require('./Release'); 8 | const Remote = require('./Remote'); 9 | const Archiver = require('./Archiver'); 10 | 11 | const logger = require('./logger'); 12 | const utils = require('./utils'); 13 | 14 | 15 | module.exports = class { 16 | constructor(options) { 17 | this.options = new Options(options).get(); 18 | this.release = new Release(this.options, Options.defaultOptions()); 19 | this.remote = new Remote(this.options); 20 | this.logger = logger; 21 | this.logger.setDebug(this.options.debug); 22 | this.context = { 23 | options: this.options, 24 | release: this.release, 25 | logger: this.logger, 26 | remote: { 27 | exec: (command, done, showLog) => { 28 | this.remote.exec(command, done, showLog); 29 | }, 30 | execMultiple: (commands, done, showLog) => { 31 | this.remote.execMultiple(commands, done, showLog); 32 | }, 33 | upload: (src, target, done) => { 34 | this.remote.upload(src, target, done); 35 | }, 36 | createSymboliclink: (target, link, done) => { 37 | this.remote.createSymboliclink(target, link, done); 38 | }, 39 | chmod: (path, mode, done) => { 40 | this.remote.chmod(path, mode, done); 41 | }, 42 | createFolder: (path, done) => { 43 | this.remote.createFolder(path, done); 44 | }, 45 | }, 46 | 47 | // 1.x.x compatibility 48 | // @deprecated 49 | releaseTag: this.release.tag, 50 | // @deprecated 51 | releaseName: this.release.name, 52 | // @deprecated 53 | execRemote: (cmd, showLog, done) => { 54 | this.remote.exec(cmd, done, showLog); 55 | }, 56 | }; 57 | } 58 | 59 | 60 | noop() { 61 | // Nothing 62 | } 63 | 64 | 65 | /** 66 | * Deploy release 67 | */ 68 | deployRelease(done) { 69 | done = done || this.noop; 70 | 71 | async.series([ 72 | this.onBeforeConnectTask.bind(this), 73 | this.connectToRemoteTask.bind(this), 74 | this.onBeforeDeployTask.bind(this), 75 | this.onBeforeDeployExecuteTask.bind(this), 76 | this.compressReleaseTask.bind(this), 77 | this.createReleaseFolderOnRemoteTask.bind(this), 78 | this.uploadArchiveTask.bind(this), 79 | this.uploadReleaseTask.bind(this), 80 | this.decompressArchiveOnRemoteTask.bind(this), 81 | this.updateSharedSymbolicLinkOnRemoteTask.bind(this), 82 | this.createFolderTask.bind(this), 83 | this.makeDirectoriesWritableTask.bind(this), 84 | this.makeFilesExecutableTask.bind(this), 85 | this.onBeforeLinkTask.bind(this), 86 | this.onBeforeLinkExecuteTask.bind(this), 87 | this.updateCurrentSymbolicLinkOnRemoteTask.bind(this), 88 | this.onAfterDeployTask.bind(this), 89 | this.onAfterDeployExecuteTask.bind(this), 90 | this.remoteCleanupTask.bind(this), 91 | this.deleteLocalArchiveTask.bind(this), 92 | this.closeConnectionTask.bind(this), 93 | ], function (err, result) { 94 | // TODO: Consider calling this.closeConnectionTask() here to ensure it's closed even if an error occurs in the series 95 | // TODO: Handle the case where err isn't null 96 | done(); 97 | }); 98 | } 99 | 100 | 101 | /** 102 | * Rollback to previous release 103 | * @param done 104 | */ 105 | rollbackToPreviousRelease(done) { 106 | done = done || this.noop; 107 | 108 | async.series([ 109 | this.onBeforeConnectTask.bind(this), 110 | this.connectToRemoteTask.bind(this), 111 | this.onBeforeRollbackTask.bind(this), 112 | this.onBeforeRollbackExecuteTask.bind(this), 113 | this.populatePenultimateReleaseNameTask.bind(this), 114 | this.renamePenultimateReleaseTask.bind(this), 115 | this.updateCurrentSymbolicLinkOnRemoteTask.bind(this), 116 | this.onAfterRollbackTask.bind(this), 117 | this.onAfterRollbackExecuteTask.bind(this), 118 | this.closeConnectionTask.bind(this), 119 | ], (err, result) => { 120 | // TODO: Consider calling this.closeConnectionTask() here to ensure it's closed even if an error occurs in the series 121 | // TODO: Handle the case where err isn't null 122 | done(); 123 | }); 124 | 125 | } 126 | 127 | 128 | /** 129 | * Remove release 130 | */ 131 | removeRelease(done) { 132 | done = done || this.noop; 133 | 134 | if (!this.options.allowRemove) { 135 | const message = 'Removing is not allowed on this environment. Aborting…'; 136 | console.warn(message); 137 | done(message); 138 | return; 139 | } 140 | 141 | 142 | 143 | async.series([ 144 | this.onBeforeConnectTask.bind(this), 145 | this.connectToRemoteTask.bind(this), 146 | this.removeReleaseTask.bind(this), 147 | this.closeConnectionTask.bind(this), 148 | ], function (err, result) { 149 | // TODO: Consider calling this.closeConnectionTask() here to ensure it's closed even if an error occurs in the series 150 | // TODO: Handle the case where err isn't null 151 | done(); 152 | }); 153 | } 154 | 155 | 156 | // ======================================================================================= 157 | 158 | /** 159 | * @param done 160 | */ 161 | onBeforeDeployTask(done) { 162 | this.middlewareCallbackExecute('onBeforeDeploy', done); 163 | } 164 | 165 | /** 166 | * @param done 167 | */ 168 | onBeforeDeployExecuteTask(done) { 169 | this.middlewareCallbackExecuteLegacy('onBeforeDeploy', done); 170 | } 171 | 172 | /** 173 | * 174 | * @param done 175 | */ 176 | compressReleaseTask(done) { 177 | 178 | if (this.options.mode != 'archive') { 179 | done(); 180 | return; 181 | } 182 | 183 | logger.subhead('Compress release'); 184 | let spinner = logger.startSpinner('Compressing'); 185 | 186 | let archiver = this.createArchiver( 187 | this.options.archiveType, 188 | this.options.archiveName, 189 | this.options.localPath, 190 | this.options.exclude 191 | ); 192 | 193 | archiver.compress( 194 | (fileSize) => { 195 | spinner.stop(); 196 | logger.ok('Archive created : ' + fileSize); 197 | done(); 198 | }, 199 | (err) => { 200 | spinner.stop(); 201 | logger.error('Error while compressing'); 202 | throw err; 203 | } 204 | ) 205 | } 206 | 207 | /** 208 | * Let power users create their own connection instance 209 | * @param {Function} done 210 | */ 211 | onBeforeConnectTask(done) { 212 | if (!this.options.onBeforeConnect) { 213 | done(); 214 | return; 215 | } 216 | 217 | if (typeof this.options.onBeforeConnect !== 'function') { 218 | console.error('options.onBeforeConnect must be a function. Please refer to the documentation.'); 219 | return; 220 | } 221 | 222 | this.logger.subhead('Open a custom connection'); 223 | const spinner = this.logger.startSpinner('Connecting'); 224 | 225 | this.remote = this.createRemote( 226 | this.options, 227 | this.logger, 228 | (command, error) => { 229 | this.logger.fatal('Connection error', command, error); 230 | } 231 | ); 232 | 233 | const connection = this.options.onBeforeConnect( 234 | this.context, 235 | // Ready 236 | () => { 237 | spinner.stop(); 238 | this.logger.ok('Connected'); 239 | done(); 240 | }, 241 | // Error 242 | (error) => { 243 | spinner.stop(); 244 | if (error) { 245 | this.logger.fatal(error); 246 | } 247 | }, 248 | // Close 249 | () => { 250 | this.logger.subhead("Custom connection closed"); 251 | } 252 | ); 253 | 254 | if (!connection) { 255 | this.logger.fatal('options.onBeforeConnect must return an ssh2 Client instance. Please refer to the documentation.'); 256 | } 257 | 258 | this.remote.connection = connection; 259 | } 260 | 261 | /** 262 | * 263 | * @param done 264 | */ 265 | connectToRemoteTask(done) { 266 | if (this.remote.connection) { 267 | this.logger.debug('Skipping this step as a custom connection is already open.'); 268 | done(); 269 | return; 270 | } 271 | 272 | this.logger.subhead('Connect to ' + this.options.host); 273 | const spinner = this.logger.startSpinner('Connecting'); 274 | 275 | this.remote = this.createRemote( 276 | this.options, 277 | this.logger, 278 | (command, error) => { 279 | this.logger.error('Connection error', command, error); 280 | 281 | // Clean up remote release + close connection 282 | // this.removeReleaseTask(this.closeConnectionTask(done)); 283 | } 284 | ); 285 | 286 | this.remote.connect( 287 | // Ready 288 | () => { 289 | spinner.stop(); 290 | this.logger.ok('Connected'); 291 | done(); 292 | }, 293 | // Error 294 | (error) => { 295 | spinner.stop(); 296 | if (error) { 297 | this.logger.fatal(error); 298 | } 299 | }, 300 | // Close 301 | () => { 302 | this.logger.subhead("Closed from " + this.options.host); 303 | } 304 | ); 305 | } 306 | 307 | /** 308 | * 309 | * @param done 310 | */ 311 | createReleaseFolderOnRemoteTask(done) { 312 | this.logger.subhead('Create release folder on remote'); 313 | this.logger.log(' - ' + this.release.path); 314 | 315 | this.remote.createFolder(this.release.path, () => { 316 | this.logger.ok('Done'); 317 | done(); 318 | }); 319 | } 320 | 321 | /** 322 | * 323 | * @param done 324 | */ 325 | uploadArchiveTask(done) { 326 | if (this.options.mode != 'archive') { 327 | done(); 328 | return; 329 | } 330 | 331 | this.logger.subhead('Upload archive to remote'); 332 | 333 | this.remote.upload( 334 | this.options.archiveName, 335 | `${this.release.path}/${this.options.archiveName}`, 336 | (error) => { 337 | if (error) { 338 | logger.fatal(error); 339 | } 340 | 341 | this.logger.ok('Done'); 342 | done(); 343 | } 344 | ); 345 | } 346 | 347 | /** 348 | * 349 | * @param done 350 | */ 351 | uploadReleaseTask(done) { 352 | 353 | if (this.options.mode != 'synchronize') { 354 | done(); 355 | return; 356 | } 357 | 358 | this.logger.subhead('Synchronize remote server'); 359 | const spinner = this.logger.startSpinner('Synchronizing'); 360 | 361 | this.remote.synchronize( 362 | this.options.localPath, 363 | this.release.path, 364 | this.options.deployPath + '/' + this.options.synchronizedFolder, 365 | () => { 366 | spinner.stop(); 367 | this.logger.ok('Done'); 368 | done(); 369 | } 370 | ); 371 | } 372 | 373 | /** 374 | * 375 | * @param done 376 | */ 377 | decompressArchiveOnRemoteTask(done) { 378 | if (this.options.mode != 'archive') { 379 | done(); 380 | return; 381 | } 382 | 383 | this.logger.subhead('Decompress archive on remote'); 384 | let spinner = this.logger.startSpinner('Decompressing'); 385 | 386 | const archivePath = path.posix.join(this.release.path, this.options.archiveName); 387 | const untarMap = { 388 | 'zip': "unzip -q " + archivePath + " -d " + this.release.path + "/", 389 | 'tar': "tar -xvf " + archivePath + " -C " + this.release.path + "/ --warning=no-timestamp", 390 | }; 391 | 392 | // Check archiveType is supported 393 | if (!untarMap[this.options.archiveType]) { 394 | logger.fatal(this.options.archiveType + ' not supported.'); 395 | } 396 | 397 | const commands = [ 398 | untarMap[this.options.archiveType], 399 | "rm " + archivePath, 400 | ]; 401 | async.eachSeries(commands, (command, itemDone) => { 402 | this.remote.exec(command, () => { 403 | itemDone(); 404 | }); 405 | }, () => { 406 | spinner.stop(); 407 | this.logger.ok('Done'); 408 | done(); 409 | }); 410 | } 411 | 412 | /** 413 | * 414 | * @param done 415 | */ 416 | onBeforeLinkTask(done) { 417 | this.middlewareCallbackExecute('onBeforeLink', done); 418 | } 419 | 420 | /** 421 | * 422 | * @param done 423 | */ 424 | onBeforeLinkExecuteTask(done) { 425 | this.middlewareCallbackExecuteLegacy('onBeforeLink', done); 426 | } 427 | 428 | /** 429 | * 430 | * @param done 431 | */ 432 | updateSharedSymbolicLinkOnRemoteTask(done) { 433 | 434 | if (!this.options.share || this.options.share.length == 0) { 435 | done(); 436 | return; 437 | } 438 | 439 | this.logger.subhead('Update shared symlink on remote'); 440 | 441 | async.eachSeries( 442 | Object.keys(this.options.share), 443 | (currentSharedFolder, itemDone) => { 444 | const configValue = this.options.share[currentSharedFolder]; 445 | let symlinkName = configValue; 446 | let mode = null; 447 | 448 | if ( 449 | typeof configValue == 'object' 450 | && 'symlink' in configValue 451 | ) { 452 | symlinkName = configValue.symlink; 453 | } 454 | 455 | if ( 456 | typeof configValue == 'object' 457 | && 'mode' in configValue 458 | ) { 459 | mode = configValue.mode; 460 | } 461 | 462 | const linkPath = this.release.path + '/' + symlinkName; 463 | const upwardPath = utils.getReversePath(symlinkName); 464 | const target = upwardPath + '/../' + this.options.sharedFolder + '/' + currentSharedFolder; 465 | 466 | this.logger.log(' - ' + symlinkName + ' ==> ' + currentSharedFolder); 467 | this.remote.createSymboliclink(target, linkPath, () => { 468 | if (!mode) { 469 | itemDone(); 470 | return; 471 | } 472 | 473 | this.logger.log(' chmod ' + mode); 474 | this.remote.chmod(linkPath, mode, () => { 475 | itemDone(); 476 | }); 477 | }); 478 | }, 479 | () => { 480 | this.logger.ok('Done'); 481 | done(); 482 | } 483 | ); 484 | } 485 | 486 | /** 487 | * 488 | * @param done 489 | */ 490 | createFolderTask(done) { 491 | 492 | if (!this.options.create || this.options.create.length == 0) { 493 | done(); 494 | return; 495 | } 496 | 497 | this.logger.subhead('Create folders on remote'); 498 | 499 | async.eachSeries( 500 | this.options.create, 501 | (currentFolderToCreate, itemDone) => { 502 | const path = this.release.path + '/' + currentFolderToCreate; 503 | this.logger.log(' - ' + currentFolderToCreate); 504 | 505 | this.remote.createFolder(path, itemDone); 506 | }, 507 | () => { 508 | this.logger.ok('Done'); 509 | done(); 510 | } 511 | ); 512 | } 513 | 514 | /** 515 | * 516 | * @param done 517 | */ 518 | makeDirectoriesWritableTask(done) { 519 | if (!this.options.makeWritable || this.options.makeWritable.length == 0) { 520 | done(); 521 | return; 522 | } 523 | 524 | this.logger.subhead('Make folders writable on remote'); 525 | 526 | async.eachSeries( 527 | this.options.makeWritable, 528 | (currentFolderToMakeWritable, itemDone) => { 529 | const path = this.release.path + '/' + currentFolderToMakeWritable; 530 | const mode = 'ugo+w'; 531 | 532 | this.logger.log(' - ' + currentFolderToMakeWritable); 533 | this.remote.chmod(path, mode, itemDone); 534 | }, 535 | () => { 536 | this.logger.ok('Done'); 537 | done(); 538 | } 539 | ); 540 | } 541 | 542 | /** 543 | * 544 | * @param done 545 | */ 546 | makeFilesExecutableTask(done) { 547 | if (!this.options.makeExecutable || this.options.makeExecutable.length == 0) { 548 | done(); 549 | return; 550 | } 551 | 552 | this.logger.subhead('Make files executables on remote'); 553 | 554 | async.eachSeries( 555 | this.options.makeExecutable, 556 | (currentFileToMakeExecutable, itemDone) => { 557 | const path = this.release.path + '/' + currentFileToMakeExecutable; 558 | const command = 'chmod ugo+x ' + path; 559 | 560 | this.logger.log(' - ' + currentFileToMakeExecutable); 561 | this.remote.exec(command, itemDone); 562 | }, () => { 563 | this.logger.ok('Done'); 564 | done(); 565 | }); 566 | } 567 | 568 | /** 569 | * 570 | * @param done 571 | */ 572 | updateCurrentSymbolicLinkOnRemoteTask(done) { 573 | this.logger.subhead('Update current release symlink on remote'); 574 | 575 | const target = path.posix.join(this.options.releasesFolder, this.release.tag); 576 | const currentPath = path.posix.join(this.options.deployPath, this.options.currentReleaseLink); 577 | 578 | this.remote.createSymboliclink( 579 | target, 580 | currentPath, 581 | () => { 582 | logger.ok('Done'); 583 | done(); 584 | } 585 | ); 586 | } 587 | 588 | /** 589 | * 590 | * @param done 591 | */ 592 | onAfterDeployTask(done) { 593 | this.middlewareCallbackExecute('onAfterDeploy', done); 594 | } 595 | 596 | /** 597 | * 598 | * @param done 599 | */ 600 | onAfterDeployExecuteTask(done) { 601 | this.middlewareCallbackExecuteLegacy('onAfterDeploy', done); 602 | } 603 | 604 | /** 605 | * 606 | * @param done 607 | */ 608 | remoteCleanupTask(done) { 609 | 610 | this.logger.subhead('Remove old builds on remote'); 611 | let spinner = this.logger.startSpinner('Removing'); 612 | 613 | if (this.options.releasesToKeep < 1) { 614 | this.options.releasesToKeep = 1; 615 | } 616 | 617 | const folder = path.posix.join( 618 | this.options.deployPath, 619 | this.options.releasesFolder 620 | ); 621 | 622 | this.remote.removeOldFolders( 623 | folder, 624 | this.options.releasesToKeep, 625 | () => { 626 | spinner.stop(); 627 | this.logger.ok('Done'); 628 | done(); 629 | } 630 | ) 631 | } 632 | 633 | /** 634 | * 635 | * @param done 636 | */ 637 | deleteLocalArchiveTask(done) { 638 | if (this.options.mode != 'archive' || !this.options.deleteLocalArchiveAfterDeployment) { 639 | done(); 640 | return; 641 | } 642 | 643 | logger.subhead('Delete local archive'); 644 | fs.unlinkSync(this.options.archiveName); 645 | logger.ok('Done'); 646 | done(); 647 | } 648 | 649 | /** 650 | * 651 | * @param done 652 | */ 653 | closeConnectionTask(done) { 654 | this.remote.close(done); 655 | } 656 | 657 | 658 | /** 659 | * Remove release on remote 660 | * @param done 661 | */ 662 | removeReleaseTask(done) { 663 | this.logger.subhead('Remove releases on remote'); 664 | 665 | const command = "rm -rf " + this.options.deployPath; 666 | this.remote.exec(command, () => { 667 | this.logger.ok('Done'); 668 | done(); 669 | }); 670 | } 671 | 672 | 673 | // ================================================================ 674 | 675 | middlewareCallbackExecuteLegacy(eventName, callback) { 676 | const legacyEventName = `${eventName}Execute`; 677 | if (this.options[legacyEventName]) { 678 | this.logger.warning(`[DEPRECATED] ${legacyEventName} is deprecated and may be removed in a future release. Please use ${eventName} instead.`) 679 | } 680 | this.middlewareCallbackExecute(legacyEventName, callback); 681 | } 682 | 683 | /** 684 | * Execute commandsFunction results 685 | * @param commandsFunction function | [] 686 | * @param callback 687 | */ 688 | middlewareCallbackExecute(eventName, callback) { 689 | let commands = this.options[eventName]; 690 | if (!commands) { 691 | callback(); 692 | return; 693 | } 694 | 695 | 696 | // If commands is a function, it can be: 697 | // * a custom callback that returns a promise 698 | // * a legacy custom callback that calls `callback` and returns nothing 699 | // * a function that returns commands to execute 700 | if (typeof commands === 'function') { 701 | let commandsFunctionReturnValue = commands(this.context, callback); 702 | 703 | // Promise 704 | if (commandsFunctionReturnValue instanceof Promise) { 705 | commandsFunctionReturnValue.then(() => { 706 | callback(); 707 | }).catch(reason => { 708 | callback(`The user-supplied ${eventName} callback returned an error: ${reason}`); 709 | }); 710 | 711 | return; 712 | } 713 | 714 | // Legacy custom callback 715 | if (commandsFunctionReturnValue === undefined) { 716 | this.logger.warning(`[DEPRECATED] ${eventName} – Node-style callback are deprecated and will be removed in a future release. Please return a Promise and call its resolve() method when you used to call the done() callback.`) 717 | // No need to call callback here, the user-supplied function must have called it 718 | return; 719 | } 720 | 721 | commands = commandsFunctionReturnValue; 722 | } 723 | 724 | // Support single command as a string 725 | if (typeof commands === 'string') { 726 | commands = [commands]; 727 | } 728 | 729 | // Nothing to execute 730 | if (!commands || commands.length == 0) { 731 | callback(); 732 | return; 733 | } 734 | 735 | // Execute each command 736 | async.eachSeries(commands, (command, innerCallback) => { 737 | this.logger.subhead('Execute on remote : ' + command); 738 | this.remote.exec(command, innerCallback, true); 739 | }, () => { 740 | this.logger.ok('Done'); 741 | callback(); 742 | }); 743 | } 744 | 745 | 746 | /** 747 | * Archiver factory 748 | * @param archiveType 749 | * @param archiveName 750 | * @param localPath 751 | * @param exclude 752 | * @returns {Archiver} 753 | */ 754 | createArchiver(archiveType, archiveName, localPath, exclude) { 755 | return new Archiver( 756 | archiveType, 757 | archiveName, 758 | localPath, 759 | exclude 760 | ); 761 | } 762 | ; 763 | 764 | 765 | /** 766 | * Remote factory 767 | */ 768 | createRemote(options, logger, onError) { 769 | return new Remote(options, logger, onError); 770 | } 771 | 772 | 773 | /** 774 | * @param {Function} done 775 | */ 776 | onBeforeRollbackTask(done) { 777 | this.middlewareCallbackExecute('onBeforeRollback', done); 778 | } 779 | 780 | /** 781 | * @param {Function} done 782 | */ 783 | onBeforeRollbackExecuteTask(done) { 784 | this.middlewareCallbackExecuteLegacy('onBeforeRollback', done); 785 | } 786 | 787 | /** 788 | * @param {Function} done 789 | */ 790 | populatePenultimateReleaseNameTask(done) { 791 | this.logger.subhead('Get previous release path'); 792 | 793 | this.remote.getPenultimateRelease() 794 | .then(penultimateReleasePath => { 795 | penultimateReleasePath = penultimateReleasePath.trim().replace(new RegExp(`^${this.options.deployPath}/?${this.options.releasesFolder}/?`), ''); 796 | this.logger.ok(penultimateReleasePath); 797 | this.penultimateReleaseName = penultimateReleasePath; 798 | done(); 799 | }) 800 | .catch(err => { 801 | done(err); 802 | }); 803 | } 804 | 805 | /** 806 | * @param {Function} done 807 | */ 808 | renamePenultimateReleaseTask(done) { 809 | this.logger.subhead('Rename previous release'); 810 | 811 | const releasesPath = path.posix.join(this.options.deployPath, this.options.releasesFolder); 812 | const newPreviousReleaseName = `${this.release.tag}_rollback-to_${this.penultimateReleaseName}`; 813 | 814 | this.remote.exec( 815 | [ 816 | 'mv', 817 | path.posix.join(releasesPath, this.penultimateReleaseName), 818 | path.posix.join(releasesPath, newPreviousReleaseName) 819 | ].join(' '), 820 | (err, exitCode, exitSignal, stdout, stderr) => { 821 | if (err) { 822 | done(err); 823 | } 824 | 825 | this.logger.ok(`Renamed to ${newPreviousReleaseName}`); 826 | this.release.tag = newPreviousReleaseName; 827 | done(); 828 | } 829 | ); 830 | } 831 | 832 | /** 833 | * @param {Function} done 834 | */ 835 | onAfterRollbackTask(done) { 836 | this.middlewareCallbackExecute('onAfterRollback', done); 837 | } 838 | 839 | /** 840 | * @param {Function} done 841 | */ 842 | onAfterRollbackExecuteTask(done) { 843 | this.middlewareCallbackExecuteLegacy('onAfterRollback', done); 844 | } 845 | }; 846 | --------------------------------------------------------------------------------