Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
schneems committed Sep 29, 2023
1 parent f0fca3c commit a942051
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 109 deletions.
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
113 changes: 52 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,46 @@ 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")
)

move_to_output(
io: io,
stack: stack,
prefix: prefix,
output_dir: output_dir,
ruby_version: ruby_version
)
Dir.mktmpdir do |tmp_dir|
# The directory where Ruby will be built into
ruby_binary_dir = Pathname(tmp_dir).join("prefix")

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 +114,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 #{file} (#{download_url})"
else
io.puts "Fetching #{filename} (#{download_url})"
run!("curl #{uri} -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
59 changes: 30 additions & 29 deletions lib/ruby_version.rb
Original file line number Diff line number Diff line change
@@ -1,46 +1,47 @@
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
def initialize(version = ENV.fetch("VERSION"))
@parts = VersionParts.new(version)
end

# Full URL of the ruby binary on ruby-lang (if it exists)
attr_reader :download_url
private def parts
@parts
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
# e.g. "ruby-3.1.4.tgz"
def tar_file_name_output
"ruby-#{parts.bundler_format}.tgz"
end

parts = version.split(".")
major = parts.shift
minor = parts.shift
patch = parts.shift.match(/\d+/)[0]
# Returns a file name without the extension (no directory)
def download_file_name
"ruby-#{parts.download_format}"
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
81 changes: 81 additions & 0 deletions lib/version_parts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@

# 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 a942051

Please sign in to comment.