Skip to content

Commit

Permalink
Ruby 3.3.0-preview2 take 3 (#34)
Browse files Browse the repository at this point in the history
* ## Binary and buildpack Context

Here's how we provide build ruby binaries. First this repo will:

- Download a ruby source tarball
- Build it into a binary (`make install` etc.)
- Zip/tar that binary up and upload it to S3 (filename is coupled to buildpack logic)

Then later the buildpack has to:

- Take the output of `bundle platform --ruby` and turn that into an S3 url
- Download and unzip that tarball and place it on the path

That means that this repo is coupled to:

- Ruby source conventions (like filenames)
- The bundler output (`bundle platform --ruby`)
- Any logic the buildpack uses to convert `bundle platform --ruby` to a download URL

Another big piece of context is that this is the first year we're trying fully automated binary build and upload steps. I knew when I automated the regular builds that we would likely have to come back to this logic. In prior years we've been able to manually adjust file names which meant inconsistencies were worked around manually.

## First problem - `ruby-3.3.0-preview2.tgz`

I've tried to release Ruby 3.3.0-preview2 twice now. The first attempt changed no logic for the binary builder or the buildpack. It produced a file that was uploaded to S3:

```
$ curl -I  https://heroku-buildpack-ruby.s3.us-east-1.amazonaws.com/heroku-22/ruby-3.3.0-preview2.tgz
HTTP/1.1 200 OK
```

This doesn't work for us for several reasons. You cannot actually run `bundle install` with Ruby 3.3.0-preview2 locally or you would get an error:

```
$ cat Gemfile | grep ruby
ruby '3.3.0-preview2'
$ bundle install
Your Ruby version is 3.3.0.preview2, but your Gemfile specified 3.3.0.pre.preview2
```

Note that it says there's a difference in the input string in the Gemfile and what bundler thinks we put in our gemfile (`-preview2` versus `.pre.preview2`). This is due to bundler replacing the dash with `.pre.`.

## Second problem `ruby-3.3.0.tgz`

When we started supporting prerelease versions of Ruby we used to use the eventual version for pre-releases. So in that scenario Ruby 3.3.0-preview2 would be uploaded to `ruby-3.3.0.tgz`. Then we asked people to put that version in their Gemfile like `ruby "3.3.0"`. Seeing problem number one, I recalled this era and thought we had some edgecase tooling for it.

However, this strategy of using the plain version stopped working with Ruby 3.2 when bundler was checking and erroring on Ruby versions and a pre-release version. We maintained that strategy until Ruby 3.1, see a  3.1 changelog (https://devcenter.heroku.com/changelog-items/2292).

In Ruby 3.2 we could not longer ask people to use `3.2.0` to work around limitations in bundler (that it does not recognize the dash). Our solution below looks a lot like it did for our solution in Ruby 3.2 where we asked people to put an extra specifier in the Gemfile like `ruby "3.2.0.preview3"` (https://devcenter.heroku.com/changelog-items/2499). Note that this is a different string than `3.3.0-preview2` (dot, which is what bundler needs versus a dash which is the source file from the ruby ftp site).

Without recalling this change I put in the work to automate generation of a `ruby-3.3.0.tgz` file which uploaded fine:

```
$ curl -I  https://heroku-buildpack-ruby.s3.us-east-1.amazonaws.com/heroku-22/ruby-3.3.0-preview2.tgz
HTTP/1.1 200 OK
```

And even can be downloaded fine if you put `ruby "3.3.0"` in a Gemfile (and do not try installing locally). However you'll get an error when you try to deploy:

```
remote: -----> Using Ruby version: ruby-3.3.0
remote: -----> Installing dependencies using bundler 2.3.25
remote:        Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4
remote:        Your Ruby version is 3.3.0.preview2, but your Gemfile specified ~> 3.3.0
remote:        Bundler Output: Your Ruby version is 3.3.0.preview2, but your Gemfile specified ~> 3.3.0
remote:
```

In essence I had to re-learn what I had already learned last year (in the changelog above) that `ruby "3.2.0.preview3"` will work with both bundler and the buildpack. (Note that we're using a dot instead of a dash). You can see that's the name of the binary on S3:

```
$ curl -I  https://heroku-buildpack-ruby.s3.us-east-1.amazonaws.com/heroku-22/ruby-3.2.0.preview3.tgz
HTTP/1.1 200 OK
```

This year I refactored and re-wrote the binary build system to add tests (previously there were none) and learn how it all works. Last year I only observed inputs and outputs. I think what happened is that I manually changed the filename to match the output of `bundle platform --ruby` when running those versions locally and just matched the file and instructions to that. I didn't put in the time to understand the intricies of the behavior of all the systems involved.

This year, I correctly recalled that there was an edgecase, and remembered one of the solutions, but I failed to remember that specific solution didn't work with newer bundler versions. 

Instead of simply getting things working, I'm investing in understanding and writing down WHY some of this behavior exists and where certain limitations are coming from.

## Here we are today

That brings us to this PR. The solution looks a lot like Ruby 3.2. We will ask customers to put `ruby "3.3.0.preview2" in their Gemfile. Which works locally without raising a bundler error:

```
$ bundle exec rake "generate_image[heroku-22]"
$ bash rubies/heroku-22/ruby-3.3.0-preview2.sh
$ docker run -it -v $(pwd)/builds/heroku-22:/tmp/output hone/ruby-builder:heroku-22  bash
root@440d92753881:/# mkdir /tmp/unzipped && tar xzf /tmp/output/ruby-3.3.0.preview2.tgz -C /tmp/unzipped &&
export PATH="/tmp/unzipped/bin/:$PATH" &&
echo "ruby '3.3.0.preview2'" > Gemfile &&
bundle install
Don't run Bundler as root. Installing your bundle as root will break this application for all non-root users on this machine.
[DEPRECATED] This Gemfile does not include an explicit global source. Not using an explicit global source may result in a different lockfile being generated depending on the gems you have installed locally before bundler is run. Instead, define a global source in your Gemfile like this: source "https://rubygems.org".
The Gemfile specifies no dependencies
Resolving dependencies...
Bundle complete! 0 Gemfile dependencies, 1 gem now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
```

And importantly it produces the same version output:

```
$ bundle platform --ruby
ruby 3.3.0.preview2
```

While it's not revelatory, it is not automated and tested. I also wrote the system to convert these version strings between the ruby source download and bundler version strings. That means that a Heroku developer using `3.3.0-preview2` or `3.3.0.preview2` will get the same output (correct) result uploaded to S3 and a working changelog to go with it.

* Remove unused method
  • Loading branch information
schneems authored Oct 2, 2023
1 parent f0fca3c commit 81e0ac4
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 114 deletions.
2 changes: 1 addition & 1 deletion .standard.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ruby_version: 3.1
ruby_version: 2.7 # System Ruby on heroku-20 is 2.7
ignore:
- 'builds/**/*'
- 'rubies/**/*'
Expand Down
2 changes: 1 addition & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ end
desc "Emits a changelog message"
task :changelog, [:version] do |_, args|
Changelog.new(
ruby_version: RubyVersion.new(args[:version])
parts: VersionParts.new(args[:version])
).call
end

Expand Down
112 changes: 51 additions & 61 deletions lib/build_script.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ def run_build_script(
ruby_version: ENV.fetch("STACK")
)

parts = VersionParts.new(ruby_version)
ruby_version = RubyVersion.new(ruby_version)

# The destination location of the built ruby version is the `prefix`
prefix = Pathname("/app/vendor/#{ruby_version.plain_file_name}")
io.puts "Using prefix: #{prefix}"
# The directory where ruby source will be downloaded
ruby_source_dir = Pathname(".")

# create cache dir if it doesn't exist
FileUtils.mkdir_p(cache_dir)
Expand All @@ -47,32 +47,45 @@ def run_build_script(
ruby_version: ruby_version
)

download_to_cache(
tar_file = download_to_cache(
io: io,
cache_dir: cache_dir,
ruby_version: ruby_version
download_url: DownloadRuby.new(parts: parts).url
)

build(
io: io,
stack: stack,
prefix: prefix,
cache_dir: cache_dir,
ruby_version: ruby_version
untar_to_dir(
tar_file: tar_file,
dest_directory: ruby_source_dir
)

fix_binstubs_in_dir(
io: io,
dir: prefix.join("bin")
)
Dir.mktmpdir do |tmp_dir|
# The directory where Ruby will be built into
ruby_binary_dir = Pathname(tmp_dir).join("prefix")

move_to_output(
io: io,
stack: stack,
prefix: prefix,
output_dir: output_dir,
ruby_version: ruby_version
)
build(
io: io,
ruby_version: ruby_version,
destination_dir: ruby_binary_dir,
ruby_source_dir: ruby_source_dir
)

fix_binstubs_in_dir(
io: io,
dir: ruby_binary_dir.join("bin")
)

destination = Pathname(output_dir)
.join(stack)
.tap(&:mkpath)
.join(ruby_version.tar_file_name_output)

io.puts "Writing #{destination}"
tar_dir(
io: io,
dir_to_tar: ruby_binary_dir,
destination_file: destination
)
end
end

# Runs a command on the command line and streams the results
Expand Down Expand Up @@ -100,59 +113,36 @@ def check_version_on_stack(ruby_version:, stack:)
end

# Downloads the given ruby version into the cache direcory
def download_to_cache(cache_dir:, ruby_version:, io: $stdout)
Dir.chdir(cache_dir) do
url = ruby_version.download_url
uri = URI.parse(url)
filename = uri.to_s.split("/").last

io.puts "Downloading #{url}"

if File.exist?(filename)
io.puts "Using #{filename}"
else
io.puts "Fetching #{filename}"
run!("curl #{uri} -s -O")
end
#
# Returns a path to the file just downloaded
def download_to_cache(cache_dir:, download_url:, io: $stdout)
file = Pathname(cache_dir).join(download_url.split("/").last)

if file.exist?
io.puts "Using cached #{file} (instead of downloading #{download_url})"
else
io.puts "Fetching #{file} (from #{download_url})"
run!("curl #{download_url} -s -o #{file}")
end

file
end

# Compiles the ruby program and puts it into `prefix`
def build(stack:, prefix:, cache_dir:, ruby_version:, jobs: DEFAULT_JOBS, io: $stdout)
build_dir = Pathname(".")
untar_to_dir(
tar_file: Pathname(cache_dir).join("#{ruby_version.plain_file_name}.tar.gz"),
dest_directory: build_dir
)

# input a tar file
def build(ruby_source_dir:, destination_dir:, ruby_version:, jobs: DEFAULT_JOBS, io: $stdout)
# Move into the directory we just unziped and run `make`
# We tell make where to put the result with the `prefix` argument
Dir.chdir(build_dir.join(ruby_version.plain_file_name)) do
Dir.chdir(ruby_source_dir.join(ruby_version.ruby_source_dir_name)) do
command = make_commands(
jobs: jobs,
prefix: prefix,
prefix: destination_dir,
ruby_version: ruby_version
)
pipe(command)
end
end

# After a ruby is compiled, this function will move it to the directory
# that docker was given so it's available when the container exits
def move_to_output(output_dir:, stack:, ruby_version:, prefix:, io: $stdout)
destination = Pathname(output_dir)
.join(stack)
.tap { |path| path.mkpath }
.join(ruby_version.tar_file_name_output)

io.puts "Writing #{destination}"
tar_dir(
io: io,
dir_to_tar: prefix,
destination_file: destination
)
end

# Generates the `make` commands that will build ruby
# this is split up from running the commands to make testing easiers
def make_commands(prefix:, ruby_version:, jobs: DEFAULT_JOBS, io: $stdout)
Expand Down
18 changes: 9 additions & 9 deletions lib/changelog.rb
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
class Changelog
private attr_reader :io, :ruby_version
private attr_reader :io, :parts

def initialize(ruby_version:, io: $stdout)
def initialize(parts:, io: $stdout)
@io = io
@ruby_version = ruby_version
@parts = parts
end

def call
io.puts "Add a changelog item: https://devcenter.heroku.com/admin/changelog_items/new"

io.puts <<~EOM
## Ruby version #{ruby_version.raw_version} is now available
## Ruby version #{parts.download_format} is now available
[Ruby v#{ruby_version.raw_version}](/articles/ruby-support#ruby-versions) is now available on Heroku. To run
[Ruby v#{parts.download_format}](/articles/ruby-support#ruby-versions) is now available on Heroku. To run
your app using this version of Ruby, add the following `ruby` directive to your Gemfile:
```ruby
ruby "#{ruby_version.major_minor_patch}"
ruby "#{parts.bundler_format}"
```
For more information on [Ruby #{ruby_version.raw_version}, you can view the release announcement](https://www.ruby-lang.org/en/news/).
For more information on [Ruby #{parts.download_format}, you can view the release announcement](https://www.ruby-lang.org/en/news/).
EOM

if ruby_version.preview?
if parts.pre.length > 0
io.puts <<~EOF
Note: This version of Ruby is not suitable for production applications.
However, it can be used to test that your application is ready for
the official release of Ruby #{ruby_version.major_minor_patch} and
the official release of Ruby #{parts.major}.#{parts.minor}.#{parts.patch} and
to provide feedback to the Ruby core team.
EOF
end
Expand Down
55 changes: 25 additions & 30 deletions lib/ruby_version.rb
Original file line number Diff line number Diff line change
@@ -1,46 +1,41 @@
require_relative "version_parts"

class RubyVersion
# Uses def <=> to implement >=, <=, etc.
include Comparable

# Returns a file name without the extension (no direcory)
attr_reader :plain_file_name
attr_reader :parts
private :parts

# Full URL of the ruby binary on ruby-lang (if it exists)
attr_reader :download_url
def initialize(version = ENV.fetch("VERSION"))
@parts = VersionParts.new(version)
end

# Returns file name with tar extension (no directory)
# Returns file name with tar extension (but no directory)
# This is the file name that will be uploaded to Heroku
#
# Preview and release candidates are output as their
# major.minor.patch (without the `-preview` suffix)
attr_reader :tar_file_name_output

# Version without an extra bits at the end
attr_reader :major_minor_patch

attr_reader :raw_version

def initialize(version = ENV.fetch("VERSION"))
@raw_version = version

parts = version.split(".")
major = parts.shift
minor = parts.shift
patch = parts.shift.match(/\d+/)[0]
# e.g. "ruby-3.1.4.tgz"
def tar_file_name_output
"ruby-#{parts.bundler_format}.tgz"
end

@major_minor_patch = "#{major}.#{minor}.#{patch}"
@plain_file_name = "ruby-#{@raw_version}"
@download_url = "https://ftp.ruby-lang.org/pub/ruby/#{major}.#{minor}/#{@plain_file_name}.tar.gz"
# Ruby packages their source with a top level directory matching the name of the download file
# see the docs in `tar_and_untar.rb` for more details on expected tar formats
def ruby_source_dir_name
"ruby-#{parts.download_format}"
end

@tar_file_name_output = "ruby-#{major}.#{minor}.#{patch}.tgz"
@compare_version = Gem::Version.new(raw_version)
def <=>(other)
Gem::Version.new(parts.bundler_format) <=> Gem::Version.new(other)
end
end

def preview?
@raw_version != @major_minor_patch
class DownloadRuby
def initialize(parts:)
@parts = parts
end

def <=>(other)
@compare_version <=> Gem::Version.new(other)
def url
"https://ftp.ruby-lang.org/pub/ruby/#{@parts.major}.#{@parts.minor}/ruby-#{@parts.download_format}.tar.gz"
end
end
80 changes: 80 additions & 0 deletions lib/version_parts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Normalize Ruby versions
#
# Released ruby versions have a "major.minor.patch" and nothing else.
# Prerelease ruby versions have "major.minor.patch" and a trailing identifier
# for example "3.3.0-preview3".
#
# Ruby stores these versions on its download server using a dash for example:
# https://ftp.ruby-lang.org/pub/ruby/3.3/ruby-3.3.0-preview2.tar.gz
#
# However once you install that version and run `ruby -v` you get a different
# representation:
#
# ```
# $ ruby -v
# ruby 3.3.0preview2 (2023-09-14 master e50fcca9a7) [x86_64-linux]
# ```
#
# And it's in yet another representation in bundler:
#
# ```
# $ cat Gemfile.lock | grep RUBY -A 2
# RUBY VERSION
# ruby 3.3.0.preview2
# ```
#
# This format comes from this logic https://github.com/rubygems/rubygems/blob/85edf547391043ddd9ff21d8426c9dd5903435b2/lib/rubygems.rb#L858-L875
#
# Note that:
#
# - Download ruby has a dash (`-`) seperator
# - Version output from `ruby -v` has no separator
# - Bundler uses a dot (`.`) separator
#
# We need to round trip:
#
# - Download a ruby source tarball
# - Build it into a binary (`make install` etc.)
# - Zip/tar that binary up and upload it to S3 (filename is coupled to buildpack logic)
#
# Then later the buildpack has to:
#
# - Take the output of `bundle platform` and turn that into an S3 url
# - Download and unzip that tarball and place it on the path
#
# For this to function we care about:
#
# - Download format (because we need to get the source from the ftp site)
# - Bundler format (because `bundle platform` output is how we lookup the donload,
# therefore it's the format we must use to zip/tar the file).
#
# This class can take in a version string containing:
#
# - Ruby version without pre-release information
# - Ruby version with pre-release in download format
# - Ruby version with pre-release in bundler format
#
# And it will normalize the format to be consistent
class VersionParts
attr_reader :major, :minor, :patch, :separator, :pre

# Normalize a version string with an optional pre-release
def initialize(version)
# https://rubular.com/r/HgtMk8O0Lscfvv
parts = version.match(/(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?<separator>[-.])?(?<pre>.*)/)

@major = parts[:major] or raise "Does not contain major #{version}: #{parts}"
@minor = parts[:minor] or raise "Does not contain minor #{version}: #{parts}"
@patch = parts[:patch] or raise "Does not contain patch #{version}: #{parts}"
@separator = parts[:separator] || ""
@pre = parts[:pre] || ""
end

def download_format
"#{major}.#{minor}.#{patch}#{separator.empty? ? "" : "-"}#{pre}"
end

def bundler_format
"#{major}.#{minor}.#{patch}#{separator.empty? ? "" : "."}#{pre}"
end
end
6 changes: 3 additions & 3 deletions spec/unit/changelog_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
io = StringIO.new
Changelog.new(
io: io,
ruby_version: RubyVersion.new("3.1.2")
parts: VersionParts.new("3.1.2")
).call

expect(io.string).to eq(<<~'EOF')
Expand All @@ -29,7 +29,7 @@
io = StringIO.new
Changelog.new(
io: io,
ruby_version: RubyVersion.new("3.3.0-preview2")
parts: VersionParts.new("3.3.0-preview2")
).call

expect(io.string).to eq(<<~'EOF')
Expand All @@ -41,7 +41,7 @@
your app using this version of Ruby, add the following `ruby` directive to your Gemfile:
```ruby
ruby "3.3.0"
ruby "3.3.0.preview2"
```
For more information on [Ruby 3.3.0-preview2, you can view the release announcement](https://www.ruby-lang.org/en/news/).
Expand Down
Loading

0 comments on commit 81e0ac4

Please sign in to comment.