diff --git a/.standard.yml b/.standard.yml index ee6f7e8..081c4b3 100644 --- a/.standard.yml +++ b/.standard.yml @@ -1,4 +1,4 @@ -ruby_version: 3.1 +ruby_version: 2.7 # System Ruby on heroku-20 is 2.7 ignore: - 'builds/**/*' - 'rubies/**/*' diff --git a/Rakefile b/Rakefile index 8718663..8996475 100644 --- a/Rakefile +++ b/Rakefile @@ -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 diff --git a/lib/build_script.rb b/lib/build_script.rb index ad46838..a9d072a 100644 --- a/lib/build_script.rb +++ b/lib/build_script.rb @@ -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) @@ -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 @@ -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) diff --git a/lib/changelog.rb b/lib/changelog.rb index 5d1eb6c..1829f86 100644 --- a/lib/changelog.rb +++ b/lib/changelog.rb @@ -1,9 +1,9 @@ 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 @@ -11,24 +11,24 @@ def call 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 diff --git a/lib/ruby_version.rb b/lib/ruby_version.rb index 4c5e16a..d58404d 100644 --- a/lib/ruby_version.rb +++ b/lib/ruby_version.rb @@ -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 diff --git a/lib/version_parts.rb b/lib/version_parts.rb new file mode 100644 index 0000000..a993c8e --- /dev/null +++ b/lib/version_parts.rb @@ -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(/(?\d+)\.(?\d+)\.(?\d+)(?[-.])?(?
.*)/)
+
+    @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
diff --git a/spec/unit/changelog_spec.rb b/spec/unit/changelog_spec.rb
index 588b4a4..daf9895 100644
--- a/spec/unit/changelog_spec.rb
+++ b/spec/unit/changelog_spec.rb
@@ -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')
@@ -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')
@@ -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/).
diff --git a/spec/unit/docker_command_spec.rb b/spec/unit/docker_command_spec.rb
index aac2818..c23ccf1 100644
--- a/spec/unit/docker_command_spec.rb
+++ b/spec/unit/docker_command_spec.rb
@@ -10,7 +10,7 @@
 
   it "works with preview releases" do
     actual = DockerCommand.gem_version_from_tar(ruby_version: RubyVersion.new("3.3.0-preview2"), stack: "heroku-22")
-    expected = %{docker run -v $(pwd)/builds/heroku-22:/tmp/output hone/ruby-builder:heroku-22 bash -c "mkdir /tmp/unzipped && tar xzf /tmp/output/ruby-3.3.0.tgz -C /tmp/unzipped && echo 'Rubygems version is: ' && /tmp/unzipped/bin/gem -v"}
+    expected = %{docker run -v $(pwd)/builds/heroku-22:/tmp/output hone/ruby-builder:heroku-22 bash -c "mkdir /tmp/unzipped && tar xzf /tmp/output/ruby-3.3.0.preview2.tgz -C /tmp/unzipped && echo 'Rubygems version is: ' && /tmp/unzipped/bin/gem -v"}
     expect(actual).to eq(expected)
   end
 end
diff --git a/spec/unit/ruby_version_spec.rb b/spec/unit/ruby_version_spec.rb
index f14123d..23f0691 100644
--- a/spec/unit/ruby_version_spec.rb
+++ b/spec/unit/ruby_version_spec.rb
@@ -8,20 +8,16 @@
     expect(RubyVersion.new("3.1.0")).to be < Gem::Version.new("3.2")
   end
 
-  it "knows the filename of a specific version" do
-    expect(RubyVersion.new("3.0.2").plain_file_name).to eq("ruby-3.0.2")
-    expect(RubyVersion.new("2.5.7").plain_file_name).to eq("ruby-2.5.7")
-  end
-
   it "knows the tarball name of a specific version" do
     expect(RubyVersion.new("3.0.2").tar_file_name_output).to eq("ruby-3.0.2.tgz")
     expect(RubyVersion.new("2.5.7").tar_file_name_output).to eq("ruby-2.5.7.tgz")
 
-    expect(RubyVersion.new("3.3.0-preview1").tar_file_name_output).to eq("ruby-3.3.0.tgz")
+    expect(RubyVersion.new("3.3.0-preview1").tar_file_name_output).to eq("ruby-3.3.0.preview1.tgz")
   end
 
   it "knows the full ftp URL" do
-    expect(RubyVersion.new("3.0.2").download_url).to eq("https://ftp.ruby-lang.org/pub/ruby/3.0/ruby-3.0.2.tar.gz")
-    expect(RubyVersion.new("2.5.7").download_url).to eq("https://ftp.ruby-lang.org/pub/ruby/2.5/ruby-2.5.7.tar.gz")
+    expect(DownloadRuby.new(parts: VersionParts.new("3.0.2")).url).to eq("https://ftp.ruby-lang.org/pub/ruby/3.0/ruby-3.0.2.tar.gz")
+    expect(DownloadRuby.new(parts: VersionParts.new("2.5.7")).url).to eq("https://ftp.ruby-lang.org/pub/ruby/2.5/ruby-2.5.7.tar.gz")
+    expect(DownloadRuby.new(parts: VersionParts.new("3.3.0-preview2")).url).to eq("https://ftp.ruby-lang.org/pub/ruby/3.3/ruby-3.3.0-preview2.tar.gz")
   end
 end
diff --git a/spec/unit/version_parts_spec.rb b/spec/unit/version_parts_spec.rb
new file mode 100644
index 0000000..93d7e76
--- /dev/null
+++ b/spec/unit/version_parts_spec.rb
@@ -0,0 +1,18 @@
+require "spec_helper"
+require "version_parts"
+
+describe VersionParts do
+  it "converts between bundler and download formats" do
+    version = VersionParts.new("3.3.0")
+    expect(version.download_format).to eq("3.3.0")
+    expect(version.bundler_format).to eq("3.3.0")
+
+    version = VersionParts.new("3.3.0-preview2")
+    expect(version.download_format).to eq("3.3.0-preview2")
+    expect(version.bundler_format).to eq("3.3.0.preview2")
+
+    version = VersionParts.new("3.3.0.preview2")
+    expect(version.download_format).to eq("3.3.0-preview2")
+    expect(version.bundler_format).to eq("3.3.0.preview2")
+  end
+end