lib/hu/deploy.rb in hu-1.6.5 vs lib/hu/deploy.rb in hu-2.0.0

- old
+ new

@@ -5,16 +5,31 @@ class Cli < Optix::Cli class Deploy < Optix::Cli ::TTY::Formats::FORMATS[:hu] = { frames: '🌑🌒🌓🌔🌕🌖🌗🌘'.chars, interval: 10 } ::TTY::Formats::FORMATS[:huroku] = { frames: '⣷⣯⣟⡿⢿⣻⣽⣾'.chars, interval: 10 } + class SigQuit < StandardError; end + RELEASE_TYPE_HINT = { 'patch' => 'only bugfixes', 'minor' => 'fully backwards compatible', 'major' => 'not backwards compatible' } + NUMBERS = { + 0 => 'Zero', + 1 => 'One', + 2 => 'Two', + 3 => 'Three', + 4 => 'Four', + 5 => 'Five', + 6 => 'Six', + 7 => 'Seven', + 8 => 'Eight', + 9 => 'Nine' + } + $stdout.sync @@shutting_down = false @@spinner = nil @@home_branch = nil @@ -25,52 +40,61 @@ if Hu::API_TOKEN.nil? text '' text "\e[1mWARNING: Environment variable 'HEROKU_API_KEY' must be set.\e[0m" end filter do - if Hu::API_TOKEN.nil? - STDERR.puts "\e[0;31;1mERROR: Environment variable 'HEROKU_API_KEY' must be set.\e[0m" + begin + if Hu::API_TOKEN.nil? + STDERR.puts "\e[0;31;1mERROR: Environment variable 'HEROKU_API_KEY' must be set.\e[0m" + exit 1 + end + require 'tty-cursor' + print TTY::Cursor.hide + "\e[30;1ms" + require 'rainbow' + print 'y' + require 'rainbow/ext/string' + print 'n' + require 'platform-api' + print 'c' + require 'version_sorter' + print 'h' + require 'versionomy' + print 'r' + require 'tty-prompt' + print 'o' + require 'tty-table' + require 'octokit' + print 'n' + require 'open3' + require 'fidget' + print 'i' + require 'json' + require 'awesome_print' + print 'z' + require 'chronic_duration' + require 'tempfile' + print 'i' + require 'thread_safe' + require 'io/console' + print 'n' + require 'rugged' + require 'pty' + print 'g' + require 'thread' + require 'paint' + require 'lolcat/lol' + require 'io/console' + rescue Interrupt + puts "\e[0m*** Abort (SIGINT)" + puts TTY::Cursor.show exit 1 end - require 'tty-cursor' - print TTY::Cursor.hide + "\e[30;1ms" - require 'rainbow' - print 'y' - require 'rainbow/ext/string' - print 'n' - require 'platform-api' - print 'c' - require 'version_sorter' - print 'h' - require 'versionomy' - print 'r' - require 'tty-prompt' - print 'o' - require 'tty-table' - print 'n' - require 'open3' - print 'i' - require 'json' - require 'awesome_print' - print 'z' - require 'chronic_duration' - require 'tempfile' - print 'i' - require 'thread_safe' - require 'io/console' - print 'n' - require 'rugged' - require 'pty' - print 'g' - require 'thread' - require 'paint' - require 'lolcat/lol' - require 'io/console' end def deploy(_cmd, _opts, _argv) trap('INT') { shutdown; puts "\e[0m\e[35;1m^C\e[0m"; exit 1 } + at_exit do if $!.class == SystemExit && 130 == $!.status puts "\n\n" end shutdown @@ -285,47 +309,48 @@ puts "\nThis is release " + release_tag.color(:green).bright end puts unless git_revisions[:release] == git_revisions[stag_app_name] || !release_branch_exists - puts ' Phase 1/3 '.inverse + ' The local release branch ' + "release/#{release_tag}".bright + ' was created.' + puts ' Phase 1/2 '.inverse + ' The local release branch ' + "release/#{release_tag}".bright + ' was created.' puts ' Nothing else has happened so far. Push this branch to' puts ' ' + stag_app_name.to_s.bright + ' to begin the deploy procedure.' puts end if release_branch_exists && git_revisions[:release] == git_revisions[stag_app_name] hyperlink = "\e]8;;#{app['web_url']}\007#{app['web_url']}\e]8;;\007" - puts ' Phase 2/3 '.inverse + ' Your local ' + "release/#{release_tag}".bright + ' (formerly ' + 'develop'.bright + ') is live on ' + stag_app_name.to_s.bright + '.' + puts ' Phase 2/2 '.inverse + ' Your local ' + "release/#{release_tag}".bright + ' (formerly ' + 'develop'.bright + ') is live on ' + stag_app_name.to_s.bright + '.' puts puts ' Please test here: ' + hyperlink.bright puts - puts ' If everything looks good you may proceed and finish the release.' + puts ' If everything looks good you may proceed and deploy to production.' puts ' If there are problems: Quit, delete the release branch and start fixing.' puts elsif git_revisions[prod_app_name] != git_revisions[stag_app_name] && !release_branch_exists && git_revisions[:release] != git_revisions[stag_app_name] hyperlink = "\e]8;;#{app['web_url']}\007#{app['web_url']}\e]8;;\007" - puts ' Phase 3/3 '.inverse + ' HEADS UP! This is the last chance to detect problems.' - puts ' The final version of ' + "release/#{release_tag}".bright + ' is now staged.' + puts ' DEPLOY '.inverse + ' HEADS UP! This is the last chance to detect problems.'.bright + puts ' The final version of ' + "release/#{release_tag}".bright + ' is staged.' puts - puts ' Test here: ' + hyperlink.bright - sleep 1 + puts ' Test here: ' + hyperlink.bright + sleep 0.1 puts - puts ' This is the exact version that will be promoted to production.' - puts " From here you are on your own. Good luck #{`whoami`.chomp}!" + puts ' This is the exact version that will be promoted to production.' + type " From here you are on your own. Good luck #{`whoami`.chomp}!" puts + puts end begin choice = prompt.select('>') do |menu| menu.enum '.' menu.choice 'Refresh', :refresh menu.choice 'Quit', :abort_ask unless git_revisions[:release] == git_revisions[stag_app_name] || !release_branch_exists - menu.choice "Push release/#{release_tag} to #{stag_app_name}", :push_to_staging + menu.choice "Push develop to origin/develop and release/#{release_tag} to #{stag_app_name}", :push_to_staging end if release_branch_exists unless release_tag == tiny_bump menu.choice "Change to PATCH release (bugfix only) #{highest_version} -> #{tiny_bump}", :bump_tiny end @@ -337,58 +362,151 @@ unless release_tag == major_bump menu.choice "Change to MAJOR release (breaking changes) #{highest_version} -> #{major_bump}", :bump_major end if git_revisions[:release] == git_revisions[stag_app_name] - menu.choice 'Finish release (merge, tag and final stage)', :finish_release + menu.choice "DEPLOY to #{prod_app_name}", :finish_release end elsif git_revisions[prod_app_name] != git_revisions[stag_app_name] menu.choice "DEPLOY (promote #{stag_app_name} to #{prod_app_name})", :DEPLOY end end - rescue TTY::Prompt::Reader::InputInterrupt + rescue TTY::Reader::InputInterrupt choice = :abort puts "\n\n" end case choice when :DEPLOY - promote_to_production + Fidget.prevent_sleep(:display, :sleep, :user) do + promote_to_production + end anykey when :finish_release - old_editor = ENV['EDITOR'] - old_git_editor = ENV['GIT_EDITOR'] - tf = Tempfile.new('hu-tag') - tf.write "#{release_tag}\n#{changelog}" - tf.close - ENV['EDITOR'] = ENV['GIT_EDITOR'] = "cp #{tf.path}" - env = { - 'PREVIOUS_TAG' => highest_version, - 'RELEASE_TAG' => release_tag, - 'GIT_MERGE_AUTOEDIT' => 'no' - } - unless 0 == finish_release(release_tag, env, tf.path) - abort_merge - puts '*** ERROR! Could not finish release *** '.color(:red) - puts - puts 'This usually means a merge conflict or' - puts 'something equally annoying has occured.' - puts - puts 'Please bring the universe into a state' - puts 'where the above sequence of commands can' - puts 'succeed. Then try again.' - puts - exit 1 + Fidget.prevent_sleep(:display, :sleep, :user) do + if ci_clear? + old_editor = ENV['EDITOR'] + old_git_editor = ENV['GIT_EDITOR'] + tf = Tempfile.new('hu-tag') + tf.write "#{release_tag}\n#{changelog}" + tf.close + ENV['EDITOR'] = ENV['GIT_EDITOR'] = "cp #{tf.path}" + env = { + 'PREVIOUS_TAG' => highest_version, + 'RELEASE_TAG' => release_tag, + 'GIT_MERGE_AUTOEDIT' => 'no' + } + unless 0 == finish_release(release_tag, env, tf.path) + abort_merge + puts '*** ERROR! Could not finish release *** '.color(:red) + puts + puts 'This usually means a merge conflict or' + puts 'something equally annoying has occured.' + puts + puts 'Please bring the universe into a state' + puts 'where the above sequence of commands can' + puts 'succeed. Then try again.' + puts + exit 1 + end + ENV['EDITOR'] = old_editor + ENV['GIT_EDITOR'] = old_git_editor + + promote_to_production + promoted_at = Time.now.to_i + + formation = h.formation.info(prod_app_name, 'web') + dyno_count = formation['quantity'] + + phase = :init + want = dyno_count + have = 0 + release_rev = `git rev-parse develop`[0..7] + parser = Proc.new do |line, pid| + source, line = line.chomp.split(' ', 2)[1].split(' ', 2) + source = /\[(.*)\]:/.match(source)[1] + prefix = "\e[0m" + case phase + when :init + if line =~ /Deploy #{release_rev}/ + phase = :observe + end + when :observe + if line =~ /State changed from starting to crashed/ + prefix = "\e[31;1m" + elsif line =~ /State changed from starting to up/ + prefix = "\e[32;1m" + have += 1 + end + + t = Time.now.to_i - promoted_at + ts = sprintf("%02d:%02d", t / 60, t % 60) + print "\e[30;1m[\e[0;33mT+#{ts} #{prefix}#{have}\e[0m/#{prefix}#{want}\e[30;1m] \e[0m" + print "#{source}: " unless source == 'api' + print prefix + line + puts + + if have >= want + Process.kill("TERM", pid) + end + end + end + + puts + puts "\e[0;1m# Observe startup\e[0m" + if h.app_feature.info(prod_app_name, 'preboot').dig('enabled') + puts <<EOF +# +# \e[0m\e]8;;https://devcenter.heroku.com/articles/preboot\007Preboot\e]8;;\007 is \e[32;1menabled\e[0m for \e[1m#{prod_app_name}\e[0m. +# +# #{NUMBERS[formation['quantity']] || formation['quantity']} new dyno#{formation['quantity'] == 1 ? '' : 's'} (\e[1m#{formation['size']}\e[0m) #{formation['quantity'] == 1 ? 'is' : 'are'} starting up. +# The old dynos will shut down within 3 minutes. +EOF + end + + script = <<-EOS.strip_heredoc + :stream + :quiet + :failquiet + :nospinner + :return + heroku logs --tail -a #{prod_app_name} | grep -E "heroku\\[|app\\[api\\]" + EOS + + sigint_handler = Proc.new do + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + + puts + puts "\e[43m \e[0m \e[0;32mRelease \e[1m#{release_tag}\e[0;32m is being launched on \e[1m#{prod_app_name}\e[0;32m\e[0m \e[43m \e[0m" + puts + + shutdown + + exit 0 + end + + run_each(script, parser: parser, sigint_handler: sigint_handler) + + puts + print"\a"; sleep 0.4 + puts "\e[42m \e[0m \e[0;32mRelease \e[1m#{release_tag}\e[0;32m has been deployed to \e[1m#{prod_app_name}\e[0;32m\e[0m \e[42m \e[0m" + print"\a"; sleep 0.4 + puts + print"\a"; sleep 0.4 + + exit 0 + end end - ENV['EDITOR'] = old_editor - ENV['GIT_EDITOR'] = old_git_editor - anykey when :push_to_staging - run_each <<-EOS.strip_heredoc - :stream - git push #{push_url} release/#{release_tag}:master -f - EOS + Fidget.prevent_sleep(:display, :sleep, :user) do + run_each <<-EOS.strip_heredoc + :stream + git push origin develop + git push #{push_url} release/#{release_tag}:master -f + EOS + end anykey when :abort_ask puts if delete_branch("release/#{release_tag}") exit 0 when :bump_tiny @@ -409,10 +527,173 @@ exit 0 end end end + def type(text, delay=0.01) + text.chars.each do |c| + print c + sleep rand * delay unless c == ' ' + end + end + + def ci_info + puts + if ENV['HU_GITHUB_ACCESS_TOKEN'].nil? + msg = "ERROR: Environment variable 'HU_GITHUB_ACCESS_TOKEN' must be set." + else + msg = 'ERROR: Github access token is invalid or has insufficient permissions' + end + puts msg.color(:red) + puts <<EOF + + 1. Go to \e]8;;https://github.com/settings/tokens\007https://github.com/settings/tokens\e]8;;\007 + + 2. Click on [Generate new token] + + 3. Create a token with (only) the following permissions: + + - repo:status + - repo_deployment + - public_repo + - read:user + + 4. Add the following line to your shell environment (e.g. ~/.bash_profile): + + \e[1mexport HU_GITHUB_ACCESS_TOKEN=<your_token>\e[0m + +EOF + end + + def ci_status(release_branch_exists=true) + okit = Octokit::Client.new(access_token: ENV['HU_GITHUB_ACCESS_TOKEN']) + + repo_name = @git.remotes['origin'].url.split(':')[1].gsub('.git', '') + + begin + raw_status_develop = status_develop = okit.status(repo_name, @git.branches["origin/develop"].target_id) + status_develop = status_develop[:statuses].empty? ? ' ' : status_develop[:state] + status_develop = ' ' if @git.branches["origin/develop"].target_id != @git.branches["develop"].target_id + status_develop = ' ' unless release_branch_exists + rescue Octokit::NotFound, Octokit::Unauthorized + return :error + end + + begin + # status_master = okit.status(repo_name, @git.branches["origin/master"].target_id) + # p status_master + # status_master = status_master[:statuses].empty? ? 'n/a' : status_master[:state] + # status_master = ' ' if @git.branches["origin/master"].target_id != @git.branches["master"].target_id + status_master = ' ' + rescue Octokit::NotFound + status_master = 'unknown' + end + + { + master: status_master, + develop: status_develop, + raw_develop: raw_status_develop + } + end + + def ci_symbol(value) + case value + when ' ' + '' + when 'pending' + ' 🐌' + when 'success' + ' ✅' + else + ' ❌' + end + end + + def ci_clear? + return true if ENV['HU_GITHUB_ACCESS_TOKEN'].nil? + msg = '' + prefix = "CI: " + ci_develop = '' + begin_wait_at = Time.now - 1 + i = 0 + puts + Signal.trap('QUIT') { raise SigQuit } + Signal.trap('INT') { raise Interrupt } + while ci_develop != 'success' do + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.hide + print prefix + print ('-' * (ci_develop.length + 2)) unless i == 0 + print msg unless i == 0 + ci_develop = ci_status(true)[:develop] if i % 10 == 0 + # ci_develop = 'failed' + + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + + print TTY::Cursor.hide + "\n" + TTY::Cursor.up + msg = " - press ^\\ to override, ^C to abort (#{ChronicDuration.output((Time.now - begin_wait_at).to_i, format: :short)}) " + + status = ci_develop.upcase + case status + when 'PENDING' + status = "\e[44;33;1m PENDING \e[0m" + when 'SUCCESS' + status = "\e[42;30;1m CLEAR \e[0m" + when ' ' + status = "\e[40;30;1m UNCONFIGURED \e[0m" + else + status = "\e[41;33;1m #{status} \e[0m" + end + print prefix + status + print msg unless ci_develop == 'success' || ci_develop == ' ' + + # key = nil + + sleep 1 + + break if ci_develop == ' ' + # catch :sigint do + # begin + # Timeout::timeout(1) do + # Signal.trap('INT') { throw :sigint } + # key = STDIN.getch + # end + # rescue Timeout::Error + # key = :timeout + # ensure + # Signal.trap('INT', 'DEFAULT') + # end + # end + # if key == "\u0003" + # puts + # print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + # return false + # end + + i += 1 + end + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + print TTY::Cursor.up + return true + rescue Interrupt + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + return false + rescue SigQuit + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + puts "CI: \e[41;33;1m OVERRIDE \e[0m" + return true + ensure + Signal.trap('QUIT', 'DEFAULT') + Signal.trap('INT', 'DEFAULT') + puts + print TTY::Cursor.up + TTY::Cursor.clear_line + TTY::Cursor.show + end + def show_pipeline_status(pipeline_name, stag_app_name, prod_app_name, release_tag, clear = true) table = TTY::Table.new header: ['', 'commit', 'tag', 'last_modified', 'last_modified_by', 'dynos', ''] busy 'synchronizing', :dots ts = [] workers = [] @@ -491,10 +772,21 @@ end [idx, table_row] end end + ci = Thread.new do + ci_status(branch_exists?("release/#{release_tag}")) + end + + ci = ci.value + if ci == :error + unbusy + ci_info + exit 1 + end + workers.each(&:join) rows = [] ts.each do |t| idx, table_row = t.value @@ -504,10 +796,11 @@ row = tpl_row.dup row[0] = 'master' revs[:master] = row[1] = `git rev-parse master`[0..5] row[2] = `git tag --points-at master` row[1] = row[1].color(row[1] == revs[:develop] ? :green : :red) + # row[3] = color_ci(ci[:master]) rows.unshift row if branch_exists? "release/#{release_tag}" row = tpl_row.dup row[0] = "release/#{release_tag}" @@ -516,13 +809,22 @@ row[2] = `git tag --points-at release/#{release_tag} 2>/dev/null` rows.unshift row end row = tpl_row.dup + row[0] = 'origin/develop' + row[1] = `git rev-parse origin/develop`[0..5] + row[1] = row[1].color(row[1] == revs[:develop] ? :green : :red) + ci_symbol(ci[:develop]) + row[2] = `git tag --points-at origin/develop` + # row[3] = color_ci(ci[:develop]) + rows.unshift row + + row = tpl_row.dup row[0] = 'develop' row[1] = revs[:develop].color(:green) row[2] = `git tag --points-at develop` + # row[3] = color_ci(ci[:develop]) if rows.unshift row unbusy rows.each do |r| @@ -555,11 +857,28 @@ puts ' WARNING '.background(:red).color(:yellow).bright + ' Missing config in ' + prod_app_name.bright + ": #{var}" sleep 0.42 end end + # p ci + if ci[:develop] != ' ' + ci[:raw_develop][:statuses].each do |status| + if ['failure', 'error'].include? status[:state] + puts + print " CI #{status[:state].upcase} ".background(:red).color(:yellow).bright + print " \033]1337;RequestAttention=fireworks\a" + sleep 1 + puts "#{status[:description]}".bright + puts " " + (" " * status[:state].length) + status[:target_url] + end + end + end + revs + rescue Interrupt + puts "*** Abort" + exit 1 end def heroku_app_by_git(git_url) busy('synchronizing', :dots) r = h.app.list.select { |e| e['git_url'] == git_url } @@ -601,23 +920,28 @@ def run_each(script, opts = {}) opts = { quiet: false, failfast: true, + failquiet: false, spinner: true, - stream: false + stream: false, + parser: nil }.merge(opts) + parser = opts[:parser] + @spinlock ||= Mutex.new # :P script.lines.each_with_index do |line, i| line.chomp! case line[0] when '#' puts "\n" + line.bright unless opts[:quiet] when ':' opts[:quiet] = true if line == ':quiet' opts[:failfast] = false if line == ':return' + opts[:failquiet] = true if line == ':failquiet' opts[:spinner] = false if line == ':nospinner' if line == ':stream' opts[:stream] = true opts[:quiet] = false end @@ -631,11 +955,11 @@ @minispin_disable = false @minispin_last_char_at = Time.now @tspin ||= Thread.new do i = 0 loop do - break if @minispin_last_char_at == :end + break if @minispin_last_char_at == :end || @shutdown begin if 0.23 > Time.now - @minispin_last_char_at || @minispin_disable sleep 0.1 next end @@ -656,14 +980,21 @@ end end PTY.spawn("stty rows #{rows} cols #{cols}; " + line) do |r, _w, pid| begin + l = '' until r.eof? c = r.getc @spinlock.synchronize do - print c + print c unless opts[:quiet] + if c == "\n" && parser + parser.call(l, pid) + l = '' + else + l += c + end @minispin_last_char_at = Time.now c = c.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: "\e") # barf. # hold on when we are (likely) inside an escape sequence @minispin_disable = true if c.ord == 27 || c.ord < 9 @minispin_disable = false if c =~ /[A-Za-z]/ || [13, 10].include?(c.ord) @@ -671,10 +1002,18 @@ end rescue Errno::EIO # Linux raises EIO on EOF, cf. # https://github.com/ruby/ruby/blob/57fb2199059cb55b632d093c2e64c8a3c60acfbb/ext/pty/pty.c#L519 nil + rescue Interrupt + if opts[:sigint_handler] + opts[:sigint_handler].call + else + puts + puts "*** Abort (SIGINT)" + exit 1 + end end _pid, status = Process.wait2(pid) @minispin_last_char_at = :end @tspin.join @@ -692,11 +1031,11 @@ puts output end end next unless status.exitstatus != 0 shutdown if opts[:failfast] - puts "Error, exit #{status.exitstatus}: #{line} (L#{i})".color(:red).bright + puts "Error, exit #{status.exitstatus}: #{line} (L#{i})".color(:red).bright unless opts[:failquiet] exit status.exitstatus if opts[:failfast] return status.exitstatus end 0 @@ -732,11 +1071,15 @@ branches.include? branch_name end def delete_branch(branch_name) return false unless branch_exists? branch_name - return false if TTY::Prompt.new.no?("Delete branch #{branch_name}?") + begin + return false if TTY::Prompt.new.no?("Delete branch #{branch_name}?") + rescue TTY::Reader::InputInterrupt + return false + end run_each <<-EOS.strip_heredoc :quiet # Delete branch #{branch_name} git checkout develop git branch -D #{branch_name} @@ -1013,11 +1356,11 @@ busy(msg, format) yield unbusy end - def anykey - unless ENV['HU_ANYKEY'] + def anykey(force=false) + unless ENV['HU_ANYKEY'] || force puts return end puts TTY::Cursor.hide print '--- Press any key ---'.color(:cyan)