From 4ee4db17c2a171a297e227d589913f21a7a28b8d Mon Sep 17 00:00:00 2001 From: schneems Date: Wed, 9 Sep 2020 11:29:50 -0500 Subject: [PATCH] Bootstrap a buildpack with `hatchet init` To make adding Hatchet tests to an existing project easier and faster I've created a `$ hatchet init` command. This will generate: - Gemfile - hatchet.json - spec/spec_helper.rb - spec/hatchet/buildpack_spec.rb - .circleci/config.yml - .github/dependabot.yml - .gitignore Then run `bundle install` and `hatchet install`. Once this is done then you can start writing tests using Hatchet. --- CHANGELOG.md | 2 + README.md | 45 ++++++++++- bin/hatchet | 6 ++ lib/hatchet.rb | 1 + lib/hatchet/init_project.rb | 86 +++++++++++++++++++++ lib/hatchet/templates/Gemfile.erb | 5 ++ lib/hatchet/templates/buildpack_spec.erb | 23 ++++++ lib/hatchet/templates/check_changelog.erb | 13 ++++ lib/hatchet/templates/circleci_template.erb | 45 +++++++++++ lib/hatchet/templates/dependabot.erb | 9 +++ lib/hatchet/templates/hatchet_json.erb | 11 +++ lib/hatchet/templates/spec_helper.erb | 30 +++++++ spec/unit/init_spec.rb | 52 +++++++++++++ 13 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 lib/hatchet/init_project.rb create mode 100644 lib/hatchet/templates/Gemfile.erb create mode 100644 lib/hatchet/templates/buildpack_spec.erb create mode 100644 lib/hatchet/templates/check_changelog.erb create mode 100644 lib/hatchet/templates/circleci_template.erb create mode 100644 lib/hatchet/templates/dependabot.erb create mode 100644 lib/hatchet/templates/hatchet_json.erb create mode 100644 lib/hatchet/templates/spec_helper.erb create mode 100644 spec/unit/init_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index cfdd425..f30ae4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## HEAD +- Add `$ hatchet init` command for bootstrapping new projects (https://github.com/heroku/hatchet/pull/123) + ## 7.1.3 - Important!! Fix branch name detection on CircleCI (https://github.com/heroku/hatchet/pull/124) diff --git a/README.md b/README.md index 50c95e2..4eebd16 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ In addition to speed, Hatchet provides isolation. Suppose you're executing `bin/ ## Quicklinks +- Getting started + - [Add hatchet tests to a existing buildpack](#hatchet-init) - Concepts - [Tell Hatchet how to find your buildpack](#specify-buildpack) - [Give Hatchet some example apps to deploy](#example-apps) @@ -78,6 +80,45 @@ In addition to speed, Hatchet provides isolation. Suppose you're executing `bin/ - [Introduction to the Rspec testing framework for non-rubyists](#basic-rspec) - [Introduction to Ruby for non-rubyists](#basic-ruby) +## Getting Started + +### Hatchet Init + +If you're working in a project that does not already have hatchet tests you can run this command to get started quickly: + +Make sure you're in directory that contains your buildpack and run: + +``` +$ gem install heroku_hatchet +$ hatchet init +``` + +This will bootstrap your project with the necessarry files to test your buildpack. Including but not limited to: + +- Gemfile +- hatchet.json +- spec/spec_helper.rb +- spec/hatchet/buildpack_spec.rb +- .circleci/config.yml +- .github/dependabot.yml +- .gitignore + +Once this executes successfully then you can run your tests with: + +``` +$ bundle exec rspec +``` + +> Note: You'll need to update the `buildpack_spec.rb` file to remove the exception + +You can also focus a specific file or test by providing a path and line number: + +``` +$ bundle exec rspec spec/hatchet/buildpack_spec:5 +``` + +Keep reading to find out more about how hatchet works. + ## Concepts ### Specify buildpack @@ -89,7 +130,7 @@ ENV["HATCHET_BUILDPACK_BASE"] = "https://github.com/path-to-your/buildpack" require 'hatchet'` ``` -If you do not specify `HATCHET_BUILDPACK_URL` the default Ruby buildpack will be used. If you do not specify a `HATCHET_BUILDPACK_BRANCH` the current branch you are on will be used. This is how the Ruby buildpack runs tests on branches on CI (by leaving `HATCHET_BUILDPACK_BRANCH` blank). +If you do not specify `HATCHET_BUILDPACK_BASE` the default Ruby buildpack will be used. If you do not specify a `HATCHET_BUILDPACK_BRANCH` the current branch you are on will be used. This is how the Ruby buildpack runs tests on branches on CI (by leaving `HATCHET_BUILDPACK_BRANCH` blank). The workflow generally looks like this: @@ -156,7 +197,7 @@ You can reference one of these applications in your test by using it's git name: Hatchet::Runner.new('no_lockfile') ``` -If you have conflicting names, use full paths like `Hatchet::RUnner.new("sharpstone/no_lockfile")`. +If you have conflicting names, use full paths like `Hatchet::Runner.new("sharpstone/no_lockfile")`. When you run `hatchet install` it will lock all the Repos to a specific commit. This is done so that if a repo changes upstream that introduces an error the test suite won't automatically pick it up. For example in https://github.com/sharpstone/lock_fail/commit/e61ba47043fbae131abb74fd74added7e6e504df an error is added, but this will only cause a failure if your project intentionally locks to commit `e61ba47043fbae131abb74fd74added7e6e504df` or later. diff --git a/bin/hatchet b/bin/hatchet index 1bc0552..9adfbec 100755 --- a/bin/hatchet +++ b/bin/hatchet @@ -15,8 +15,14 @@ require 'thor' require 'threaded' require 'date' require 'yaml' +require 'pathname' class HatchetCLI < Thor + desc "init", "bootstraps a project with minimal files required to add hatchet tests" + define_method("init") do + Hatchet::InitProject.new.call + end + desc "ci:install_heroku", "installs the `heroku` cli" define_method("ci:install_heroku") do if `which heroku` && $?.success? diff --git a/lib/hatchet.rb b/lib/hatchet.rb index f561ed1..00ccd6e 100644 --- a/lib/hatchet.rb +++ b/lib/hatchet.rb @@ -18,6 +18,7 @@ module Hatchet require 'hatchet/git_app' require 'hatchet/config' require 'hatchet/api_rate_limit' +require 'hatchet/init_project' module Hatchet RETRIES = Integer(ENV['HATCHET_RETRIES'] || 1) diff --git a/lib/hatchet/init_project.rb b/lib/hatchet/init_project.rb new file mode 100644 index 0000000..1424326 --- /dev/null +++ b/lib/hatchet/init_project.rb @@ -0,0 +1,86 @@ +require 'thor' +require 'yaml' + +module Hatchet + # Bootstraps a project with files for running hatchet tests + # + # Hatchet::InitProject.new.call + # + # puts File.exist?("spec/spec_helper.rb") # => true + # puts File.exist?("") # => true + class InitProject + def initialize(dir: ".", io: STDOUT) + + @target_dir = Pathname.new(dir) + raise "Must run in a directory with a buildpack, #{@target_dir} has no bin/ directory" unless @target_dir.join("bin").directory? + + @template_dir = Pathname.new(__dir__).join("templates") + @thor_shell = ::Thor::Shell::Basic.new + @io = io + @git_ignore = @target_dir.join(".gitignore") + + FileUtils.touch(@git_ignore) + FileUtils.touch(@target_dir.join("hatchet.lock")) + end + + def call + write_target(target: ".circleci/config.yml", template: "circleci_template.erb") + write_target(target: "Gemfile", template: "Gemfile.erb") + write_target(target: "hatchet.json", template: "hatchet_json.erb") + write_target(target: "spec/spec_helper.rb", template: "spec_helper.erb") + write_target(target: "spec/hatchet/buildpack_spec.rb", template: "buildpack_spec.erb") + write_target(target: ".github/dependabot.yml", template: "dependabot.erb") + write_target(target: ".github/workflows/check_changelog.yml", template: "check_changelog.erb") + + add_gitignore(".rspec_status") + add_gitignore("repos/*") + + stream("cd #{@target_dir} && bundle install") + stream("cd #{@target_dir} && hatchet install") + + @io.puts + @io.puts "Done, run `bundle exec rspec` to execute your tests" + @io.puts + end + + private def add_gitignore(statement) + @git_ignore.open("a") {|f| f.puts statement } unless @git_ignore.read.include?(statement) + end + + private def stream(command) + output = "" + IO.popen(command) do |io| + until io.eof? + buffer = io.gets + output << buffer + @io.puts(buffer) + end + end + raise "Error running #{command}. Output:\n#{output}" unless $?.success? + output + end + + private def write_target(template: nil, target:, contents: nil) + if template + template = @template_dir.join(template) + contents = ERB.new(template.read).result(binding) + end + + target = @target_dir.join(target) + target.dirname.mkpath # Create directory if it doesn't exist already + + if target.exist? + return if contents === target.read # identical + target.write(contents) if @thor_shell.file_collision(target) { contents } + else + target.write(contents) + end + end + + private def cmd(command) + result = `#{command}`.chomp + raise "Command #{command} failed:\n#{result}" unless $?.success? + result + end + end +end diff --git a/lib/hatchet/templates/Gemfile.erb b/lib/hatchet/templates/Gemfile.erb new file mode 100644 index 0000000..4ff73e4 --- /dev/null +++ b/lib/hatchet/templates/Gemfile.erb @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "parallel_split_test" +gem "heroku_hatchet" +gem "rspec-retry" diff --git a/lib/hatchet/templates/buildpack_spec.erb b/lib/hatchet/templates/buildpack_spec.erb new file mode 100644 index 0000000..f56ab41 --- /dev/null +++ b/lib/hatchet/templates/buildpack_spec.erb @@ -0,0 +1,23 @@ +require_relative "../spec_helper.rb" + +RSpec.describe "This buildpack" do + it "has its own tests" do + raise "delete this and replace it with your own logic" + + # Specify where you want your buildpack to go using :default + buildpacks = [:default, "heroku/ruby"] + + # To deploy a different app modify the hatchet.json or + # commit an app to your source control and use a path + # instead of "default_ruby" here + Hatchet::Runner.new("default_ruby", buildpacks: buildpacks).tap do |app| + app.before_deploy do + # Modfiy the app here if you need + end + app.deploy do + # Assert the behavior you desire here + expect(app.output).to match("deployed to Heroku") + end + end + end +end diff --git a/lib/hatchet/templates/check_changelog.erb b/lib/hatchet/templates/check_changelog.erb new file mode 100644 index 0000000..1e9febe --- /dev/null +++ b/lib/hatchet/templates/check_changelog.erb @@ -0,0 +1,13 @@ +name: Check Changelog + +on: + pull_request: + types: [opened, reopened, edited, synchronize] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Check that CHANGELOG is touched + run: | + 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 diff --git a/lib/hatchet/templates/circleci_template.erb b/lib/hatchet/templates/circleci_template.erb new file mode 100644 index 0000000..8bf7dbe --- /dev/null +++ b/lib/hatchet/templates/circleci_template.erb @@ -0,0 +1,45 @@ +version: 2 +references: + unit: &unit + run: + name: Run test suite + command: PARALLEL_SPLIT_TEST_PROCESSES=25 IS_RUNNING_ON_CI=1 bundle exec parallel_split_test spec/ + restore: &restore + restore_cache: + keys: + - v1_bundler_deps-{{ .Environment.CIRCLE_JOB }} + save: &save + save_cache: + paths: + - ./vendor/bundle + key: v1_bundler_deps-{{ .Environment.CIRCLE_JOB }} # CIRCLE_JOB e.g. "ruby-2.5" + hatchet_setup: &hatchet_setup + run: + name: Hatchet setup + command: | + bundle exec hatchet ci:setup + bundle: &bundle + run: + name: install dependencies + command: | + bundle install --jobs=4 --retry=3 --path vendor/bundle + bundle update + bundle clean +jobs: + "ruby-2.7": + docker: + - image: circleci/ruby:2.7 + steps: + - checkout + - <<: *restore + - <<: *bundle + - <<: *hatchet_setup + - <<: *unit + - <<: *save + +workflows: + version: 2 + build: + jobs: + - "ruby-2.7" + diff --git a/lib/hatchet/templates/dependabot.erb b/lib/hatchet/templates/dependabot.erb new file mode 100644 index 0000000..1f6a859 --- /dev/null +++ b/lib/hatchet/templates/dependabot.erb @@ -0,0 +1,9 @@ +version: 1 +updates: + - package-ecosystem: "bundler" + directory: "/" + open-pull-requests-limit: 1 # Limit concurrent CI runs from executing + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/lib/hatchet/templates/hatchet_json.erb b/lib/hatchet/templates/hatchet_json.erb new file mode 100644 index 0000000..8165049 --- /dev/null +++ b/lib/hatchet/templates/hatchet_json.erb @@ -0,0 +1,11 @@ +{ + "ruby_apps": [ + "sharpstone/default_ruby" + ], + "node_apps": [ + "heroku/node-js-getting-started" + ], + "python_apps": [ + "heroku/python-getting-started" + ] +} diff --git a/lib/hatchet/templates/spec_helper.erb b/lib/hatchet/templates/spec_helper.erb new file mode 100644 index 0000000..49435b9 --- /dev/null +++ b/lib/hatchet/templates/spec_helper.erb @@ -0,0 +1,30 @@ +require "bundler/setup" + +require 'rspec/retry' + +ENV["HATCHET_BUILDPACK_BASE"] = "<%= cmd("git config --get remote.origin.url") %>" + +require 'hatchet' +require 'pathname' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + config.verbose_retry = true # show retry status in spec process + config.default_retry_count = 2 if ENV['IS_RUNNING_ON_CI'] # retry all tests that fail again + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end + +def run!(cmd) + out = `#{cmd}` + raise "Error running #{cmd}, output: #{out}" unless $?.success? + out +end + +def spec_dir + Pathname.new(__dir__) +end + diff --git a/spec/unit/init_spec.rb b/spec/unit/init_spec.rb new file mode 100644 index 0000000..7fdb670 --- /dev/null +++ b/spec/unit/init_spec.rb @@ -0,0 +1,52 @@ +require "spec_helper" + +describe "Hatchet::Init" do + def fake_buildpack_dir + Dir.mktmpdir do |dir| + FileUtils.mkdir_p("#{dir}/bin") + yield dir + end + end + it "raises an error when not pointing at the right directory" do + Dir.mktmpdir do |dir| + expect { + Hatchet::InitProject.new(dir: dir) + }.to raise_error(/Must run in a directory with a buildpack/) + end + end + + # write_target(target: ".circleci/config.yml", template: "circleci_template.erb") + # write_target(target: "Gemfile", template: "Gemfile.erb") + # write_target(target: "hatchet.json", contents: "{}") + # write_target(target: "hatchet.lock", contents: YAML.dump({})) + # write_target(target: "spec/spec_helper.rb", template: "spec_helper.erb") + # write_target(target: "spec/hatchet/buildpack_spec.rb", template: "buildpack_spec.erb") + # write_target(target: ".github/dependabot.yml", template: "dependabot.erb") + + it "generates files" do + fake_buildpack_dir do |dir| + fake_stdout = StringIO.new + init = Hatchet::InitProject.new(dir: dir, io: fake_stdout) + init.call + + circle_ci_file = Pathname.new(dir).join(".circleci/config.yml") + expect(circle_ci_file.read).to match("parallel_split_test") + + %W{ + .circleci/config.yml + Gemfile + hatchet.json + hatchet.lock + spec/spec_helper.rb + spec/hatchet/buildpack_spec.rb + .github/dependabot.yml + .github/workflows/check_changelog.yml + .gitignore + }.each do |path| + expect(Pathname.new(dir).join(path)).to exist + end + + expect(fake_stdout.string).to match("Bundle complete") + end + end +end