Skip to content

Commit

Permalink
Merge pull request #30 from jacobthemyth/dual-boot-ruby
Browse files Browse the repository at this point in the history
Patch bundler to allow different rubies
  • Loading branch information
Edouard-chin authored Jan 10, 2020
2 parents 87d0707 + 7aa5549 commit fb502ad
Show file tree
Hide file tree
Showing 5 changed files with 246 additions and 6 deletions.
115 changes: 113 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ No matter what approach you decide to take, you'll need to create tooling to ens

Bootboot is only useful if you decide to follow the second approach. It creates the required tooling as well as the Bundler workaround needed to enable dual booting.



Installation
------------
1) In your Gemfile, add this
Expand All @@ -57,6 +55,119 @@ Dual boot it!
------------
If you want to boot using the dependencies from the `Gemfile_next.lock`, run any bundler command prefixed with the `DEPENDENCIES_NEXT=1` ENV variable. I.e. `DEPENDENCIES_NEXT=1 bundle exec irb`.

**Note:** `bootboot` will use the gems and Ruby version specified per environment in your `Gemfile` to resolve dependencies and keep `Gemfile.lock` and `Gemfile_next.lock` in sync, but it does not do any magic to actually change the running Ruby version or install the gems in the environment you are not currently running, it simply tells Bundler which Ruby and gem versions to use in its resolution algorithm and keeps the lock files in sync. If you are a developer who is not involved in updating the dependency set, this should not affect you, simply use bundler normally. _However_, if you are involved in the dependency changes directly, you will often have to run `DEPENDENCIES_NEXT=1 bundle install` after making changes to the dependencies.

```sh
# This will update Gemfile.lock and Gemfile_next.lock and install the gems
# specified in Gemfile.lock:
$ bundle update some_gem
# This will actually install the gems specified in Gemfile_next.lock
$ DEPENDENCIES_NEXT=1 bundle install
```

Dual boot different Ruby versions
---------------------------------

While dual booting is often used for framework upgrades, it is also possible to use `bootboot` to dual boot two Ruby versions, each with its own set of gems.

```ruby
# Gemfile

if ENV['DEPENDENCIES_NEXT']
ruby '2.6.5'
else
ruby '2.5.7'
end
```

Dual booting Ruby versions does incur some additional complications however, see the examples following for more detail.

Example: updating a gem while dual booting Ruby versions
--------------------------------------------------------

To dual boot an app while upgrading from Ruby 2.5.7 to Ruby 2.6.5, your Gemfile would look like this:

```ruby
# Gemfile

if ENV['DEPENDENCIES_NEXT']
ruby '2.6.5'
else
ruby '2.5.7'
end
```

After running `bundle install`, `Gemfile.lock` will have:

```
RUBY VERSION
ruby 2.5.7p206
```

and `Gemfile_next.lock` will have:

```
RUBY VERSION
ruby 2.6.5p114
```
Assuming there's a gem `some_gem` with the following constraints in its gemspecs:

```ruby
# some_gem-1.0.gemspec
spec.version = "1.0"
spec.required_ruby_version = '>= 2.5.7'
```

```ruby
# some_gem-2.0.gemspec
spec.version = "2.0"
spec.required_ruby_version = '>= 2.6.5'
```

Running `bundle update some_gem` will use Ruby 2.5.7 to resolve `some_gem` for `Gemfile.lock` and Ruby 2.6.5 to resolve `some_gem` for `Gemfile_next.lock` with the following results:

Gemfile.lock:
```
specs:
some_gem (1.0)
```

Gemfile_next.lock:
```
specs:
some_gem (2.0)
```

**Note:** It is important to note that at this point, `some_gem 2.0` **will not** be installed on your system, it will simply be specified in `Gemfile_next.lock`, since installing it on the system would require changing the running Ruby version. This is sufficient to keep `Gemfile_next.lock` in sync, but is a potential source of confusion. To install gems under both versions of Ruby, see the next section.

