summaryrefslogtreecommitdiff
path: root/lib/rubygems/commands/push_command.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rubygems/commands/push_command.rb')
-rw-r--r--lib/rubygems/commands/push_command.rb185
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