heroku_hatchet 7.1.2 → 7.3.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -46,7 +46,7 @@ module Hatchet
46
46
  def path_for_name(name)
47
47
  possible_paths = [repos[name.to_s], "repos/#{name}", name].compact
48
48
  path = possible_paths.detect do |path|
49
- !Dir[path]&.empty?
49
+ !(Dir[path] && Dir[path].empty?)
50
50
  end
51
51
  raise BadRepoName.new(name, possible_paths) if path.nil? || path.empty?
52
52
  path
@@ -5,25 +5,30 @@ module Hatchet
5
5
  "https://git.heroku.com/#{name}.git"
6
6
  end
7
7
 
8
-
9
8
  def push_without_retry!
10
9
  output = ""
11
10
 
12
11
  ShellThrottle.new(platform_api: @platform_api).call do
13
- output = git_push_heroku_yall
14
- rescue FailedDeploy => e
15
- if e.output.match?(/reached the API rate limit/)
16
- throw(:throttle)
17
- elsif @allow_failure
18
- output = e.output
19
- else
20
- raise e
12
+ begin
13
+ output = git_push_heroku_yall
14
+ rescue FailedDeploy => e
15
+ case e.output
16
+ when /reached the API rate limit/, /429 Too Many Requests/
17
+ throw(:throttle)
18
+ else
19
+ raise e unless @allow_failure
20
+ output = e.output
21
+ end
21
22
  end
22
23
  end
23
24
 
24
25
  return output
25
26
  end
26
27
 
28
+ def releases
29
+ platform_api.release.list(name)
30
+ end
31
+
27
32
  private def git_push_heroku_yall
28
33
  output = `git push #{git_repo} HEAD:main 2>&1`
29
34
 
@@ -31,7 +36,6 @@ module Hatchet
31
36
  raise FailedDeployError.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}", output: output)
32
37
  end
33
38
 
34
- releases = platform_api.release.list(name)
35
39
  if releases.last["status"] == "failed"
36
40
  commit! # An empty commit allows us to deploy again
37
41
  raise FailedReleaseError.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}", output: output)
