├── Gemfile ├── lib ├── rubygems_plugin.rb └── rubygems │ ├── compiler │ └── version.rb │ ├── commands │ └── compile_command.rb │ └── compiler.rb ├── .gitignore ├── Makefile ├── .github └── workflows │ ├── macos.yml │ ├── ubuntu.yml │ ├── windows.yml │ └── release.yml ├── LICENSE ├── Rakefile ├── gem-compiler.gemspec ├── test └── rubygems │ ├── test_gem_commands_compile_command.rb │ └── test_gem_compiler.rb ├── CHANGELOG.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /lib/rubygems_plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems/command_manager" 4 | Gem::CommandManager.instance.register_command :compile 5 | -------------------------------------------------------------------------------- /lib/rubygems/compiler/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # frozen_string_literal: true 3 | 4 | module Gem 5 | class Compiler 6 | VERSION = "0.9.0" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | Gemfile.lock 7 | InstalledFiles 8 | lib/bundler/man 9 | pkg 10 | rdoc 11 | spec/reports 12 | test/tmp 13 | test/version_tmp 14 | tmp 15 | 16 | # YARD artifacts 17 | .yardoc 18 | _yardoc 19 | doc/ 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RUBY ?= ruby # default name for Ruby interpreter 2 | 3 | .PHONY: default autotest test 4 | default: test 5 | 6 | # `autotest` task uses `watchexec` external dependency: 7 | # https://github.com/mattgreen/watchexec 8 | autotest: 9 | watchexec --exts rb --watch lib --watch test --clear "$(RUBY) -S rake test" 10 | 11 | test: 12 | $(RUBY) -S rake test 13 | -------------------------------------------------------------------------------- /.github/workflows/macos.yml: -------------------------------------------------------------------------------- 1 | name: macOS 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | runs-on: macos-latest 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.0' 18 | - '2.7' 19 | - '2.6' 20 | - '2.5' 21 | - 'head' 22 | steps: 23 | - uses: actions/checkout@v2.3.4 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1.64.1 26 | with: 27 | ruby-version: ${{ matrix.ruby }} 28 | bundler-cache: true 29 | - name: Run test 30 | run: | 31 | rake test 32 | - name: Run packaging 33 | run: | 34 | rake package 35 | gem install --local pkg/gem-compiler-*.gem 36 | -------------------------------------------------------------------------------- /.github/workflows/ubuntu.yml: -------------------------------------------------------------------------------- 1 | name: Ubuntu 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.0' 18 | - '2.7' 19 | - '2.6' 20 | - '2.5' 21 | - 'head' 22 | steps: 23 | - uses: actions/checkout@v2.3.4 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1.64.1 26 | with: 27 | ruby-version: ${{ matrix.ruby }} 28 | bundler-cache: true 29 | - name: Run test 30 | run: | 31 | rake test 32 | - name: Run packaging 33 | run: | 34 | rake package 35 | gem install --local pkg/gem-compiler-*.gem 36 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: Windows 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.0' 18 | - '2.7' 19 | - '2.6' 20 | - '2.5' 21 | - 'head' 22 | steps: 23 | - uses: actions/checkout@v2.3.4 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1.64.1 26 | with: 27 | ruby-version: ${{ matrix.ruby }} 28 | bundler-cache: true 29 | - name: Run test 30 | run: | 31 | rake test 32 | - name: Run packaging 33 | run: | 34 | rake package 35 | gem install --local pkg/gem-compiler-*.gem 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Luis Lavena 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | name: Build + Publish 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby: 15 | # use always oldest supported version 16 | - '2.5' 17 | steps: 18 | - uses: actions/checkout@v2.3.4 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1.64.1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | - name: Build gem 25 | run: | 26 | rake package 27 | - name: Publish to RubyGems 28 | run: | 29 | mkdir -p $HOME/.gem 30 | touch $HOME/.gem/credentials 31 | chmod 0600 $HOME/.gem/credentials 32 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 33 | gem push pkg/gem-compiler-*.gem 34 | env: 35 | GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_AUTH_TOKEN }} 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1.1.4 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | with: 42 | tag_name: ${{ github.ref }} 43 | release_name: ${{ github.ref }} 44 | draft: true 45 | prerelease: false 46 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems/package_task" 4 | 5 | gemspec = Gem::Specification.load("gem-compiler.gemspec") 6 | 7 | Gem::PackageTask.new(gemspec) do |pkg| 8 | end 9 | 10 | desc "Environment information" 11 | task :info do 12 | puts "Ruby: #{RUBY_VERSION}" 13 | puts "RubyGems: #{Gem::VERSION}" 14 | puts "$LOAD_PATH: #{$LOAD_PATH.join(File::PATH_SEPARATOR)}" 15 | puts "---" * 10 16 | puts "PATH: #{ENV['PATH']}" 17 | puts "RUBYOPT: #{ENV['RUBYOPT']}" 18 | puts "RUBYLIB: #{ENV['RUBYLIB']}" 19 | puts "GEM_HOME: #{ENV['GEM_HOME']}" 20 | puts "GEM_PATH: #{ENV['GEM_PATH']}" 21 | 22 | # List any Bundle specific information 23 | ENV.select { |k, _| k =~ /BUNDLE/ }.each do |key, value| 24 | puts "#{key}: #{value}" 25 | end 26 | 27 | puts "---" * 10 28 | end 29 | 30 | desc "Run tests" 31 | task test: [:info] do 32 | lib_dirs = ["lib", "test"].join(File::PATH_SEPARATOR) 33 | 34 | filters = (ENV["FILTER"] || ENV["TESTOPTS"] || "").dup 35 | filters << " -n #{ENV["N"]}" if ENV["N"] 36 | 37 | test_files = ["rubygems"] 38 | test_files << "minitest/autorun" 39 | test_files << FileList["test/**/test_*.rb"].gsub("test/", "") 40 | test_files.flatten! 41 | test_files.map! { |f| %(require "#{f}") } 42 | 43 | ruby "-w -I#{lib_dirs} --disable-gems -e '#{test_files.join("; ")}' -- #{filters}" 44 | end 45 | 46 | task default: [:test] 47 | -------------------------------------------------------------------------------- /gem-compiler.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("../lib", __FILE__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require "rubygems/compiler/version" 7 | 8 | Gem::Specification.new do |spec| 9 | # basic 10 | spec.name = "gem-compiler" 11 | spec.version = Gem::Compiler::VERSION 12 | spec.platform = Gem::Platform::RUBY 13 | 14 | # description 15 | spec.summary = "A RubyGems plugin that generates binary gems." 16 | spec.description = <<~EOF 17 | A RubyGems plugin that helps generates binary gems from already existing 18 | ones without altering the original source code. It compiles Ruby C 19 | extensions and bundles the result into a new gem. 20 | EOF 21 | 22 | # project info 23 | spec.homepage = "https://github.com/luislavena/gem-compiler" 24 | spec.licenses = ["MIT"] 25 | spec.author = "Luis Lavena" 26 | spec.email = "luislavena@gmail.com" 27 | 28 | spec.metadata = { 29 | "homepage_uri" => spec.homepage, 30 | "bug_tracker_uri" => "https://github.com/luislavena/gem-compiler/issues", 31 | "documentation_uri" => "https://rubydoc.info/github/luislavena/gem-compiler/master", 32 | "changelog_uri" => "https://github.com/luislavena/gem-compiler/blob/master/CHANGELOG.md", 33 | "source_code_uri" => spec.homepage, 34 | } 35 | 36 | # files 37 | spec.files = Dir["README.md", "CHANGELOG.md", "Rakefile", 38 | "lib/**/*.rb", "test/**/test*.rb"] 39 | 40 | # requirements 41 | spec.required_ruby_version = ">= 2.5.0" 42 | spec.required_rubygems_version = ">= 2.6.0" 43 | 44 | # development dependencies 45 | spec.add_development_dependency "rake", "~> 12.0", ">= 12.0.0" 46 | 47 | # minitest 5.14.2 is required to support Ruby 3.0 48 | spec.add_development_dependency "minitest", "~> 5.14", ">= 5.14.2" 49 | end 50 | -------------------------------------------------------------------------------- /test/rubygems/test_gem_commands_compile_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems/test_case" 4 | require "rubygems/commands/compile_command" 5 | require "rubygems/package" 6 | 7 | class TestGemCommandsCompileCommand < Gem::TestCase 8 | def setup 9 | super 10 | 11 | @cmd = Gem::Commands::CompileCommand.new 12 | end 13 | 14 | def test_execute_no_gem 15 | @cmd.options[:args] = [] 16 | 17 | e = assert_raises Gem::CommandLineError do 18 | use_ui @ui do 19 | @cmd.execute 20 | end 21 | end 22 | 23 | assert_match %r{Please specify a gem file on the command line}, e.message 24 | end 25 | 26 | def test_handle_abi_lock_ruby 27 | @cmd.handle_options [] 28 | 29 | assert_equal :ruby, @cmd.options[:abi_lock] 30 | end 31 | 32 | def test_handle_abi_lock_explicit_ruby 33 | @cmd.handle_options ["--abi-lock=ruby"] 34 | 35 | assert_equal :ruby, @cmd.options[:abi_lock] 36 | end 37 | 38 | def test_handle_abi_lock_strict 39 | @cmd.handle_options ["--abi-lock=strict"] 40 | 41 | assert_equal :strict, @cmd.options[:abi_lock] 42 | end 43 | 44 | def test_handle_abi_lock_none 45 | @cmd.handle_options ["--abi-lock=none"] 46 | 47 | assert_equal :none, @cmd.options[:abi_lock] 48 | end 49 | 50 | def test_handle_no_abi_lock_none 51 | @cmd.handle_options ["--no-abi-lock"] 52 | 53 | assert_equal :none, @cmd.options[:abi_lock] 54 | end 55 | 56 | def test_handle_abi_lock_unknown 57 | e = assert_raises OptionParser::InvalidArgument do 58 | @cmd.handle_options %w[--abi-lock unknown] 59 | end 60 | 61 | assert_equal "invalid argument: --abi-lock unknown (none, ruby, strict are valid)", 62 | e.message 63 | end 64 | 65 | def test_handle_strip_default 66 | @cmd.handle_options %w[--strip] 67 | 68 | assert_equal RbConfig::CONFIG["STRIP"], @cmd.options[:strip] 69 | end 70 | 71 | def test_handle_strip_custom 72 | @cmd.handle_options ["--strip", "strip --custom"] 73 | 74 | assert_equal "strip --custom", @cmd.options[:strip] 75 | end 76 | 77 | def test_handle_build_number 78 | @cmd.handle_options %w[--build-number 10] 79 | 80 | assert_equal 10, @cmd.options[:build_number] 81 | end 82 | 83 | def test_handle_invalid_build_number 84 | e = assert_raises OptionParser::InvalidArgument do 85 | @cmd.handle_options %w[--build-number a] 86 | end 87 | 88 | assert_equal "invalid argument: --build-number must be a number", 89 | e.message 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/rubygems/commands/compile_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rbconfig" 4 | require "rubygems/command" 5 | 6 | class Gem::Commands::CompileCommand < Gem::Command 7 | ABIs = { 8 | "ruby" => :ruby, 9 | "strict" => :strict, 10 | "none" => :none 11 | }.freeze 12 | 13 | def initialize 14 | defaults = { 15 | output: Dir.pwd, 16 | abi_lock: :ruby 17 | } 18 | 19 | super "compile", "Create binary pre-compiled gem", defaults 20 | 21 | add_option "-O", "--output DIR", "Directory where binary will be stored" do |value, options| 22 | options[:output] = File.expand_path(value, Dir.pwd) 23 | end 24 | 25 | add_option "--include-shared-dir DIR", "Additional directory for shared libraries" do |value, options| 26 | options[:include_shared_dir] = value 27 | end 28 | 29 | add_option "--prune", "Clean non-existing files during re-packaging" do |value, options| 30 | options[:prune] = true 31 | end 32 | 33 | add_option "--abi-lock MODE", 34 | "Lock to version of Ruby (ruby, strict, none)" do |value, options| 35 | 36 | mode = ABIs[value] 37 | unless mode 38 | valid = ABIs.keys.sort 39 | raise OptionParser::InvalidArgument, "#{value} (#{valid.join ', '} are valid)" 40 | end 41 | 42 | options[:abi_lock] = mode 43 | end 44 | 45 | add_option "-N", "--no-abi-lock", "Do not lock compiled Gem to Ruby's ABI (same as --abi-lock=none)" do |value, options| 46 | options[:abi_lock] = :none 47 | end 48 | 49 | add_option "-S", "--strip [CMD]", "Strip symbols from generated binaries" do |value, options| 50 | if value.nil? || value.empty? 51 | options[:strip] = RbConfig::CONFIG["STRIP"] 52 | else 53 | options[:strip] = value 54 | end 55 | end 56 | 57 | add_option "--build-number NUMBER", 58 | "Append build number to compiled Gem version" do |value, options| 59 | 60 | begin 61 | options[:build_number] = Integer(value).abs 62 | rescue ArgumentError 63 | raise OptionParser::InvalidArgument, "must be a number" 64 | end 65 | end 66 | end 67 | 68 | def arguments 69 | "GEMFILE path to the gem file to compile" 70 | end 71 | 72 | def usage 73 | "#{program_name} GEMFILE" 74 | end 75 | 76 | def execute 77 | gemfile = options[:args].shift 78 | 79 | # no gem, no binary 80 | unless gemfile 81 | raise Gem::CommandLineError, 82 | "Please specify a gem file on the command line (e.g. #{program_name} foo-0.1.0.gem)" 83 | end 84 | 85 | require "rubygems/compiler" 86 | 87 | compiler = Gem::Compiler.new(gemfile, options) 88 | compiler.compile 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | Please take notes of *Changed*, *Removed* and *Deprecated* items prior 9 | upgrading. 10 | 11 | ## [Unreleased] 12 | 13 | ### Changed 14 | - Fix Code Climate maintainability badge 15 | - Update GitHub actions to latest versions 16 | - CI: Test against Ruby 'head' (3.0) version 17 | 18 | ### Removed 19 | - Drop support for Ruby 2.4.x since reached EOL (End Of Life) 20 | 21 | ## [0.9.0] - 2020-04-05 22 | 23 | ### Added 24 | - Allow symbol stripping from extensions (using `--strip`). (#40, #48, #50) 25 | - Introduce more strict Ruby version locking (using `--abi-lock`). (#51, #52) 26 | 27 | ### Fixed 28 | - Solve upcoming RubyGems deprecation warnings 29 | 30 | ### Changed 31 | - Deal with RubyGems 3.x `new_spec` deprecation in tests. 32 | - CI: Replace Travis/AppVeyor with GitHub Actions for Ubuntu, macOS and Windows. 33 | - No longer raise exceptions when executed against non-compilable gems. (#38, #47) 34 | 35 | ### Removed 36 | - Drop support for Ruby 2.3.x, as it reached EOL (End Of Life) 37 | - Drop support for RubyGems older than 2.6.0 (Ruby 2.4 includes RubyGems 2.6.8) 38 | 39 | ## [0.8.0] - 2017-12-28 40 | 41 | ### Added 42 | - Introduce `--include-shared-dir` to specify additional directory where to 43 | lookup platform-specific shared libraries to bundle in the package. (#34) 44 | 45 | ### Fixed 46 | - Solve RubyGems 2.6.x changes on exception hierarchy. Thanks to @MSP-Greg (#30) 47 | 48 | ### Removed 49 | - Drop support for Ruby 2.1.x and 2.2.x, as they reached EOL (End Of Life) 50 | - Drop support for RubyGems older than 2.5.0 51 | 52 | ### Changed 53 | - CI: Avoid possible issues when installing Bundler on AppVeyor 54 | 55 | ## [0.7.0] - 2017-10-01 56 | 57 | ### Added 58 | - Introduce `--output` (`-O` in short) to specify the output directory where 59 | compiled gem will be stored. 60 | 61 | ### Changed 62 | - Introduce `Makefile` for local development 63 | - CI: Update Travis test matrix 64 | - Reduce RubyGems warnings during `rake package` 65 | 66 | ## [0.6.0] - 2017-06-25 67 | 68 | ### Fixed 69 | - Solve RubyGems 2.5 deprecation warnings 70 | 71 | ### Removed 72 | - Drop support for any Ruby version prior to 2.1.0 73 | 74 | ### Changed 75 | - Use Travis to automate new releases 76 | - CI: Update test matrix (Travis and AppVeyor) 77 | 78 | ## [0.5.0] - 2016-04-24 79 | 80 | ### Fixed 81 | - Workaround shortname directories on Windows. Thanks to @mbland (#17 & #19) 82 | - Validate both Ruby and RubyGems versions defined in gemspec 83 | - Ensure any RubyGems' `pre_install` hooks are run at extension compilation (#18) 84 | 85 | ### Changed 86 | - Lock compile gems to Ruby's ABI version which can be disabled using 87 | `--no-abi-lock` option (#11) 88 | 89 | ### Removed 90 | - Drop support for any Ruby version prior to 2.0.0 91 | 92 | ## [0.4.0] - 2015-07-18 93 | 94 | ### Added 95 | - Introduce `--prune` option to cleanup gemspecs. Thanks to @androbtech [#13] 96 | 97 | ### Changed 98 | - Test builds on both Travis (Linux) and AppVeyor (Windows) environments. 99 | 100 | ## [0.3.0] - 2014-04-19 101 | 102 | ### Added 103 | - Support RubyGems 2.2.x thanks to @drbrain 104 | 105 | ### Changed 106 | - Minor reorganization to make testing on Travis more easy 107 | 108 | ## [0.2.0] - 2013-04-28 109 | 110 | ### Added 111 | - Support RubyGems 2.0.0 thanks to @mgoggin [#6] 112 | 113 | ## [0.1.1] - 2012-05-07 114 | 115 | ### Fixed 116 | - Loose requirements to allow installation on Ruby 1.8.7 or greater. You 117 | still need RubyGems 1.8.24 118 | 119 | ## [0.1.0] - 2012-05-06 120 | 121 | - Initial public release, extracted from internal project. 122 | 123 | [Unreleased]: https://github.com/luislavena/gem-compiler/compare/v0.9.0...HEAD 124 | [0.9.0]: https://github.com/luislavena/gem-compiler/compare/v0.8.0...v0.9.0 125 | [0.8.0]: https://github.com/luislavena/gem-compiler/compare/v0.7.0...v0.8.0 126 | [0.7.0]: https://github.com/luislavena/gem-compiler/compare/v0.6.0...v0.7.0 127 | [0.6.0]: https://github.com/luislavena/gem-compiler/compare/v0.5.0...v0.6.0 128 | [0.5.0]: https://github.com/luislavena/gem-compiler/compare/v0.4.0...v0.5.0 129 | [0.4.0]: https://github.com/luislavena/gem-compiler/compare/v0.3.0...v0.4.0 130 | [0.3.0]: https://github.com/luislavena/gem-compiler/compare/v0.2.0...v0.3.0 131 | [0.2.0]: https://github.com/luislavena/gem-compiler/compare/v0.1.1...v0.2.0 132 | [0.1.1]: https://github.com/luislavena/gem-compiler/compare/v0.1.0...v0.1.1 133 | -------------------------------------------------------------------------------- /lib/rubygems/compiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "open3" 5 | require "rbconfig" 6 | require "tmpdir" 7 | require "rubygems/installer" 8 | require "rubygems/package" 9 | 10 | class Gem::Compiler 11 | include Gem::UserInteraction 12 | 13 | # raise when there is a error 14 | class CompilerError < Gem::InstallError; end 15 | 16 | attr_reader :target_dir, :options 17 | 18 | def initialize(gemfile, _options = {}) 19 | @gemfile = gemfile 20 | @output_dir = _options.delete(:output) 21 | @options = _options 22 | end 23 | 24 | def compile 25 | unpack 26 | 27 | build_extensions 28 | 29 | artifacts = collect_artifacts 30 | 31 | strip_artifacts artifacts 32 | 33 | if shared_dir = options[:include_shared_dir] 34 | shared_libs = collect_shared(shared_dir) 35 | 36 | artifacts.concat shared_libs 37 | end 38 | 39 | # build a new gemspec from the original one 40 | gemspec = installer.spec.dup 41 | 42 | adjust_gemspec_files gemspec, artifacts 43 | 44 | # generate new gem and return new path to it 45 | repackage gemspec 46 | ensure 47 | cleanup 48 | end 49 | 50 | private 51 | 52 | def adjust_abi_lock(gemspec) 53 | abi_lock = @options[:abi_lock] || :ruby 54 | case abi_lock 55 | when :ruby 56 | ruby_abi = RbConfig::CONFIG["ruby_version"] 57 | gemspec.required_ruby_version = "~> #{ruby_abi}" 58 | when :strict 59 | cfg = RbConfig::CONFIG 60 | ruby_abi = "#{cfg["MAJOR"]}.#{cfg["MINOR"]}.#{cfg["TEENY"]}.0" 61 | gemspec.required_ruby_version = "~> #{ruby_abi}" 62 | end 63 | end 64 | 65 | def adjust_gemspec_files(gemspec, artifacts) 66 | # remove any non-existing files 67 | if @options[:prune] 68 | gemspec.files.reject! { |f| !File.exist?("#{target_dir}/#{f}") } 69 | end 70 | 71 | # add discovered artifacts 72 | artifacts.each do |path| 73 | # path needs to be relative to target_dir 74 | file = path.sub("#{target_dir}/", "") 75 | 76 | debug "Adding '#{file}' to gemspec" 77 | gemspec.files.push file 78 | end 79 | end 80 | 81 | def build_extensions 82 | # run pre_install hooks 83 | if installer.respond_to?(:run_pre_install_hooks) 84 | installer.run_pre_install_hooks 85 | end 86 | 87 | installer.build_extensions 88 | end 89 | 90 | def cleanup 91 | FileUtils.rm_rf tmp_dir 92 | end 93 | 94 | def collect_artifacts 95 | # determine build artifacts from require_paths 96 | dlext = RbConfig::CONFIG["DLEXT"] 97 | lib_dirs = installer.spec.require_paths.join(",") 98 | 99 | Dir.glob("#{target_dir}/{#{lib_dirs}}/**/*.#{dlext}") 100 | end 101 | 102 | def collect_shared(shared_dir) 103 | libext = platform_shared_ext 104 | 105 | Dir.glob("#{target_dir}/#{shared_dir}/**/*.#{libext}") 106 | end 107 | 108 | def ensure_ruby_version_met(spec) 109 | if rrv = spec.required_ruby_version 110 | ruby_version = Gem.ruby_version 111 | unless rrv.satisfied_by? ruby_version 112 | raise Gem::RuntimeRequirementNotMetError, 113 | "#{spec.full_name} requires Ruby version #{rrv}. The current ruby version is #{ruby_version}." 114 | end 115 | end 116 | end 117 | 118 | def ensure_rubygems_version_met(spec) 119 | if rrgv = spec.required_rubygems_version 120 | unless rrgv.satisfied_by? Gem.rubygems_version 121 | rg_version = Gem::VERSION 122 | raise Gem::RuntimeRequirementNotMetError, 123 | "#{spec.full_name} requires RubyGems version #{rrgv}. The current RubyGems version is #{rg_version}. " + 124 | "Try 'gem update --system' to update RubyGems itself." 125 | end 126 | end 127 | end 128 | 129 | def info(msg) 130 | say msg if Gem.configuration.verbose 131 | end 132 | 133 | def debug(msg) 134 | say msg if Gem.configuration.really_verbose 135 | end 136 | 137 | def installer 138 | @installer ||= prepare_installer 139 | end 140 | 141 | def platform_shared_ext 142 | platform = Gem::Platform.local 143 | 144 | case platform.os 145 | when /darwin/ 146 | "dylib" 147 | when /linux|bsd|solaris/ 148 | "so" 149 | when /mingw|mswin|cygwin|msys/ 150 | "dll" 151 | else 152 | "so" 153 | end 154 | end 155 | 156 | def prepare_installer 157 | installer = Gem::Installer.at(@gemfile, options.dup.merge(unpack: true)) 158 | installer.spec.full_gem_path = @target_dir 159 | installer.spec.extension_dir = File.join(@target_dir, "lib") 160 | 161 | # Ensure Ruby version is met 162 | ensure_ruby_version_met(installer.spec) 163 | 164 | # Check version of RubyGems (just in case) 165 | ensure_rubygems_version_met(installer.spec) 166 | 167 | # Hmm, gem already compiled? 168 | if installer.spec.platform != Gem::Platform::RUBY 169 | info "The gem file seems to be compiled already. Skipping." 170 | cleanup 171 | terminate_interaction 172 | end 173 | 174 | # Hmm, no extensions? 175 | if installer.spec.extensions.empty? 176 | info "There are no extensions to build on this gem file. Skipping." 177 | cleanup 178 | terminate_interaction 179 | end 180 | 181 | installer 182 | end 183 | 184 | def repackage(gemspec) 185 | # clear out extensions from gemspec 186 | gemspec.extensions.clear 187 | 188 | # adjust platform 189 | gemspec.platform = Gem::Platform::CURRENT 190 | 191 | # adjust gem version 192 | if build_number = options[:build_number] 193 | gemspec.version = Gem::Version.create("#{gemspec.version}.#{build_number}") 194 | end 195 | 196 | # adjust version of Ruby 197 | adjust_abi_lock(gemspec) 198 | 199 | # build new gem 200 | output_gem = nil 201 | 202 | Dir.chdir target_dir do 203 | output_gem = Gem::Package.build(gemspec) 204 | end 205 | 206 | unless output_gem 207 | raise CompilerError, 208 | "There was a problem building the gem." 209 | end 210 | 211 | # move the built gem to the original output directory 212 | FileUtils.mv File.join(target_dir, output_gem), @output_dir 213 | 214 | # return the path of the gem 215 | output_gem 216 | end 217 | 218 | def simple_run(command, command_name) 219 | begin 220 | output, status = Open3.capture2e(*command) 221 | rescue => error 222 | raise Gem::CompilerError, "#{command_name} failed#{error.message}" 223 | end 224 | 225 | yield(status, output) if block_given? 226 | 227 | unless status.success? 228 | exit_reason = 229 | if status.exited? 230 | ", exit code #{status.exitstatus}" 231 | elsif status.signaled? 232 | ", uncaught signal #{status.termsig}" 233 | end 234 | 235 | raise Gem::CompilerError, "#{command_name} failed#{exit_reason}" 236 | end 237 | end 238 | 239 | def strip_artifacts(artifacts) 240 | return unless options[:strip] 241 | 242 | strip_cmd = options[:strip] 243 | 244 | info "Stripping symbols from extensions (using '#{strip_cmd}')..." 245 | 246 | artifacts.each do |artifact| 247 | cmd = [strip_cmd, artifact].join(' ').rstrip 248 | 249 | simple_run(cmd, "strip #{File.basename(artifact)}") do |status, output| 250 | if status.success? 251 | debug "Stripped #{File.basename(artifact)}" 252 | end 253 | end 254 | end 255 | end 256 | 257 | def tmp_dir 258 | @tmp_dir ||= Dir.glob(Dir.mktmpdir).first 259 | end 260 | 261 | def unpack 262 | basename = File.basename(@gemfile, ".gem") 263 | @target_dir = File.join(tmp_dir, basename) 264 | 265 | # unpack gem sources into target_dir 266 | # We need the basename to keep the unpack happy 267 | info "Unpacking gem: '#{basename}' in temporary directory..." 268 | 269 | # RubyGems >= 3.1.x 270 | if installer.respond_to?(:package) 271 | package = installer.package 272 | else 273 | package = Gem::Package.new(@gemfile) 274 | end 275 | 276 | package.extract_files(@target_dir) 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gem-compiler 2 | 3 | A RubyGems plugin that generates binary (pre-compiled) gems. 4 | 5 | [![Gem Version](https://img.shields.io/gem/v/gem-compiler.svg)](https://rubygems.org/gems/gem-compiler) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/340423e051aa44275ca4/maintainability)](https://codeclimate.com/github/luislavena/gem-compiler/maintainability) 7 | 8 | - [home](https://github.com/luislavena/gem-compiler) 9 | - [bugs](https://github.com/luislavena/gem-compiler/issues) 10 | 11 | ## Description 12 | 13 | `gem-compiler` is a RubyGems plugin that helps generates binary gems from 14 | already existing ones without altering the original source code. It compiles 15 | Ruby C extensions and bundles the result into a new gem. 16 | 17 | It uses an *outside-in* approach and leverages on existing RubyGems code to 18 | do it. 19 | 20 | ## Benefits 21 | 22 | Using `gem-compiler` removes the need to install a compiler toolchain on the 23 | platform used to run the extension. This means less dependencies are required 24 | in those systems and can reduce associated update/maintenance cycles. 25 | 26 | Additionally, by having only binaries, it reduces the time it takes to install 27 | several gems that normally take minutes to compile themselves and the needed 28 | dependencies. 29 | 30 | Without `gem-compiler`, takes more than a minute to install Nokogiri on 31 | Ubuntu 18.04: 32 | 33 | ```console 34 | $ time gem install --local nokogiri-1.10.7.gem 35 | Building native extensions. This could take a while... 36 | Successfully installed nokogiri-1.10.7 37 | 1 gem installed 38 | 39 | real 1m22.670s 40 | user 1m5.856s 41 | sys 0m18.637s 42 | ``` 43 | 44 | Compared to the installation of the pre-compiled version: 45 | 46 | ```console 47 | $ gem compile nokogiri-1.10.7.gem --prune 48 | Unpacking gem: 'nokogiri-1.10.7' in temporary directory... 49 | Building native extensions. This could take a while... 50 | Successfully built RubyGem 51 | Name: nokogiri 52 | Version: 1.10.7 53 | File: nokogiri-1.10.7-x86_64-linux.gem 54 | 55 | $ time gem install --local nokogiri-1.10.7-x86_64-linux.gem 56 | Successfully installed nokogiri-1.10.7-x86_64-linux 57 | 1 gem installed 58 | 59 | real 0m1.697s 60 | user 0m1.281s 61 | sys 0m0.509s 62 | ``` 63 | 64 | ## Installation 65 | 66 | To install gem-compiler you need to use RubyGems: 67 | 68 | $ gem install gem-compiler 69 | 70 | Which will fetch and install the plugin. After that the `compile` command 71 | will be available through `gem`. 72 | 73 | ## Usage 74 | 75 | As requirement, gem-compiler can only compile local gems, either one you have 76 | generated from your projects or previously downloaded. 77 | 78 | ### Fetching a gem 79 | 80 | If you don't have the gem locally, you can use `fetch` to retrieve it first: 81 | 82 | $ gem fetch yajl-ruby --platform=ruby 83 | Fetching: yajl-ruby-1.1.0.gem (100%) 84 | Downloaded yajl-ruby-1.1.0 85 | 86 | Please note that I was explicit about which platform to fetch. This will 87 | avoid RubyGems attempt to download any existing binary gem for my current 88 | platform. 89 | 90 | ### Compiling a gem 91 | 92 | You need to tell RubyGems the filename of the gem you want to compile: 93 | 94 | $ gem compile yajl-ruby-1.1.0.gem 95 | 96 | The above command will unpack, compile any existing extensions found and 97 | repackage everything as a binary gem: 98 | 99 | Unpacking gem: 'yajl-ruby-1.1.0' in temporary directory... 100 | Building native extensions. This could take a while... 101 | Successfully built RubyGem 102 | Name: yajl-ruby 103 | Version: 1.1.0 104 | File: yajl-ruby-1.1.0-x86-mingw32.gem 105 | 106 | This new gem do not require a compiler, as shown when locally installed: 107 | 108 | C:\> gem install --local yajl-ruby-1.1.0-x86-mingw32.gem 109 | Successfully installed yajl-ruby-1.1.0-x86-mingw32 110 | 1 gem installed 111 | 112 | There are native gems that will invalidate their own specification after 113 | compile process completes. This will not permit them be repackaged as binary 114 | gems. To workaround this problem you have the option to *prune* the package 115 | process: 116 | 117 | $ gem fetch nokogiri --platform=ruby 118 | Fetching: nokogiri-1.6.6.2.gem (100%) 119 | Downloaded nokogiri-1.6.6.2 120 | 121 | $ gem compile nokogiri-1.6.6.2.gem --prune 122 | Unpacking gem: 'nokogiri-1.6.6.2' in temporary directory... 123 | Building native extensions. This could take a while... 124 | Successfully built RubyGem 125 | Name: nokogiri 126 | Version: 1.6.6.2 127 | File: nokogiri-1.6.6.2-x86_64-darwin-12.gem 128 | 129 | $ gem install --local nokogiri-1.6.6.2-x86_64-darwin-12.gem 130 | Successfully installed nokogiri-1.6.6.2-x86_64-darwin-12 131 | 1 gem installed 132 | 133 | #### Restricting generated binary gems 134 | 135 | Gems compiled with `gem-compiler` be lock to the version of Ruby used 136 | to compile them, following Ruby's ABI compatibility (`MAJOR.MINOR`) 137 | 138 | This means that a gem compiled with Ruby 2.6.1 could be installed in any 139 | version of Ruby 2.6.x (Eg. 2.6.4). 140 | 141 | You can tweak this behavior by using `--abi-lock` option during compilation. 142 | There are 3 available modes: 143 | 144 | * `ruby`: Follows Ruby's ABI. Gems compiled with Ruby 2.6.1 can be installed 145 | in any Ruby 2.6.x (default behavior). 146 | * `strict`: Uses Ruby's full version. Gems compiled with Ruby 2.6.1 can only 147 | be installed in Ruby 2.6.1. 148 | * `none`: Disables Ruby compatibility. Gems compiled with this option can be 149 | installed on any version of Ruby (alias for `--no-abi-lock`). 150 | 151 | **Warning**: usage of `none` is not recommended since different versions of 152 | Ruby might expose different APIs. The binary might be expecting specific 153 | features not present in the version of Ruby you're installing the gem into. 154 | 155 | #### Reducing extension's size (stripping) 156 | 157 | By default, RubyGems do not strip symbols from compiled extensions, including 158 | debugging information and can result in increased size of final package. 159 | 160 | With `--strip`, you can reduce extensions by using same stripping options used 161 | by Ruby itself (see `RbConfig::CONFIG["STRIP"]`): 162 | 163 | ```console 164 | $ gem compile oj-3.10.0.gem --strip 165 | Unpacking gem: 'oj-3.10.0' in temporary directory... 166 | Building native extensions. This could take a while... 167 | Stripping symbols from extensions (using 'strip -S -x')... 168 | Successfully built RubyGem 169 | Name: oj 170 | Version: 3.10.0 171 | File: oj-3.10.0-x86_64-linux.gem 172 | ``` 173 | 174 | Or you can provide your own stripping command instead: 175 | 176 | ```console 177 | $ gem compile oj-3.10.0.gem --strip "strip --strip-unneeded" 178 | Unpacking gem: 'oj-3.10.0' in temporary directory... 179 | Building native extensions. This could take a while... 180 | Stripping symbols from extensions (using 'strip --strip-unneeded')... 181 | Successfully built RubyGem 182 | Name: oj 183 | Version: 3.10.0 184 | File: oj-3.10.0-x86_64-linux.gem 185 | ``` 186 | 187 | #### Append build number to gem version 188 | 189 | Gem servers like RubyGems or Gemstash treat gems as immutable, so once a gem 190 | has been pushed, you cannot replace it. 191 | 192 | When playing with compilation options or library dependencies, you might 193 | require to build and push an updated version of the same version. 194 | 195 | You can use `--build-number` to add the build number to the compiled version 196 | and push an updated build, maintaining gem dependency compatibility: 197 | 198 | ```console 199 | $ gem compile oj-3.11.3.gem --build-number 10 200 | Unpacking gem: 'oj-3.11.3' in temporary directory... 201 | Building native extensions. This could take a while... 202 | Successfully built RubyGem 203 | Name: oj 204 | Version: 3.11.3.10 205 | File: oj-3.11.3.10-x86_64-linux.gem 206 | ``` 207 | 208 | This new version remains compatible with RubyGems' dependency requirements 209 | like `~> 3.11` or `~> 3.11.3`. 210 | 211 | ### Compiling from Rake 212 | 213 | Most of the times, as gem developer, you would like to generate both kind of 214 | gems at once. For that purpose, you can add a task for Rake similar to the 215 | one below: 216 | 217 | ```ruby 218 | desc "Generate a pre-compiled native gem" 219 | task "gem:native" => ["gem"] do 220 | sh "gem compile #{gem_file}" 221 | end 222 | ``` 223 | 224 | Of course, that assumes you have a task `gem` that generates the base gem 225 | required. 226 | 227 | ## Requirements 228 | 229 | ### Ruby and RubyGems 230 | 231 | It's assumed you have Ruby and RubyGems installed. gem-compiler requires 232 | RubyGems 2.6.x to work. 233 | 234 | If you don't have RubyGems 2.6.x, you can upgrade by running: 235 | 236 | $ gem update --system 237 | 238 | ### A compiler 239 | 240 | In order to compile a gem, you need a compiler toolchain installed. Depending 241 | on your Operating System you will have one already installed or will require 242 | additional steps to do it. Check your OS documentation about getting the 243 | right one. 244 | 245 | ### If you're using Windows 246 | 247 | For those using RubyInstaller-based builds, you will need to download the 248 | DevKit from their [downloads page](http://rubyinstaller.org/downloads) 249 | and follow the installation instructions. 250 | 251 | To be sure your installation of Ruby is based on RubyInstaller, execute at 252 | the command prompt: 253 | 254 | C:\> ruby --version 255 | 256 | And from the output: 257 | 258 | ruby 2.4.9p362 (2019-10-02 revision 67824) [x64-mingw32] 259 | 260 | If you see `mingw32`, that means you're using a RubyInstaller build 261 | (MinGW based). 262 | 263 | ## Differences with rake-compiler 264 | 265 | [rake-compiler](https://github.com/luislavena/rake-compiler) has provided to 266 | Ruby library authors a *tool* for compiling extensions and generating binary 267 | gems of their libraries. 268 | 269 | You can consider rake-compiler's approach be an *inside-out* process. To do 270 | its magic, it requires library authors to modify their source code, adjust 271 | some structure and learn a series of commands. 272 | 273 | While the ideal scenario is using a tool like rake-compiler that endorses 274 | *convention over configuration*, is not humanly possible change all the 275 | projects by snapping your fingers :wink: 276 | 277 | ## License 278 | 279 | [The MIT License](LICENSE) 280 | -------------------------------------------------------------------------------- /test/rubygems/test_gem_compiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems/test_case" 4 | require "rubygems/compiler" 5 | 6 | class TestGemCompiler < Gem::TestCase 7 | def setup 8 | super 9 | 10 | # unset GEM_PATH so `rake` is found during compilation of extensions 11 | ENV.delete("GEM_PATH") 12 | 13 | @output_dir = File.join @tempdir, 'output' 14 | FileUtils.mkdir_p @output_dir 15 | end 16 | 17 | def test_compile_no_extensions 18 | gem_file = util_bake_gem 19 | 20 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 21 | 22 | assert_raises Gem::MockGemUi::SystemExitException do 23 | use_ui @ui do 24 | compiler.compile 25 | end 26 | end 27 | 28 | out = @ui.output.split "\n" 29 | 30 | assert_equal "There are no extensions to build on this gem file. Skipping.", 31 | out.last 32 | end 33 | 34 | def test_compile_non_ruby 35 | gem_file = util_bake_gem { |s| s.platform = Gem::Platform::CURRENT } 36 | 37 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 38 | 39 | assert_raises Gem::MockGemUi::SystemExitException do 40 | use_ui @ui do 41 | compiler.compile 42 | end 43 | end 44 | 45 | out = @ui.output.split "\n" 46 | 47 | assert_equal "The gem file seems to be compiled already. Skipping.", out.last 48 | end 49 | 50 | def test_compile_pre_install_hooks 51 | util_reset_arch 52 | 53 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 54 | 55 | gem_file = util_bake_gem("foo") { |s| 56 | util_fake_extension s, "foo", util_custom_configure(artifact) 57 | } 58 | 59 | hook_run = false 60 | 61 | Gem.pre_install do |installer| 62 | hook_run = true 63 | true 64 | end 65 | 66 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 67 | 68 | use_ui @ui do 69 | compiler.compile 70 | end 71 | 72 | assert hook_run, "pre_install hook not run" 73 | end 74 | 75 | def test_compile_required_ruby 76 | gem_file = util_bake_gem("old_required") { |spec| 77 | spec.required_ruby_version = "= 1.4.6" 78 | util_fake_extension spec 79 | } 80 | 81 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 82 | 83 | e = assert_raises Gem::RuntimeRequirementNotMetError do 84 | use_ui @ui do 85 | compiler.compile 86 | end 87 | end 88 | 89 | assert_match %r|requires Ruby version = 1.4.6|, e.message 90 | end 91 | 92 | def test_compile_required_rubygems 93 | gem_file = util_bake_gem("old_rubygems") { |spec| 94 | spec.required_rubygems_version = "< 0" 95 | util_fake_extension spec 96 | } 97 | 98 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 99 | 100 | e = assert_raises Gem::RuntimeRequirementNotMetError do 101 | use_ui @ui do 102 | compiler.compile 103 | end 104 | end 105 | 106 | assert_match %r|requires RubyGems version < 0|, e.message 107 | end 108 | 109 | def test_compile_succeed 110 | util_set_arch "i386-mingw32" 111 | 112 | gem_file = util_bake_gem { |spec| 113 | util_fake_extension spec 114 | } 115 | 116 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 117 | 118 | use_ui @ui do 119 | compiler.compile 120 | end 121 | 122 | out = @ui.output.split "\n" 123 | 124 | assert_match %r|Unpacking gem: 'a-1' in temporary directory...|, 125 | out.shift 126 | 127 | assert_path_exists File.join(@output_dir, "a-1-x86-mingw32.gem") 128 | end 129 | 130 | def test_compile_succeed_using_prune 131 | name = 'a' 132 | 133 | artifact = "#{name}.#{RbConfig::CONFIG["DLEXT"]}" 134 | old_spec = '' 135 | 136 | gem_file = util_bake_gem(name, 'ports/to_be_deleted_during_ext_build.patch') { |spec| 137 | old_spec = spec 138 | util_fake_extension spec, name, <<~EOF 139 | require 'fileutils' 140 | FileUtils.rm File.expand_path(File.join(File.dirname(__FILE__), '../../ports/to_be_deleted_during_ext_build.patch')) 141 | #{util_custom_configure(artifact)} 142 | EOF 143 | } 144 | 145 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir, :prune => true) 146 | output_gem = nil 147 | 148 | use_ui @ui do 149 | output_gem = compiler.compile 150 | end 151 | 152 | assert_path_exists File.join(@output_dir, output_gem) 153 | actual_spec = util_read_spec File.join(@output_dir, output_gem) 154 | 155 | refute actual_spec.files.include? "ports/to_be_deleted_during_ext_build.patch" 156 | end 157 | 158 | def test_compile_bundle_artifacts 159 | util_reset_arch 160 | 161 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 162 | 163 | gem_file = util_bake_gem("foo") { |s| 164 | util_fake_extension s, "foo", util_custom_configure(artifact) 165 | } 166 | 167 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 168 | output_gem = nil 169 | 170 | use_ui @ui do 171 | output_gem = compiler.compile 172 | end 173 | 174 | assert_path_exists File.join(@output_dir, output_gem) 175 | spec = util_read_spec File.join(@output_dir, output_gem) 176 | 177 | assert_includes spec.files, "lib/#{artifact}" 178 | end 179 | 180 | # We need to check that tempdir paths that contain spaces as are handled 181 | # properly on Windows. In some cases, Dir.tmpdir may returned shortened 182 | # versions of these components, e.g. "C:/Users/JOHNDO~1/AppData/Local/Temp" 183 | # for "C:/Users/John Doe/AppData/Local/Temp". 184 | def test_compile_bundle_artifacts_path_with_spaces 185 | skip("only necessary to test on Windows") unless Gem.win_platform? 186 | old_tempdir = @tempdir 187 | old_output_dir = @output_dir 188 | 189 | old_tmp = ENV["TMP"] 190 | old_temp = ENV["TEMP"] 191 | old_tmpdir = ENV["TMPDIR"] 192 | 193 | # We want to make sure Dir.tmpdir returns the path containing "DIRWIT~1" 194 | # so that we're testing whether the compiler expands the path properly. To 195 | # do this, "dir with spaces" must not be the last path component. 196 | # 197 | # This is because Dir.tmpdir calls File.expand_path on ENV[TMPDIR] (or 198 | # ENV[TEMP], etc.). When "DIRWIT~1" is the last component, 199 | # File.expand_path will expand this to "dir with spaces". When it's not 200 | # the last component, it will leave "DIRWIT~1" as-is. 201 | @tempdir = File.join(@tempdir, "dir with spaces", "tmp") 202 | FileUtils.mkdir_p(@tempdir) 203 | @tempdir = File.join(old_tempdir, "DIRWIT~1", "tmp") 204 | 205 | @output_dir = File.join(@tempdir, "output") 206 | FileUtils.mkdir_p(@output_dir) 207 | 208 | ["TMP", "TEMP", "TMPDIR"].each { |varname| ENV[varname] = @tempdir } 209 | 210 | util_reset_arch 211 | 212 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 213 | 214 | gem_file = util_bake_gem("foo") { |s| 215 | util_fake_extension s, "foo", util_custom_configure(artifact) 216 | } 217 | 218 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir) 219 | output_gem = nil 220 | 221 | use_ui @ui do 222 | output_gem = compiler.compile 223 | end 224 | 225 | assert_path_exists File.join(@output_dir, output_gem) 226 | spec = util_read_spec File.join(@output_dir, output_gem) 227 | 228 | assert_includes spec.files, "lib/#{artifact}" 229 | ensure 230 | if Gem.win_platform? 231 | FileUtils.rm_rf @tempdir 232 | 233 | ENV["TMP"] = old_tmp 234 | ENV["TEMP"] = old_temp 235 | ENV["TMPDIR"] = old_tmpdir 236 | 237 | @tempdir = old_tempdir 238 | @output_dir = old_output_dir 239 | end 240 | end 241 | 242 | def test_compile_bundle_extra_artifacts_linux 243 | util_set_arch "x86_64-linux" 244 | 245 | name = 'a' 246 | 247 | artifact = "shared.so" 248 | old_spec = '' 249 | 250 | gem_file = util_bake_gem(name) { |spec| 251 | old_spec = spec 252 | util_fake_extension spec, name, <<~EOF 253 | require "fileutils" 254 | 255 | FileUtils.touch "#{artifact}" 256 | 257 | File.write('Rakefile', "task :default") 258 | EOF 259 | } 260 | 261 | compiler = Gem::Compiler.new(gem_file, 262 | :output => @output_dir, :include_shared_dir => "ext") 263 | 264 | output_gem = nil 265 | 266 | use_ui @ui do 267 | output_gem = compiler.compile 268 | end 269 | 270 | assert_path_exists File.join(@output_dir, output_gem) 271 | actual_spec = util_read_spec File.join(@output_dir, output_gem) 272 | 273 | assert_includes actual_spec.files, "ext/#{name}/#{artifact}" 274 | ensure 275 | util_reset_arch 276 | end 277 | 278 | def test_compile_bundle_extra_artifacts_windows 279 | util_set_arch "i386-mingw32" 280 | 281 | name = 'a' 282 | 283 | artifact = "shared.dll" 284 | old_spec = '' 285 | 286 | gem_file = util_bake_gem(name) { |spec| 287 | old_spec = spec 288 | util_fake_extension spec, name, <<~EOF 289 | require "fileutils" 290 | 291 | FileUtils.touch "#{artifact}" 292 | 293 | File.write('Rakefile', "task :default") 294 | EOF 295 | } 296 | 297 | compiler = Gem::Compiler.new(gem_file, 298 | :output => @output_dir, :include_shared_dir => "ext") 299 | 300 | output_gem = nil 301 | 302 | use_ui @ui do 303 | output_gem = compiler.compile 304 | end 305 | 306 | assert_path_exists File.join(@output_dir, output_gem) 307 | actual_spec = util_read_spec File.join(@output_dir, output_gem) 308 | 309 | assert_includes actual_spec.files, "ext/#{name}/#{artifact}" 310 | ensure 311 | util_reset_arch 312 | end 313 | 314 | def test_compile_abi_lock_ruby 315 | util_reset_arch 316 | 317 | ruby_abi = RbConfig::CONFIG["ruby_version"] 318 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 319 | 320 | gem_file = util_bake_gem("foo") { |s| 321 | util_fake_extension s, "foo", util_custom_configure(artifact) 322 | } 323 | 324 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir, :abi_lock => nil) 325 | output_gem = nil 326 | 327 | use_ui @ui do 328 | output_gem = compiler.compile 329 | end 330 | 331 | spec = util_read_spec File.join(@output_dir, output_gem) 332 | 333 | assert_equal Gem::Requirement.new("~> #{ruby_abi}"), spec.required_ruby_version 334 | end 335 | 336 | def test_compile_abi_lock_explicit_ruby 337 | util_reset_arch 338 | 339 | ruby_abi = RbConfig::CONFIG["ruby_version"] 340 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 341 | 342 | gem_file = util_bake_gem("foo") { |s| 343 | util_fake_extension s, "foo", util_custom_configure(artifact) 344 | } 345 | 346 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir, :abi_lock => :ruby) 347 | output_gem = nil 348 | 349 | use_ui @ui do 350 | output_gem = compiler.compile 351 | end 352 | 353 | spec = util_read_spec File.join(@output_dir, output_gem) 354 | 355 | assert_equal Gem::Requirement.new("~> #{ruby_abi}"), spec.required_ruby_version 356 | end 357 | 358 | def test_compile_abi_lock_strict 359 | util_reset_arch 360 | 361 | ruby_abi = "%d.%d.%d.0" % RbConfig::CONFIG.values_at("MAJOR", "MINOR", "TEENY") 362 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 363 | 364 | gem_file = util_bake_gem("foo") { |s| 365 | util_fake_extension s, "foo", util_custom_configure(artifact) 366 | } 367 | 368 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir, :abi_lock => :strict) 369 | output_gem = nil 370 | 371 | use_ui @ui do 372 | output_gem = compiler.compile 373 | end 374 | 375 | spec = util_read_spec File.join(@output_dir, output_gem) 376 | 377 | assert_equal Gem::Requirement.new("~> #{ruby_abi}"), spec.required_ruby_version 378 | end 379 | 380 | def test_compile_abi_lock_none 381 | util_reset_arch 382 | 383 | artifact = "foo.#{RbConfig::CONFIG["DLEXT"]}" 384 | 385 | gem_file = util_bake_gem("foo") { |s| 386 | util_fake_extension s, "foo", util_custom_configure(artifact) 387 | } 388 | 389 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir, :abi_lock => :none) 390 | output_gem = nil 391 | 392 | use_ui @ui do 393 | output_gem = compiler.compile 394 | end 395 | 396 | spec = util_read_spec File.join(@output_dir, output_gem) 397 | 398 | assert_equal Gem::Requirement.new(">= 0"), spec.required_ruby_version 399 | end 400 | 401 | def test_compile_build_number 402 | util_reset_arch 403 | 404 | artifact = "bar.#{RbConfig::CONFIG["DLEXT"]}" 405 | 406 | gem_file = util_bake_gem("bar") { |s| 407 | util_fake_extension s, "bar", util_custom_configure(artifact) 408 | } 409 | 410 | compiler = Gem::Compiler.new(gem_file, output: @output_dir, build_number: 50) 411 | output_gem = nil 412 | 413 | use_ui @ui do 414 | output_gem = compiler.compile 415 | end 416 | 417 | spec = util_read_spec File.join(@output_dir, output_gem) 418 | 419 | assert_equal Gem::Version.create("1.50"), spec.version 420 | end 421 | 422 | def test_compile_strip_cmd 423 | util_reset_arch 424 | hook_simple_run 425 | 426 | old_rbconfig_strip = RbConfig::CONFIG["STRIP"] 427 | RbConfig::CONFIG["STRIP"] = "rbconfig-strip-cmd" 428 | 429 | gem_file = util_bake_gem("foo") do |spec| 430 | util_dummy_extension spec, "bar" 431 | end 432 | 433 | compiler = Gem::Compiler.new(gem_file, :output => @output_dir, 434 | :strip => "echo strip-custom") 435 | output_gem = nil 436 | 437 | use_ui @ui do 438 | output_gem = compiler.compile 439 | end 440 | 441 | spec = util_read_spec File.join(@output_dir, output_gem) 442 | assert_includes spec.files, "lib/bar.#{RbConfig::CONFIG["DLEXT"]}" 443 | 444 | assert_match %r|Stripping symbols from extensions|, @ui.output 445 | refute_match %r|#{RbConfig::CONFIG["STRIP"]}|, @ui.output 446 | assert_match %r|using 'echo strip-custom'|, @ui.output 447 | ensure 448 | RbConfig::CONFIG["STRIP"] = old_rbconfig_strip 449 | restore_simple_run 450 | end 451 | 452 | ## 453 | # Replace `simple_run` to help testing command execution 454 | 455 | def hook_simple_run 456 | Gem::Compiler.class_eval do 457 | alias_method :orig_simple_run, :simple_run 458 | remove_method :simple_run 459 | 460 | def simple_run(command, command_name) 461 | say "#{command_name}: #{command}" 462 | end 463 | end 464 | end 465 | 466 | ## 467 | # Restore `simple_run` to its original version 468 | 469 | def restore_simple_run 470 | Gem::Compiler.class_eval do 471 | remove_method :simple_run 472 | alias_method :simple_run, :orig_simple_run 473 | end 474 | end 475 | 476 | ## 477 | # Reset RubyGems platform to original one. Useful when testing platform 478 | # specific features (like compiled extensions) 479 | 480 | def util_reset_arch 481 | util_set_arch @orig_arch 482 | end 483 | 484 | ## 485 | # Create a real gem and return the path to it. 486 | 487 | def util_bake_gem(name = "a", *extra, &block) 488 | files = ["lib/#{name}.rb"].concat(extra) 489 | 490 | spec = if Gem::VERSION >= "3.0.0" 491 | util_spec name, "1", nil, files, &block 492 | else 493 | new_spec name, "1", nil, files, &block 494 | end 495 | 496 | util_build_gem spec 497 | 498 | spec.cache_file 499 | end 500 | 501 | ## 502 | # Add a dummy, valid extension to provided spec 503 | 504 | def util_dummy_extension(spec, name = "a") 505 | extconf = File.join("ext", name, "extconf.rb") 506 | dummy_c = File.join("ext", name, "dummy.c") 507 | 508 | spec.extensions << extconf 509 | spec.files << dummy_c 510 | 511 | dir = spec.gem_dir 512 | FileUtils.mkdir_p dir 513 | 514 | Dir.chdir dir do 515 | FileUtils.mkdir_p File.dirname(extconf) 516 | 517 | # extconf.rb 518 | File.open extconf, "w" do |f| 519 | f.write <<~EOF 520 | require "mkmf" 521 | 522 | create_makefile("#{name}") 523 | EOF 524 | end 525 | 526 | # dummy.c 527 | File.open dummy_c, "w" do |f| 528 | f.write <<~EOF 529 | #include 530 | 531 | void Init_#{name}(void) 532 | { 533 | rb_p(ID2SYM(rb_intern("ok"))); 534 | } 535 | EOF 536 | end 537 | end 538 | end 539 | 540 | ## 541 | # Add a fake extension to provided spec and accept an optional script. 542 | # Default to no-op if none is provided. 543 | 544 | def util_fake_extension(spec, name = "a", script = nil) 545 | mkrf_conf = File.join("ext", name, "mkrf_conf.rb") 546 | 547 | spec.extensions << mkrf_conf 548 | 549 | dir = spec.gem_dir 550 | FileUtils.mkdir_p dir 551 | 552 | Dir.chdir dir do 553 | FileUtils.mkdir_p File.dirname(mkrf_conf) 554 | File.open mkrf_conf, "w" do |f| 555 | if script 556 | f.write script 557 | else 558 | f.write <<~EOF 559 | File.write('Rakefile', "task :default") 560 | EOF 561 | end 562 | end 563 | end 564 | end 565 | 566 | ## 567 | # Constructor of custom configure script to be used with 568 | # +util_fake_extension+ 569 | # 570 | # Provided +target+ will be used to fake an empty file at default task 571 | 572 | def util_custom_configure(target) 573 | <<~EO_MKRF 574 | File.open("Rakefile", "w") do |f| 575 | f.puts <<~EOF 576 | require 'fileutils' 577 | task :default do 578 | lib_dir = ENV["RUBYARCHDIR"] || ENV["RUBYLIBDIR"] 579 | FileUtils.touch File.join(lib_dir, #{target.inspect}) 580 | end 581 | EOF 582 | end 583 | EO_MKRF 584 | end 585 | 586 | ## 587 | # Return the metadata (spec) from the supplied filename. IO from filename 588 | # is closed automatically 589 | 590 | def util_read_spec(filename) 591 | unless Gem::VERSION >= "2.0.0" 592 | io = File.open(filename, "rb") 593 | Gem::Package.open(io, "r") { |x| x.metadata } 594 | else 595 | Gem::Package.new(filename).spec 596 | end 597 | end 598 | end 599 | --------------------------------------------------------------------------------