### Example: running Ruby scripts while dual booting Ruby versions

When running Ruby scripts while dual booting two different Ruby versions, you have to remember to do two things simultaneously for every command:
- Run the command with the correct version of Ruby
- Add the DEPENDENCIES_NEXT environment variable to tell bundler to use `Gemfile_next.lock`

So to run a spec in both versions, the workflow would look like this (assuming chruby for version management):

```sh
$ chruby 2.5.7
$ bundle exec rspec spec/some_spec.rb
$ chruby 2.6.5
$ DEPENDENCIES_NEXT=1 bundle exec rspec spec/some_spec.rb
```

Perhaps more importantly, to update or install a gem, the workflow would look like this:

```sh
# This will update Gemfile.lock and Gemfile_next.lock and install the gems
# specified in Gemfile.lock:
$ chruby 2.5.7
$ bundle update some_gem
# This will actually install the gems specified in Gemfile_next.lock under the
# correct Ruby installation:
$ chruby 2.6.5
$ DEPENDENCIES_NEXT=1 bundle install
```

Configuration (Optional)
------------------------
By default Bootboot will use the `DEPENDENCIES_NEXT` environment variable to update your Gemfile_next.lock. You can however configure it. For example, if you want the dualboot to happen when the `SHOPIFY_NEXT` env variable is present, you simply have to add this in your Gemfile:
Expand Down
32 changes: 31 additions & 1 deletion lib/bootboot/bundler_patch.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# frozen_string_literal: true

require "bootboot/ruby_source"

module DefinitionPatch
def initialize(wrong_lock, *args)
lockfile = if ENV['SKIP_BUNDLER_PATCH']
lockfile = if ENV['BOOTBOOT_UPDATING_ALTERNATE_LOCKFILE']
wrong_lock
else
Bootboot::GEMFILE_NEXT_LOCK
Expand All @@ -12,12 +14,40 @@ def initialize(wrong_lock, *args)
end
end

module RubyVersionPatch
def system
if ENV['BOOTBOOT_UPDATING_ALTERNATE_LOCKFILE']
# If we're updating the alternate file and the ruby version specified in
# the Gemfile is different from the Ruby version currently running, we
# want to write the version specified in `Gemfile` for the current
# dependency set to the lock file
Bundler::Definition.build(Bootboot::GEMFILE, nil, false).ruby_version || super
else
super
end
end
end

module DefinitionSourceRequirementsPatch
def source_requirements
super.tap do |source_requirements|
# Bundler has a hard requirement that Ruby should be in the Metadata
# source, so this replaces Ruby's Metadata source with our custom source
source = Bootboot::RubySource.new({})
source_requirements[source.ruby_spec_name] = source
end
end
end

module SharedHelpersPatch
def default_lockfile
Bootboot::GEMFILE_NEXT_LOCK
end
end

Bundler::Definition.prepend(DefinitionSourceRequirementsPatch)
Bundler::RubyVersion.singleton_class.prepend(RubyVersionPatch)

Bundler::Dsl.class_eval do
def enable_dual_booting
Bundler::Definition.prepend(DefinitionPatch)
Expand Down
4 changes: 2 additions & 2 deletions lib/bootboot/gemfile_next_auto_sync.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ def update!(current_definition)

Bundler.ui.confirm("Updating the #{lock}")
ENV[env] = '1'
ENV['SKIP_BUNDLER_PATCH'] = '1'
ENV['BOOTBOOT_UPDATING_ALTERNATE_LOCKFILE'] = '1'

unlock = current_definition.instance_variable_get(:@unlock)
definition = Bundler::Definition.build(GEMFILE, lock, unlock)
definition.resolve_remotely!
definition.lock(lock)
ensure
ENV.delete(env)
ENV.delete('SKIP_BUNDLER_PATCH')
ENV.delete('BOOTBOOT_UPDATING_ALTERNATE_LOCKFILE')
end

def which_env
Expand Down
39 changes: 39 additions & 0 deletions lib/bootboot/ruby_source.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module Bootboot
class RubySource
include Bundler::Plugin::API::Source