@@ -0,0 +1,96 @@
1
+ module Hatchet
2
+ # Used for running Heroku commands
3
+ #
4
+ # Example:
5
+ #
6
+ # run_obj = HerokuRun.new("ruby -v", app: app).call
7
+ # puts run_obj.output #=> "ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]"
8
+ # puts run_obj.status.success? #=> true
9
+ #
10
+ # There's a bug in specs sometimes where App#run will return an empty
11
+ # value. When that's detected then the command will be re-run. This can be
12
+ # optionally disabled by setting `retry_on_empty: false` if you're expecting
13
+ # the command to be empty.
14
+ #
15
+ class HerokuRun
16
+ attr_reader :command
17
+
18
+ def initialize(
19
+ command,
20
+ app: ,
21
+ heroku: {},
22
+ retry_on_empty: !ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"],
23
+ raw: false,
24
+ stderr: $stderr)
25
+
26
+ @raw = raw
27
+ @app = app
28
+ @command = build_heroku_command(command, heroku || {})
29
+ @retry_on_empty = retry_on_empty
30
+ @stderr = stderr
31
+ @output = ""
32
+ @status = nil
33
+ @empty_fail_count = 0
34
+ end
35
+
36
+ def output
37
+ raise "You must run `call` on this object first" unless @status
38
+ @output
39
+ end
40
+
41
+ def status
42
+ raise "You must run `call` on this object first" unless @status
43
+ @status
44
+ end
45
+
46
+ def call
47
+ loop do
48
+ execute!
49
+
50
+ break unless output.empty?
51
+ break unless @retry_on_empty
52
+
53
+ @empty_fail_count += 1
54
+
55
+ break if @empty_fail_count >= 3
56
+
57
+ message = String.new("Empty output from command #{@command}, retrying the command.")
58
+ message << "\nTo disable pass in `retry_on_empty: false` or set HATCHET_DISABLE_EMPTY_RUN_RETRY=1 globally"
59
+ message << "\nfailed_count: #{@empty_fail_count}"
60
+ message << "\nreleases: #{@app.releases}"
61
+ message << "\n#{caller.join("\n")}"
62
+ @stderr.puts message
63
+ end
64
+
65
+ self
66
+ end
67
+
68
+ private def execute!
69
+ ShellThrottle.new(platform_api: @app.platform_api).call do |throttle|
70
+ run_shell!
71
+ throw(:throttle) if output.match?(/reached the API rate limit/)
72
+ end
73
+ end
74
+
75
+ private def run_shell!
76
+ @output = `#{@command}`
77
+ @status = $?
78
+ end
79
+
80
+ private def build_heroku_command(command, options = {})
81
+ command = command.shellescape unless @raw
82
+
83
+ default_options = { "app" => @app.name, "exit-code" => nil }
84
+ heroku_options_array = (default_options.merge(options)).map do |k,v|
85
+ # This was a bad interface decision
86
+ next if v == Hatchet::App::SkipDefaultOption # for forcefully removing e.g. --exit-code, a user can pass this
87
+
88
+ arg = "--#{k.to_s.shellescape}"
89
+ arg << "=#{v.to_s.shellescape}" unless v.nil? # nil means we include the option without an argument
90
+ arg
91
+ end
92
+
93
+ "heroku run #{heroku_options_array.compact.join(' ')} -- #{command}"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,86 @@
1
+ require 'thor'
2
+ require 'yaml'
3
+
4
+ module Hatchet
5
+ # Bootstraps a project with files for running hatchet tests
6
+ #
7
+ # Hatchet::InitProject.new.call
8
+ #
9
+ # puts File.exist?("spec/spec_helper.rb") # => true
10
+ # puts File.exist?("") # => true
11
+ class InitProject
12
+ def initialize(dir: ".", io: STDOUT)
13
+
14
+ @target_dir = Pathname.new(dir)
15
+ raise "Must run in a directory with a buildpack, #{@target_dir} has no bin/ directory" unless @target_dir.join("bin").directory?
16
+
17
+ @template_dir = Pathname.new(__dir__).join("templates")
18
+ @thor_shell = ::Thor::Shell::Basic.new
19
+ @io = io
20
+ @git_ignore = @target_dir.join(".gitignore")
21
+
22
+ FileUtils.touch(@git_ignore)
23
+ FileUtils.touch(@target_dir.join("hatchet.lock"))
24
+ end
25
+
26
+ def call
27
+ write_target(target: ".circleci/config.yml", template: "circleci_template.erb")
28
+ write_target(target: "Gemfile", template: "Gemfile.erb")
29
+ write_target(target: "hatchet.json", template: "hatchet_json.erb")
30
+ write_target(target: "spec/spec_helper.rb", template: "spec_helper.erb")
31
+ write_target(target: "spec/hatchet/buildpack_spec.rb", template: "buildpack_spec.erb")
32
+ write_target(target: ".github/dependabot.yml", template: "dependabot.erb")
33
+ write_target(target: ".github/workflows/check_changelog.yml", template: "check_changelog.erb")
34
+
35
+ add_gitignore(".rspec_status")
36
+ add_gitignore("repos/*")
37
+
38
+ stream("cd #{@target_dir} && bundle install")
39
+ stream("cd #{@target_dir} && hatchet install")
40
+
41
+ @io.puts
42
+ @io.puts "Done, run `bundle exec rspec` to execute your tests"
43
+ @io.puts
44
+ end
45
+
46
+ private def add_gitignore(statement)
47
+ @git_ignore.open("a") {|f| f.puts statement } unless @git_ignore.read.include?(statement)
48
+ end
49
+
50
+ private def stream(command)
51
+ output = ""
52
+ IO.popen(command) do |io|
53
+ until io.eof?
54
+ buffer = io.gets
55
+ output << buffer
56
+ @io.puts(buffer)
57
+ end
58
+ end
59
+ raise "Error running #{command}. Output:\n#{output}" unless $?.success?
60
+ output
61
+ end
62
+
63
+ private def write_target(template: nil, target:, contents: nil)
64
+ if template
65
+ template = @template_dir.join(template)
66
+ contents = ERB.new(template.read).result(binding)
67
+ end
68
+
69
+ target = @target_dir.join(target)
70
+ target.dirname.mkpath # Create directory if it doesn't exist already
71
+
72
+ if target.exist?
73
+ return if contents === target.read # identical
74
+ target.write(contents) if @thor_shell.file_collision(target) { contents }
75
+ else
76
+ target.write(contents)
77
+ end
78
+ end
79
+
80
+ private def cmd(command)
81
+ result = `#{command}`.chomp
82
+ raise "Command #{command} failed:\n#{result}" unless $?.success?
83
+ result
84
+ end
85
+ end
86
+ end
@@ -55,7 +55,7 @@ module Hatchet
55
55
  # To be safe try to delete an app even if we're not over the limit
