diff options
Diffstat (limited to 'lib/rubygems/commands/push_command.rb')
| -rw-r--r-- | lib/rubygems/commands/push_command.rb | 169 |
1 files changed, 125 insertions, 44 deletions
diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index d294cbc8df..02931b3025 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -require 'rubygems/command' -require 'rubygems/local_remote_options' -require 'rubygems/gemcutter_utilities' -require 'rubygems/package' + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../gemcutter_utilities" +require_relative "../package" class Gem::Commands::PushCommand < Gem::Command include Gem::LocalRemoteOptions @@ -13,8 +14,10 @@ class Gem::Commands::PushCommand < Gem::Command The push command uploads a gem to the push server (the default is https://rubygems.org) and adds it to the index. -The gem can be removed from the index (but only the index) using the yank +The gem can be removed from the index and deleted from the server using the yank command. For further discussion see the help for the yank command. + +The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. EOF end @@ -27,78 +30,156 @@ command. For further discussion see the help for the yank command. end def initialize - super 'push', 'Push a gem up to the gem server', :host => self.host + super "push", "Push a gem up to the gem server", host: host, attestations: [] + + @user_defined_host = false add_proxy_option add_key_option + add_otp_option - add_option('--host HOST', - 'Push to another gemcutter-compatible host', - ' (e.g. https://rubygems.org)') do |value, options| + add_option("--host HOST", + "Push to another gemcutter-compatible host", + " (e.g. https://rubygems.org)") do |value, options| options[:host] = value + @user_defined_host = true + end + + add_option("--attestation FILE", + "Push with sigstore attestations") do |value, options| + options[:attestations] << value end @host = nil end def execute - @host = options[:host] + gem_name = get_one_gem_name + default_gem_server, push_host = get_hosts_for(gem_name) + + @host = if @user_defined_host + options[:host] + elsif default_gem_server + default_gem_server + elsif push_host + push_host + else + options[:host] + end - sign_in @host + sign_in @host, scope: get_push_scope - send_gem get_one_gem_name + send_gem(gem_name) end - def send_gem name + def send_gem(name) args = [:post, "api/v1/gems"] - latest_rubygems_version = Gem.latest_rubygems_version + _, push_host = get_hosts_for(name) - if latest_rubygems_version < Gem.rubygems_version and - Gem.rubygems_version.prerelease? and - Gem::Version.new('2.0.0.rc.2') != Gem.rubygems_version then - alert_error <<-ERROR -You are using a beta release of RubyGems (#{Gem::VERSION}) which is not -allowed to push gems. Please downgrade or upgrade to a release version. + @host ||= push_host -The latest released RubyGems version is #{latest_rubygems_version} + # Always include @host, even if it's nil + args += [@host, push_host] -You can upgrade or downgrade to the latest release version with: + say "Pushing gem to #{@host || Gem.host}..." - gem update --system=#{latest_rubygems_version} + response = send_push_request(name, args) - ERROR - terminate_interaction 1 - end + with_response response + end - gem_data = Gem::Package.new(name) + private - unless @host then - @host = gem_data.spec.metadata['default_gem_server'] + def send_push_request(name, args) + # Always honor explicit --attestation option + # Auto-attestation is only supported on rubygems.org with GitHub Actions (not JRuby) + if options[:attestations].any? || (RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"]) + send_push_request_with_attestation(name, args) + else + send_push_request_without_attestation(name, args) end + end - push_host = nil + def send_push_request_without_attestation(name, args) + scope = get_push_scope + rubygems_api_request(*args, scope: scope) do |request| + body = Gem.read_binary name + request.body = body + request.add_field "Content-Type", "application/octet-stream" + request.add_field "Content-Length", request.body.size + request.add_field "Authorization", api_key + end + end - if gem_data.spec.metadata.has_key?('allowed_push_host') - push_host = gem_data.spec.metadata['allowed_push_host'] + def send_push_request_with_attestation(name, args) + attestations = if options[:attestations].any? + options[:attestations].map do |attestation| + Gem.read_binary(attestation) + end + else + bundle_path = attest!(name) + begin + [Gem.read_binary(bundle_path)] + ensure + File.unlink(bundle_path) if bundle_path && File.exist?(bundle_path) + end end + bundles = "[" + attestations.join(",") + "]" + + rubygems_api_request(*args, scope: get_push_scope) do |request| + request.set_form([ + ["gem", Gem.read_binary(name), { filename: name, content_type: "application/octet-stream" }], + ["attestations", bundles, { content_type: "application/json" }], + ], "multipart/form-data") + request.add_field "Authorization", api_key + end + rescue StandardError => e + message = "Failed to push with attestation, retrying without attestation.\n" + message += if Gem.configuration.really_verbose + e.full_message + else + e.message + end + alert_warning message + send_push_request_without_attestation(name, args) + end - @host ||= push_host + def attest!(name) + require "open3" + require "tempfile" - # Always include @host, even if it's nil - args += [ @host, push_host ] + tempfile = Tempfile.new([File.basename(name, ".*"), ".sigstore.json"]) + bundle = tempfile.path + tempfile.close(false) - say "Pushing gem to #{@host || Gem.host}..." + env = defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h + out, st = Open3.capture2e( + env, + Gem.ruby, "-S", "gem", "exec", "--conservative", + "sigstore-cli", "sign", name, "--bundle", bundle, + unsetenv_others: true + ) + raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success? - response = rubygems_api_request(*args) do |request| - request.body = Gem.read_binary name - request.add_field "Content-Length", request.body.size - request.add_field "Content-Type", "application/octet-stream" - request.add_field "Authorization", api_key - end + bundle + end - with_response response + def get_hosts_for(name) + gem_metadata = Gem::Package.new(name).spec.metadata + + [ + gem_metadata["default_gem_server"], + gem_metadata["allowed_push_host"], + ] end -end + def get_push_scope + :push_rubygem + end + def attestation_supported_host? + host = (@host || Gem.host).to_s.chomp("/") + host == Gem::DEFAULT_HOST + end +end |