# The spec name for Ruby changed from "ruby\0" to "Ruby\0" between Bundler
# 1.17 and 2.0, so we want to use the Ruby spec name from Metadata so
# Bootboot works across Bundler versions
def ruby_spec_name
@ruby_spec_name ||= begin
metadata = Bundler::Source::Metadata.new
ruby_spec = metadata.specs.find { |s| s.name[/[R|r]uby\0/] }
# Default to Bundler > 2 in case the Bundler internals change
ruby_spec ? ruby_spec.name : "Ruby\0"
end
end

def specs
Bundler::Index.build do |idx|
# If the ruby version specified in the Gemfile is different from the
# Ruby version currently running, we want to build a definition without
# a lockfile (so that `ruby_version` in the Gemfile isn't overridden by
# the lockfile) and get its `ruby_version`. This will be used both
# during dependency resolution so that we can pretend the intended Ruby
# version is present, as well as when updating the lockfile itself.
ruby_version = Bundler::Definition.build(Bootboot::GEMFILE, nil, false).ruby_version
ruby_version ||= Bundler::RubyVersion.system
ruby_spec = Gem::Specification.new(ruby_spec_name, ruby_version.to_gem_version_with_patchlevel)
ruby_spec.source = self
idx << ruby_spec
end
end

def to_s
"Bootboot plugin Ruby source"
end
end
end
62 changes: 61 additions & 1 deletion test/bootboot_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,56 @@ def test_bootboot_command_initialize_the_next_lock_and_update_the_gemfile
end
end

def test_bundle_install_with_different_ruby_updating_gemfile_next_lock_succeeds
write_gemfile do |file, _dir|
FileUtils.cp("#{file.path}.lock", gemfile_next(file))
File.write(file, <<-EOM, mode: 'a')
if ENV['DEPENDENCIES_NEXT']
ruby '9.9.9'
else
ruby '#{RUBY_VERSION}'
end
EOM

run_bundler_command('bundle install', file.path)

assert_equal(
RUBY_VERSION,
Bundler::Definition.build(
file.path, "#{file.path}.lock", false
).locked_ruby_version_object.gem_version.to_s
)

with_env_next do
assert_equal(
"9.9.9",
Bundler::Definition.build(
file.path, gemfile_next(file), false
).locked_ruby_version_object.gem_version.to_s
)
end
end
end

def test_bundle_install_with_different_ruby_for_installing_gemfile_next_lock_fails
write_gemfile do |file, _dir|
FileUtils.cp("#{file.path}.lock", gemfile_next(file))
File.write(file, <<-EOM, mode: 'a')
if ENV['DEPENDENCIES_NEXT']
ruby '9.9.9'
else
ruby '#{RUBY_VERSION}'
end
EOM

error = assert_raises BundleInstallError do
run_bundler_command('bundle install', file.path, env: { Bootboot.env_next => '1' })
end

assert_match("Your Ruby version is #{RUBY_VERSION}, but your Gemfile specified 9.9.9", error.message)
end
end

private

def gemfile_next(gemfile)
Expand Down Expand Up @@ -244,13 +294,23 @@ def plugin
"plugin 'bootboot', git: '#{Bundler.root}', branch: '#{branch}'"
end

class BundleInstallError < StandardError; end

def run_bundler_command(command, gemfile_path, env: {})
output = nil
Bundler.with_unbundled_env do
output, status = Open3.capture2e({ 'BUNDLE_GEMFILE' => gemfile_path }.merge(env), command)

raise StandardError, "bundle install failed: #{output}" unless status.success?
raise BundleInstallError, "bundle install failed: #{output}" unless status.success?
end
output
end

def with_env_next
prev = ENV[Bootboot.env_next]
ENV[Bootboot.env_next] = "1"
yield
ensure
ENV[Bootboot.env_next] = prev
end
end

0 comments on commit fb502ad

Please sign in to comment.