diff options
Diffstat (limited to 'lib/rubygems/commands/push_command.rb')
| -rw-r--r-- | lib/rubygems/commands/push_command.rb | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb new file mode 100644 index 0000000000..02931b3025 --- /dev/null +++ b/lib/rubygems/commands/push_command.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require_relative "../command" +require_relative "../local_remote_options" +require_relative "../gemcutter_utilities" +require_relative "../package" + +class Gem::Commands::PushCommand < Gem::Command + include Gem::LocalRemoteOptions + include Gem::GemcutterUtilities + + def description # :nodoc: + <<-EOF +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 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 + + def arguments # :nodoc: + "GEM built gem to push up" + end + + def usage # :nodoc: + "#{program_name} GEM" + end + + def initialize + 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| + 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 + 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, scope: get_push_scope + + send_gem(gem_name) + end + + def send_gem(name) + args = [:post, "api/v1/gems"] + + _, push_host = get_hosts_for(name) + + @host ||= push_host + + # Always include @host, even if it's nil + args += [@host, push_host] + + say "Pushing gem to #{@host || Gem.host}..." + + response = send_push_request(name, args) + + with_response response + end + + private + + 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 + + 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 + + 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 + + def attest!(name) + require "open3" + require "tempfile" + + tempfile = Tempfile.new([File.basename(name, ".*"), ".sigstore.json"]) + bundle = tempfile.path + tempfile.close(false) + + 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? + + bundle + end + + def get_hosts_for(name) + gem_metadata = Gem::Package.new(name).spec.metadata + + [ + gem_metadata["default_gem_server"], + gem_metadata["allowed_push_host"], + ] + 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 |
