Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bootstrap a buildpack with hatchet init #123

Merged
merged 1 commit into from
Sep 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions bin/hatchet
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions lib/hatchet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions lib/hatchet/init_project.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions lib/hatchet/templates/Gemfile.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
source "https://rubygems.org"

gem "parallel_split_test"
gem "heroku_hatchet"
gem "rspec-retry"
23 changes: 23 additions & 0 deletions lib/hatchet/templates/buildpack_spec.erb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions lib/hatchet/templates/check_changelog.erb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions lib/hatchet/templates/circleci_template.erb
Original file line number Diff line number Diff line change
@@ -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"

9 changes: 9 additions & 0 deletions lib/hatchet/templates/dependabot.erb
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions lib/hatchet/templates/hatchet_json.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"ruby_apps": [
"sharpstone/default_ruby"
],
"node_apps": [
"heroku/node-js-getting-started"
],
"python_apps": [
"heroku/python-getting-started"
]
}
30 changes: 30 additions & 0 deletions lib/hatchet/templates/spec_helper.erb
Original file line number Diff line number Diff line change
@@ -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

52 changes: 52 additions & 0 deletions spec/unit/init_spec.rb
Original file line number Diff line number Diff line change
@@ -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