├── .gitignore ├── Gemfile ├── LICENCE ├── Readme.md ├── init.rb ├── lib └── heroku │ ├── command │ └── deploy.rb │ └── deploy │ ├── app.rb │ ├── deployer.rb │ ├── diff.rb │ ├── runner.rb │ ├── shell.rb │ ├── strategies │ ├── base.rb │ ├── delta.rb │ └── setup.rb │ ├── tasks │ ├── base.rb │ ├── commit_assets.rb │ ├── compile_assets.rb │ ├── database_migrate.rb │ ├── prepare_production_branch.rb │ ├── push_to_heroku.rb │ ├── push_to_origin.rb │ ├── safe_migration.rb │ ├── stash_git_changes.rb │ └── unsafe_migration.rb │ ├── ui.rb │ └── ui │ ├── colors.rb │ └── spinner.rb └── spec ├── heroku └── deploy │ └── diff_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | InstalledFiles 7 | lib/bundler/man 8 | pkg 9 | rdoc 10 | spec/reports 11 | test/tmp 12 | test/version_tmp 13 | tmp 14 | Gemfile.lock 15 | 16 | # YARD artifacts 17 | .yardoc 18 | _yardoc 19 | doc/ 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'heroku' 4 | gem 'rspec' 5 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Envato & Keith Pitt. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # heroku-deploy 2 | 3 | It's better than `git push` 4 | 5 | ## Introduction 6 | 7 | TODO 8 | 9 | I haven't written the introduction and proper usage stuff yet, sorry. I was roped into doing a presentation at Rails Meetup at the last minute, and I open sourced this thing quickly. So if you want to know more, poke @keithpitt on Twitter and Github! 10 | 11 | ## Installation and Usage 12 | 13 | You need the heroku-cli installed before you can use this plugin. 14 | 15 | ```bash 16 | heroku plugins:install git://github.com/envato/heroku-deploy.git 17 | heroku deploy 18 | ``` 19 | 20 | Because we don't do asset compliation on Heroku anymore, we can easily remove the 21 | asset group from being bundled, giving us a nice speed boost. 22 | 23 | ```bash 24 | heroku config:add BUNDLE_WITHOUT="development:test:assets" 25 | ``` 26 | 27 | 28 | ## Migrations 29 | 30 | By default any safe migrations will run without any downtime. So adding a new 31 | column/table/index. If a migration file contains an unsafe keyword 32 | `remove_column`, `execute` as examples, a maintenance page will be used for a 33 | downtime deploy. If you would like to prevent this behavior, then you can add a 34 | `# safe` comment on the destructive line. This allows a zero downtime deploy 35 | even with destructive operations. 36 | 37 | ## Development 38 | 39 | ```bash 40 | git clone git@github.com:envato/heroku-deploy.git 41 | 42 | mkdir -p ~/.heroku/plugins 43 | cd ~/.heroku/plugins 44 | ln -s ~/path/to/heroku-deploy heroku-deploy 45 | ``` 46 | 47 | This should allow you to test the plugin and use it locally. 48 | 49 | 50 | ## Testing 51 | 52 | The tests can be run with `rspec spec` 53 | 54 | ## Contributing 55 | 56 | We encourage all community contributions. Keeping this in mind, please follow these general guidelines when contributing: 57 | 58 | * Fork the project 59 | * Create a topic branch for what you’re working on (git checkout -b awesome_feature) 60 | * Commit away, push that up (git push your\_remote awesome\_feature) 61 | * Create a new GitHub Issue with the commit, asking for review. Alternatively, send a pull request with details of what you added. 62 | 63 | ## License 64 | 65 | heroku-deploy is released under the MIT License (see the [license file](https://github.com/envato/heroku-deploy/blob/master/LICENCE)) and is copyright Envato & Keith Pitt, 2013. 66 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'heroku/command/deploy' 2 | require 'heroku/deploy/deployer' 3 | -------------------------------------------------------------------------------- /lib/heroku/command/deploy.rb: -------------------------------------------------------------------------------- 1 | require 'heroku/command/run' 2 | 3 | class Heroku::Command::Deploy < Heroku::Command::Run 4 | # deploy 5 | # 6 | # deploy your code 7 | def deploy 8 | Heroku::Deploy::Deployer.deploy Heroku::Deploy::App.new(api, app) 9 | end 10 | alias_command 'deploy', 'deploy:deploy' 11 | end 12 | -------------------------------------------------------------------------------- /lib/heroku/deploy/app.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | class App 3 | attr_accessor :api, :name 4 | 5 | def initialize(api, name) 6 | @api = api 7 | @name = name 8 | end 9 | 10 | def host 11 | data['domain_name']['domain'] 12 | end 13 | 14 | def git_url 15 | data['git_url'] 16 | end 17 | 18 | def env 19 | @env ||= unless @env 20 | vars = api.get_config_vars(name).body 21 | vars.reject { |key, value| key == 'PATH' || key == 'GEM_PATH' } 22 | end 23 | end 24 | 25 | def put_config_vars(vars) 26 | api.put_config_vars name, vars 27 | end 28 | 29 | def feature_enabled?(feature) 30 | all_features = api.get_features(name).body 31 | 32 | found_feature = all_features.find { |f| f['name'].to_s == feature.to_s } 33 | raise "Could not find feature `#{feature}`" unless found_feature 34 | 35 | found_feature['enabled'] 36 | end 37 | 38 | def disable_maintenance 39 | post_app_maintenance '0' 40 | end 41 | 42 | def enable_maintenance 43 | post_app_maintenance '1' 44 | end 45 | 46 | def enable_feature(feature) 47 | api.post_feature feature, name 48 | end 49 | 50 | def disable_feature(feature) 51 | api.delete_feature feature, name 52 | end 53 | 54 | private 55 | 56 | def post_app_maintenance(action) 57 | api.post_app_maintenance name, action 58 | end 59 | 60 | def data 61 | @data ||= api.get_app(name).body 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/heroku/deploy/deployer.rb: -------------------------------------------------------------------------------- 1 | require "heroku/deploy/app" 2 | require "heroku/deploy/ui" 3 | require "heroku/deploy/shell" 4 | require "heroku/deploy/runner" 5 | require "heroku/deploy/diff" 6 | 7 | require "heroku/deploy/tasks/base" 8 | require "heroku/deploy/tasks/stash_git_changes" 9 | require "heroku/deploy/tasks/prepare_production_branch" 10 | require "heroku/deploy/tasks/compile_assets" 11 | require "heroku/deploy/tasks/commit_assets" 12 | require "heroku/deploy/tasks/push_to_origin" 13 | require "heroku/deploy/tasks/safe_migration" 14 | require "heroku/deploy/tasks/database_migrate" 15 | require "heroku/deploy/tasks/push_to_heroku" 16 | require "heroku/deploy/tasks/unsafe_migration" 17 | 18 | require "heroku/deploy/strategies/base" 19 | require "heroku/deploy/strategies/delta" 20 | require "heroku/deploy/strategies/setup" 21 | 22 | module Heroku::Deploy 23 | class Deployer 24 | include Shell 25 | 26 | def self.deploy(app) 27 | new(app).deploy 28 | end 29 | 30 | attr_accessor :app, :git_url, :new_commit, :deployed_commit 31 | 32 | def initialize(app) 33 | @app = app 34 | end 35 | 36 | def branch 37 | "heroku-deploy/#{app.name}" 38 | end 39 | 40 | def deploy 41 | banner <<-OUT 42 | _ _ _ 43 | __| | ___ _ __ | | ___ _ _(_)_ __ __ _ 44 | / _` |/ _ \\ '_ \\| |/ _ \\| | | | | '_ \\ / _` | 45 | | (_| | __/ |_) | | (_) | |_| | | | | | (_| | 46 | \\__,_|\\___| .__/|_|\\___/ \\__, |_|_| |_|\\__, | 47 | |_| |___/ |___/ 48 | OUT 49 | 50 | task "Gathering information about the deploy" do 51 | self.git_url = app.git_url 52 | self.new_commit = git %{rev-parse --verify HEAD} 53 | end 54 | 55 | task "Looking at what is currently on #{colorize git_url, :cyan}" do 56 | ls_remote = git %{ls-remote #{git_url}} 57 | result = ls_remote.match(/^(.+)refs\/heads\/master/) 58 | 59 | self.deployed_commit = result[1].chomp.strip 60 | end 61 | 62 | if deployed_commit && !deployed_commit.empty? 63 | Strategy::Delta.perform self 64 | else 65 | Strategy::Setup.perform self 66 | end 67 | 68 | finish "Finished! Thanks for playing." 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/heroku/deploy/diff.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | class Diff 3 | include Shell 4 | 5 | def self.diff(*args) 6 | new(*args) 7 | end 8 | 9 | attr_accessor :from, :to 10 | 11 | def initialize(from, to) 12 | @from = from 13 | @to = to 14 | end 15 | 16 | def diff(folders) 17 | git %{diff #{from}..#{to} #{folders.join " "}} 18 | end 19 | 20 | def has_asset_changes? 21 | folders_that_could_have_changes = %w(app/assets lib/assets vendor/assets Gemfile.lock) 22 | folders_that_exist = folders_that_could_have_changes.select { |folder| File.exist?(folder) } 23 | 24 | diff(folders_that_exist).match /diff/ 25 | end 26 | 27 | def has_migrations? 28 | migrations_diff.match /ActiveRecord::Migration/ 29 | end 30 | 31 | def has_unsafe_migrations? 32 | migrations_diff.split("\n").any? do |line| 33 | has_unsafe_keyword?(line) && has_no_safe_override?(line) 34 | end 35 | end 36 | 37 | private 38 | 39 | def has_unsafe_keyword?(line) 40 | line.match(unsafe_migration_regexp) 41 | end 42 | 43 | def has_no_safe_override?(line) 44 | !line.match(safe_override_regexp) 45 | end 46 | 47 | def unsafe_migration_regexp 48 | /change_column|change_table|drop_table|remove_column|remove_index|rename_column|execute|rename_table/ 49 | end 50 | 51 | def safe_override_regexp 52 | /#\s*safe/ 53 | end 54 | 55 | def migrations_diff 56 | diff %w(db/migrate) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/heroku/deploy/runner.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | class Runner 3 | include Heroku::Deploy::Shell 4 | 5 | attr_accessor :tasks 6 | 7 | def initialize(tasks = []) 8 | @tasks = tasks 9 | end 10 | 11 | def perform_methods(*methods) 12 | performed_tasks = [] 13 | current_task = nil 14 | 15 | begin 16 | methods.each do |method| 17 | tasks.each do |task| 18 | task = task.call if task.kind_of?(Proc) 19 | 20 | if task 21 | current_task = task 22 | performed_tasks << [ current_task, method ] 23 | current_task.public_send method 24 | end 25 | end 26 | end 27 | rescue => e 28 | warning e.message 29 | warning "#{current_task.class.name} failed. Rolling back" 30 | 31 | performed_tasks.reverse.each do |task, method| 32 | task.public_send "rollback_#{method.to_s}" 33 | end 34 | 35 | raise e 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/heroku/deploy/shell.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | module Shell 3 | class CommandFailed < StandardError; end 4 | 5 | include UI 6 | 7 | def git(cmd, options = {}) 8 | shell "git #{cmd}", options 9 | end 10 | 11 | def shell(cmd, options = {}) 12 | original_cmd = cmd 13 | 14 | # Ensure all output is written to the same place 15 | cmd = "#{cmd} 2>&1" 16 | 17 | if env = options[:env] 18 | exports = env.keys.map { |key| "#{key}=#{env[key].inspect}" } 19 | cmd = "#{exports.join " "} #{cmd}" 20 | end 21 | 22 | puts "$ #{cmd}" if ENV['DEBUG'] 23 | 24 | if options[:exec] 25 | success = system cmd 26 | 27 | raise CommandFailed.new("`#{original_cmd}` failed") unless success 28 | else 29 | output = `#{cmd}` 30 | exit_status = $?.to_i 31 | 32 | if exit_status.to_i > 0 33 | raise CommandFailed.new("`#{original_cmd}` return an exit status of #{exit_status}\n\n#{output}") 34 | end 35 | 36 | # Ensure the string is valid utf8 37 | cleaned_output = output.to_s.chomp.strip.force_encoding("ISO-8859-1").encode("utf-8", :replace => nil) 38 | 39 | puts cleaned_output if ENV['DEBUG'] 40 | 41 | cleaned_output 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/heroku/deploy/strategies/base.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | module Strategy 3 | class Base 4 | include Heroku::Deploy::UI 5 | 6 | def self.perform(deployer) 7 | new(deployer).perform 8 | end 9 | 10 | attr_accessor :deployer 11 | 12 | def initialize(deployer) 13 | @deployer = deployer 14 | end 15 | 16 | def new_commit 17 | deployer.new_commit 18 | end 19 | 20 | def deployed_commit 21 | deployer.deployed_commit 22 | end 23 | 24 | def app 25 | deployer.app 26 | end 27 | 28 | def branch 29 | deployer.branch 30 | end 31 | 32 | def runner 33 | @runner ||= Heroku::Deploy::Runner.new 34 | end 35 | 36 | def perform 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/heroku/deploy/strategies/delta.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Strategy 2 | class Delta < Base 3 | include Heroku::Deploy::Task 4 | 5 | def diff 6 | @diff ||= unless @diff 7 | difference = "#{chop_sha deployed_commit}..#{chop_sha new_commit}" 8 | task "Performing diff on #{colorize difference, :cyan}" do 9 | Heroku::Deploy::Diff.diff deployed_commit, new_commit 10 | end 11 | end 12 | end 13 | 14 | def perform 15 | strategy = self 16 | 17 | tasks = [ 18 | StashGitChanges.new(self), 19 | PrepareProductionBranch.new(self) 20 | ] 21 | 22 | tasks << Proc.new do 23 | if strategy.diff.has_asset_changes? 24 | CompileAssets.new(strategy) 25 | end 26 | end 27 | 28 | tasks << Proc.new do 29 | if strategy.diff.has_asset_changes? 30 | CommitAssets.new(strategy) 31 | end 32 | end 33 | 34 | tasks << PushToOrigin.new(self) 35 | 36 | tasks << Proc.new do 37 | if strategy.diff.has_unsafe_migrations? 38 | UnsafeMigration.new(self) 39 | elsif strategy.diff.has_migrations? 40 | SafeMigration.new(self) 41 | end 42 | end 43 | 44 | tasks << PushToHeroku.new(self) 45 | 46 | runner.tasks = tasks 47 | runner.perform_methods :before_deploy, :deploy, :after_deploy 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/heroku/deploy/strategies/setup.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Strategy 2 | class Setup < Base 3 | include Heroku::Deploy::Task 4 | 5 | def perform 6 | task "This looks like a first time deploy to Heroku" 7 | 8 | runner.tasks = [ 9 | StashGitChanges.new(self), 10 | PrepareProductionBranch.new(self), 11 | CompileAssets.new(self), 12 | CommitAssets.new(self), 13 | PushToOrigin.new(self), 14 | UnsafeMigration.new(self), 15 | PushToHeroku.new(self) 16 | ] 17 | 18 | runner.perform_methods :before_deploy, :deploy, :after_deploy 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/base.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class Base 3 | attr_accessor :strategy, :app 4 | 5 | def initialize(strategy) 6 | @strategy = strategy 7 | @app = strategy.app 8 | end 9 | 10 | def rollback_before_deploy; end 11 | def before_deploy; end 12 | 13 | def deploy; end 14 | def rollback_deploy; end 15 | 16 | def rollback_after_deploy; end 17 | def after_deploy; end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/commit_assets.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class CommitAssets < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def before_deploy 6 | assets_folder = "public/assets" 7 | 8 | has_changes = false 9 | task "Checking to see if there are any changes in #{colorize assets_folder, :cyan}" do 10 | changes = git %{status #{assets_folder} --porcelain} 11 | has_changes = !changes.empty? 12 | end 13 | 14 | if has_changes 15 | task "Commiting #{colorize assets_folder, :cyan} for deployment" do 16 | git %{add #{assets_folder}} 17 | git %{commit #{assets_folder} -m "[heroku-deploy] Compiled assets for deployment"} 18 | 19 | @deployment_commit = git 'rev-parse --verify HEAD' 20 | end 21 | end 22 | end 23 | 24 | def rollback_before_deploy 25 | # Revert back to the commit before we compiled assets 26 | if @deployment_commit 27 | git %{reset #{@deployment_commit}~1 --hard} 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/compile_assets.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class CompileAssets < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def before_deploy 6 | # TODO: Recomend that this is off? 7 | # initialize_on_precompile = shell %{bundle exec rails runner "puts Rails.application.config.assets.initialize_on_precompile"} 8 | 9 | env_vars = app.env.dup 10 | env_vars['RAILS_ENV'] = 'production' 11 | env_vars['RAILS_GROUPS'] = 'assets' 12 | env_vars.delete 'BUNDLE_WITHOUT' 13 | 14 | task "Precompiling assets" 15 | shell "bundle exec rake assets:precompile", :env => env_vars, :exec => true 16 | end 17 | 18 | def rollback_before_deploy 19 | task "Cleaning directory" do 20 | git "clean -fd" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/database_migrate.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class DatabaseMigrate < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def self.migrate(strategy) 6 | new(strategy).migrate 7 | end 8 | 9 | def migrate 10 | env_vars = app.env.dup 11 | env_vars['RAILS_ENV'] = 'production' 12 | 13 | task "Migrating the database remotely" 14 | shell "bundle exec rake db:migrate", :env => env_vars, :exec => true 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/prepare_production_branch.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class PrepareProductionBranch < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def before_deploy 6 | @previous_branch = git "rev-parse --abbrev-ref HEAD" 7 | 8 | # If HEAD is returned, it means we're on a random commit, instead 9 | # of a branch. 10 | if @previous_branch == "HEAD" 11 | @previous_branch = git "rev-parse --verify HEAD" 12 | end 13 | 14 | # Always fetch first. The repo may have already been created. 15 | # Also, unshallow the repo with the crazy --depth thing. See 16 | # http://permalink.gmane.org/gmane.comp.version-control.git/213186 17 | task "Fetching from #{colorize "origin", :cyan}" 18 | shell "(test -e .git/shallow) && rm .git/shallow" rescue CommandFailed # Command failed is raised if the file doesn't exist. 19 | git "fetch origin --depth=2147483647 -v", :exec => true 20 | 21 | task "Switching to #{colorize strategy.branch, :cyan}" do 22 | local_branches = git "branch" 23 | 24 | if local_branches.match /#{strategy.branch}$/ 25 | git "checkout #{strategy.branch}" 26 | else 27 | git "checkout -b #{strategy.branch}" 28 | end 29 | 30 | # reset to whats on origin if the branch exists there already 31 | remote_branches = git "branch -a" 32 | 33 | if remote_branches.match /origin\/#{strategy.branch}$/ 34 | git "reset origin/#{strategy.branch} --hard" 35 | end 36 | end 37 | 38 | task "Merging your current branch #{colorize @previous_branch, :cyan} into #{colorize strategy.branch, :cyan}" do 39 | git "merge #{strategy.new_commit}" 40 | end 41 | end 42 | 43 | def after_deploy 44 | switch_back_to_old_branch 45 | end 46 | 47 | def rollback_before_deploy 48 | switch_back_to_old_branch 49 | end 50 | 51 | private 52 | 53 | def switch_back_to_old_branch 54 | task "Switching back to #{colorize @previous_branch, :cyan}" do 55 | git "checkout #{@previous_branch}" 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/push_to_heroku.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class PushToHeroku < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def deploy 6 | git_url = app.git_url 7 | 8 | task "Pushing #{colorize strategy.branch, :cyan} to #{colorize "#{git_url}:master", :cyan}" 9 | git "push -f #{git_url} #{strategy.branch}:master -v", :exec => true 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/push_to_origin.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class PushToOrigin < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def deploy 6 | task "Pushing local #{colorize strategy.branch, :cyan} to #{colorize "origin", :cyan}" 7 | git "push -u origin #{strategy.branch} -v", :exec => true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/safe_migration.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class SafeMigration < Base 3 | def before_deploy 4 | DatabaseMigrate.migrate(strategy) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/stash_git_changes.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class StashGitChanges < Base 3 | include Heroku::Deploy::Shell 4 | 5 | def before_deploy 6 | output = git "status --untracked-files --short" 7 | 8 | unless output.empty? 9 | @stash_name = "heroku-deploy-#{Time.now.to_i}" 10 | task "Stashing your current changes" do 11 | git "stash save -u #{@stash_name}" 12 | end 13 | end 14 | end 15 | 16 | def rollback_before_deploy 17 | after_deploy 18 | end 19 | 20 | def after_deploy 21 | return unless @stash_name 22 | 23 | task "Applying back your local changes" do 24 | stashes = git 'stash list' 25 | matched_stash = stashes.split("\n").find { |x| x.match @stash_name } 26 | label = matched_stash.match(/^([^:]+)/) 27 | 28 | # Make sure there are no weird local changes (think db/schema.db changing 29 | # because we ran migrations locally, and column order changing because postgres 30 | # is crazy like that) 31 | git "clean -fd" 32 | git "stash apply #{label}" 33 | git "stash drop #{label}" 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/heroku/deploy/tasks/unsafe_migration.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy::Task 2 | class UnsafeMigration < Base 3 | include Heroku::Deploy::UI 4 | 5 | def before_deploy 6 | task "Turn off preboot if its enabled" do 7 | @preboot = app.feature_enabled? :preboot 8 | 9 | if @preboot 10 | app.disable_feature :preboot 11 | end 12 | end 13 | 14 | task "Turning on maintenance mode" do 15 | app.enable_maintenance 16 | end 17 | 18 | DatabaseMigrate.migrate(strategy) 19 | end 20 | 21 | def rollback_before_deploy 22 | after_deploy 23 | end 24 | 25 | def after_deploy 26 | task "Turning off maintenance mode" do 27 | app.disable_maintenance 28 | end 29 | 30 | if @preboot 31 | task "Enabling preboot again" do 32 | app.enable_feature :preboot 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/heroku/deploy/ui.rb: -------------------------------------------------------------------------------- 1 | require "heroku/deploy/ui/colors" 2 | require "heroku/deploy/ui/spinner" 3 | 4 | module Heroku::Deploy 5 | module UI 6 | include Colors 7 | extend Colors 8 | 9 | PREFIX = colorize("--> ", :yellow) 10 | 11 | def task(message, options = {}) 12 | print "#{PREFIX}#{message}" 13 | return_value = nil 14 | 15 | if block_given? 16 | print "... " 17 | spinner = Heroku::Deploy::UI::Spinner.new 18 | spinner.start 19 | 20 | begin 21 | return_value = yield 22 | rescue => e 23 | spinner.stop 24 | print colorize(icon(:cross), :red) + "\n" 25 | raise e 26 | end 27 | 28 | spinner.stop 29 | print colorize(icon(:tick), :green) 30 | end 31 | 32 | print "\n" 33 | 34 | return_value 35 | end 36 | 37 | def finish(message) 38 | puts "#{PREFIX}#{colorize message, :green} #{emoji :smile}" 39 | exit 0 40 | end 41 | 42 | def warning(message) 43 | puts "#{PREFIX}#{colorize message, :red}" 44 | end 45 | 46 | def error(message) 47 | print_and_colorize message, :red 48 | end 49 | 50 | def fatal(message) 51 | error(message) 52 | exit 1 53 | end 54 | 55 | def info(message) 56 | print_and_colorize message, :cyan 57 | end 58 | 59 | def ok(message) 60 | print_and_colorize message, :green 61 | end 62 | 63 | def banner(message) 64 | print_and_colorize message, :magenta 65 | end 66 | 67 | private 68 | 69 | def chop_sha(sha) 70 | if sha 71 | sha[0..7] 72 | else 73 | "" 74 | end 75 | end 76 | 77 | def print_and_colorize(message, color) 78 | puts colorize(message, color) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/heroku/deploy/ui/colors.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | module UI 3 | module Colors 4 | COLORS = { 5 | :red => 31, 6 | :green => 32, 7 | :yellow => 33, 8 | :magenta => 35, 9 | :cyan => 36, 10 | } 11 | 12 | EMOJI = { 13 | :smile => "\u{1f60a}" 14 | } 15 | 16 | ICONS = { 17 | :tick => "\u{2714}", 18 | :cross => "\u{2718}", 19 | } 20 | 21 | def colorize(message, color) 22 | "\033[#{COLORS[color].to_s}m#{message}\033[0m" 23 | end 24 | 25 | def emoji(name) 26 | EMOJI[name] 27 | end 28 | 29 | def icon(name) 30 | ICONS[name] 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/heroku/deploy/ui/spinner.rb: -------------------------------------------------------------------------------- 1 | module Heroku::Deploy 2 | module UI 3 | class Spinner 4 | include Colors 5 | 6 | def initialize 7 | @spinner = nil 8 | @count = 0 9 | @chars = [ '|', '/', '-', '\\' ].map { |c| colorize(c, :yellow) } 10 | end 11 | 12 | def start 13 | @spinner = Thread.new do 14 | loop do 15 | print @chars[0] 16 | @count += 1 17 | sleep(0.1) 18 | print "\b" 19 | @chars.push @chars.shift 20 | end 21 | end 22 | end 23 | 24 | # stops the spinner and backspaces over last displayed character 25 | def stop 26 | @spinner.kill 27 | if @count > 0 28 | print "\b" 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/heroku/deploy/diff_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Heroku::Deploy::Shell 4 | def git(*args) 5 | $diff_content 6 | end 7 | end 8 | 9 | describe Heroku::Deploy::Diff do 10 | let(:diff) { Heroku::Deploy::Diff.new(from, to) } 11 | let(:from) { double } 12 | let(:to) { double } 13 | 14 | describe '#has_unsafe_migrations?' do 15 | context 'when there are only safe migrations' do 16 | before { $diff_content = 'add_table add_column' } 17 | 18 | it 'returns false' do 19 | expect(diff).to_not have_unsafe_migrations 20 | end 21 | end 22 | 23 | context 'when there are unsafe migrations' do 24 | before { $diff_content = 'remove_column' } 25 | 26 | it 'returns true' do 27 | expect(diff).to have_unsafe_migrations 28 | end 29 | end 30 | 31 | context 'when there are unsafe migrations that are explicidly marked as safe' do 32 | 33 | it 'returns false' do 34 | $diff_content = 'remove_column #safe' 35 | expect(diff).to_not have_unsafe_migrations 36 | 37 | $diff_content = 'remove_column # safe' 38 | expect(diff).to_not have_unsafe_migrations 39 | end 40 | end 41 | 42 | context 'when only some of the unsafe migrations are marked' do 43 | before { $diff_content = "remove_column :foo #safe\nremove_column :blah" } 44 | 45 | it 'returns true' do 46 | expect(diff).to have_unsafe_migrations 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require_relative '../init' 2 | 3 | RSpec.configure do |config| 4 | config.run_all_when_everything_filtered = true 5 | config.filter_run :focus 6 | 7 | config.order = 'random' 8 | end 9 | --------------------------------------------------------------------------------