56
56
  # since the exception may have been caused by going over the maximum account limit
57
57
  if app_exception_message
58
- io.puts <<~EOM
58
+ io.puts <<-EOM.strip_heredoc
59
59
  WARNING: Running reaper due to exception on app
60
60
  #{stats_string}
61
61
  Exception: #{app_exception_message}
@@ -122,7 +122,7 @@ module Hatchet
122
122
 
123
123
  # Sleep, try again later
124
124
  @reaper_throttle.call(max_sleep: age.sleep_for_ttl) do |sleep_for|
125
- io.puts <<~EOM
125
+ io.puts <<-EOM.strip_heredoc
126
126
  WARNING: Attempting to destroy an app without maintenance mode on, but it is not old enough. app: #{app["name"]}, app_age: #{age.in_minutes} minutes
127
127
  This can happen if App#teardown! is not called on an application, which will leave it in an 'unfinished' state
128
128
  This can also happen if you're trying to run more tests concurrently than your currently set value for HATCHET_APP_COUNT
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "parallel_split_test"
4
+ gem "heroku_hatchet"
5
+ gem "rspec-retry"
@@ -0,0 +1,23 @@
1
+ require_relative "../spec_helper.rb"
2
+
3
+ RSpec.describe "This buildpack" do
4
+ it "has its own tests" do
5
+ raise "delete this and replace it with your own logic"
6
+
7
+ # Specify where you want your buildpack to go using :default
8
+ buildpacks = [:default, "heroku/ruby"]
9
+
10
+ # To deploy a different app modify the hatchet.json or
11
+ # commit an app to your source control and use a path
12
+ # instead of "default_ruby" here
13
+ Hatchet::Runner.new("default_ruby", buildpacks: buildpacks).tap do |app|
14
+ app.before_deploy do
15
+ # Modfiy the app here if you need
16
+ end
17
+ app.deploy do
18
+ # Assert the behavior you desire here
19
+ expect(app.output).to match("deployed to Heroku")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ name: Check Changelog
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, reopened, edited, synchronize]
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v1
11
+ - name: Check that CHANGELOG is touched
12
+ run: |
13
+ cat $GITHUB_EVENT_PATH | jq .pull_request.title | grep -i '\[\(\(changelog skip\)\|\(ci skip\)\)\]' || git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md
@@ -0,0 +1,45 @@
1
+ version: 2
2
+ references:
3
+ unit: &unit
4
+ run:
5
+ name: Run test suite
6
+ command: PARALLEL_SPLIT_TEST_PROCESSES=25 IS_RUNNING_ON_CI=1 bundle exec parallel_split_test spec/
7
+ restore: &restore
8
+ restore_cache:
9
+ keys:
10
+ - v1_bundler_deps-{{ .Environment.CIRCLE_JOB }}
11
+ save: &save
12
+ save_cache:
13
+ paths:
14
+ - ./vendor/bundle
15
+ key: v1_bundler_deps-{{ .Environment.CIRCLE_JOB }} # CIRCLE_JOB e.g. "ruby-2.5"
16
+ hatchet_setup: &hatchet_setup
17
+ run:
18
+ name: Hatchet setup
19
+ command: |
20
+ bundle exec hatchet ci:setup
21
+ bundle: &bundle
22
+ run:
23
+ name: install dependencies
24
+ command: |
25
+ bundle install --jobs=4 --retry=3 --path vendor/bundle
26
+ bundle update
27
+ bundle clean
28
+ jobs:
29
+ "ruby-2.7":
30
+ docker:
31
+ - image: circleci/ruby:2.7
32
+ steps:
33
+ - checkout
34
+ - <<: *restore
35
+ - <<: *bundle
36
+ - <<: *hatchet_setup
37
+ - <<: *unit
38
+ - <<: *save
39
+
40
+ workflows:
41
+ version: 2
42
+ build:
43
+ jobs:
44
+ - "ruby-2.7"
45
+
@@ -0,0 +1,9 @@
1
+ version: 1
2
+ updates:
3
+ - package-ecosystem: "bundler"
4
+ directory: "/"
5
+ open-pull-requests-limit: 1 # Limit concurrent CI runs from executing
6
+ schedule:
7
+ interval: "weekly"
8
+ labels:
9
+ - "dependencies"
@@ -0,0 +1,11 @@
1
+ {
2
+ "ruby_apps": [
3
+ "sharpstone/default_ruby"
4
+ ],
5
+ "node_apps": [
6
+ "heroku/node-js-getting-started"
7
+ ],
8
+ "python_apps": [
9
+ "heroku/python-getting-started"
10
+ ]
11
+ }
@@ -0,0 +1,30 @@
1
+ require "bundler/setup"
2
+
3
+ require 'rspec/retry'
4
+
5
+ ENV["HATCHET_BUILDPACK_BASE"] = "<%= cmd("git config --get remote.origin.url") %>"
6
+
7
+ require 'hatchet'
8
+ require 'pathname'
9
+
10
+ RSpec.configure do |config|
11
+ # Enable flags like --only-failures and --next-failure
12
+ config.example_status_persistence_file_path = ".rspec_status"
13
+ config.verbose_retry = true # show retry status in spec process
14
+ config.default_retry_count = 2 if ENV['IS_RUNNING_ON_CI'] # retry all tests that fail again
15
+
16
+ config.expect_with :rspec do |c|
17
+ c.syntax = :expect
18
+ end
19
+ end
20
+
21
+ def run!(cmd)
22
+ out = `#{cmd}`
23
+ raise "Error running #{cmd}, output: #{out}" unless $?.success?
24
+ out
25
+ end
26
+
27
+ def spec_dir
28
+ Pathname.new(__dir__)
29
+ end
30
+
@@ -1,7 +1,9 @@
1
+ require "tempfile"
2
+
1
3
  module Hatchet
2
4
  class FailedTestError < StandardError
3
5
  def initialize(app, output)
4
- msg = "Could not run tests on pipeline id: '#{app.pipeline_id}' (#{app.repo_name}) at path: '#{app.directory}'\n" <<
6
+ msg = "Could not run tests on pipeline id: '#{app.pipeline_id}' (#{app.repo_name}) at path: '#{app.original_source_code_directory}'\n" <<
5
7
  " if this was expected add `allow_failure: true` to your hatchet initialization hash.\n" <<
6
8
  "output:\n" <<
7
9
  "#{output}"
@@ -140,8 +142,9 @@ module Hatchet
140
142
  end
141
143
  end
142
144
  rescue Timeout::Error
143
- puts "Timed out status: #{@status}, timeout: #{@timeout}"
144
- raise FailedTestError.new(self.app, self.output) unless app.allow_failure?
145
+ message = "Timed out status: #{@status}, timeout: #{@timeout}, app: #{app.name}"
146
+ puts message
147
+ raise FailedTestError.new(self.app, "#{message}, output:\n#{self.output}") unless app.allow_failure?
145
148
  yield self
146
149
  return self
147
150
  end
@@ -176,15 +179,17 @@ module Hatchet
176
179
  app_json["stack"] ||= @app.stack if @app.stack && !@app.stack.empty?
177
180
  File.open("app.json", "w") {|f| f.write(JSON.generate(app_json)) }
178
181
 
179
- out = `tar c . | gzip -9 > slug.tgz`
180
- raise "Tar command failed: #{out}" unless $?.success?
181
-
182
- source_put_url = @app.create_source
183
- Hatchet::RETRIES.times.retry do
184
- PlatformAPI.rate_throttle.call do
185
- Excon.put(source_put_url,
186
- expects: [200],
187
- body: File.read('slug.tgz'))
182
+ Tempfile.create("slug.tgz") do |slug|
183
+ out = `tar c . | gzip -9 > #{slug.path}`
184
+ raise "Tar command failed: #{out}" unless $?.success?
185
+
186
+ source_put_url = @app.create_source
187
+ Hatchet::RETRIES.times.retry do
188
+ PlatformAPI.rate_throttle.call do
189
+ Excon.put(source_put_url,
190
+ expects: [200],
191
+ body: slug.read)
192
+ end
188
193
  end
189
194
  end
190
195
  end