Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 60 additions & 18 deletions lib/rubygems/commands/push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,70 @@ def send_gem(name)
private

def send_push_request(name, args)
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early return for RUBY_ENGINE == "jruby" || !attestation_supported_host? skips all attestation, including when the user explicitly passes --attestation. That changes existing behavior and also contradicts the PR description (“When --attestation option provided, gem push only uses that.”). Consider only disabling auto attestation here (i.e., still call send_push_request_with_attestation when options[:attestations].any?), and gate the attest! path separately for JRuby/unsupported hosts.

Suggested change
def send_push_request(name, args)
def send_push_request(name, args)
if options[:attestations].any?
begin
return send_push_request_with_attestation(name, args)
rescue StandardError => e
alert_warning "Failed to push with attestation, retrying without attestation.\n#{e.full_message}"
return send_push_request_without_attestation(name, args)
end
end

Copilot uses AI. Check for mistakes.
# Attestation is only supported on rubygems.org with GitHub Actions (not JRuby)
if 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
if options[:attestations].any?
request.set_form([
["gem", body, { filename: name, content_type: "application/octet-stream" }],
get_attestations_part,
], "multipart/form-data")
else
request.body = body
request.add_field "Content-Type", "application/octet-stream"
request.add_field "Content-Length", request.body.size
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
alert_warning "Failed to push with attestation, retrying without attestation.\n#{e.full_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",
"sigstore-cli:0.2.2", "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)
Expand All @@ -122,14 +171,7 @@ def get_push_scope
:push_rubygem
end

def get_attestations_part
bundles = "[" + options[:attestations].map do |attestation|
Gem.read_binary(attestation)
end.join(",") + "]"
[
"attestations",
bundles,
{ content_type: "application/json" },
]
def attestation_supported_host?
(@host || Gem.host) == "https://rubygems.org"
end
Comment on lines +174 to 176
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attestation_supported_host? uses exact string equality against "https://rubygems.org". If the host is configured as https://rubygems.org/ (trailing slash) or otherwise equivalent, auto-attestation will be incorrectly disabled. Consider normalizing/parsing the URI (scheme + host) or comparing against Gem::DEFAULT_HOST after normalization.

Copilot uses AI. Check for mistakes.
end
172 changes: 142 additions & 30 deletions test/rubygems/test_gem_commands_push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,44 +103,127 @@ def test_execute_host
end

def test_execute_attestation
@response = "Successfully registered gem: freewill (1.0.0)"
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")
omit if RUBY_ENGINE == "jruby"

ENV["GITHUB_ACTIONS"] = "true"
begin
@response = "Successfully registered gem: freewill (1.0.0)"
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")

File.write("#{@path}.sigstore.json", "attestation")
@cmd.options[:args] = [@path]
@cmd.options[:attestations] = ["#{@path}.sigstore.json"]

@cmd.execute

assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
content_length = @fetcher.last_request["Content-Length"].to_i
assert_equal content_length, @fetcher.last_request.body.length
assert_attestation_multipart Gem.read_binary("#{@path}.sigstore.json")
ensure
ENV.delete("GITHUB_ACTIONS")
end
end

def test_execute_attestation_auto
omit if RUBY_ENGINE == "jruby"

ENV["GITHUB_ACTIONS"] = "true"
begin
@response = "Successfully registered gem: freewill (1.0.0)"
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")

attestation_path = "#{@path}.sigstore.json"
attestation_content = "auto-attestation"
File.write(attestation_path, attestation_content)
@cmd.options[:args] = [@path]

@cmd.stub(:attest!, attestation_path) do
@cmd.execute
end

assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
content_length = @fetcher.last_request["Content-Length"].to_i
assert_equal content_length, @fetcher.last_request.body.length
assert_attestation_multipart attestation_content
ensure
ENV.delete("GITHUB_ACTIONS")
end
end

def test_execute_attestation_fallback
omit if RUBY_ENGINE == "jruby"

ENV["GITHUB_ACTIONS"] = "true"
begin
@response = "Successfully registered gem: freewill (1.0.0)"
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")

@cmd.options[:args] = [@path]

@cmd.stub(:attest!, proc { raise Gem::Exception, "boom" }) do
use_ui @ui do
@cmd.execute
end
end

assert_match "Failed to push with attestation, retrying without attestation.", @ui.error
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
assert_equal Gem.read_binary(@path), @fetcher.last_request.body
assert_equal "application/octet-stream",
@fetcher.last_request["Content-Type"]
ensure
ENV.delete("GITHUB_ACTIONS")
end
end

def test_execute_attestation_skipped_on_non_rubygems_host
@spec, @path = util_gem "freebird", "1.0.1" do |spec|
spec.metadata["allowed_push_host"] = "https://privategemserver.example"
end

@response = "Successfully registered gem: freebird (1.0.1)"
@fetcher.data["#{@spec.metadata["allowed_push_host"]}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")

File.write("#{@path}.sigstore.json", "attestation")
@cmd.options[:args] = [@path]
@cmd.options[:attestations] = ["#{@path}.sigstore.json"]

@cmd.execute
attest_called = false
@cmd.stub(:attest!, proc { attest_called = true }) do
@cmd.execute
end

refute attest_called, "attest! should not be called for non-rubygems.org hosts"
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
content_length = @fetcher.last_request["Content-Length"].to_i
assert_equal content_length, @fetcher.last_request.body.length
assert_equal "multipart", @fetcher.last_request.main_type, @fetcher.last_request.content_type
assert_equal "form-data", @fetcher.last_request.sub_type
assert_include @fetcher.last_request.type_params, "boundary"
boundary = @fetcher.last_request.type_params["boundary"]
assert_equal Gem.read_binary(@path), @fetcher.last_request.body
assert_equal "application/octet-stream",
@fetcher.last_request["Content-Type"]
end

parts = @fetcher.last_request.body.split(/(?:\r\n|\A)--#{Regexp.quote(boundary)}(?:\r\n|--)/m)
refute_empty parts
assert_empty parts[0]
parts.shift # remove the first empty part
def test_execute_attestation_skipped_on_jruby
@response = "Successfully registered gem: freewill (1.0.0)"
@fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK")

p1 = parts.shift
p2 = parts.shift
assert_equal "\r\n", parts.shift
assert_empty parts
@cmd.options[:args] = [@path]

assert_equal [
"Content-Disposition: form-data; name=\"gem\"; filename=\"#{@path}\"",
"Content-Type: application/octet-stream",
nil,
Gem.read_binary(@path),
].join("\r\n").b, p1
assert_equal [
"Content-Disposition: form-data; name=\"attestations\"",
nil,
"[#{Gem.read_binary("#{@path}.sigstore.json")}]",
].join("\r\n").b, p2
attest_called = false
engine = RUBY_ENGINE
Object.send :remove_const, :RUBY_ENGINE
Object.const_set :RUBY_ENGINE, "jruby"

begin
@cmd.stub(:attest!, proc { attest_called = true }) do
@cmd.execute
end

refute attest_called, "attest! should not be called on JRuby"
assert_equal Gem::Net::HTTP::Post, @fetcher.last_request.class
assert_equal Gem.read_binary(@path), @fetcher.last_request.body
assert_equal "application/octet-stream",
@fetcher.last_request["Content-Type"]
ensure
Object.send :remove_const, :RUBY_ENGINE
Object.const_set :RUBY_ENGINE, engine
end
end

def test_execute_allowed_push_host
Expand Down Expand Up @@ -642,6 +725,35 @@ def test_sending_gem_with_no_local_creds

private

def assert_attestation_multipart(attestation_payload)
assert_equal "multipart", @fetcher.last_request.main_type, @fetcher.last_request.content_type
assert_equal "form-data", @fetcher.last_request.sub_type
assert_include @fetcher.last_request.type_params, "boundary"
boundary = @fetcher.last_request.type_params["boundary"]

parts = @fetcher.last_request.body.split(/(?:\r\n|\A)--#{Regexp.quote(boundary)}(?:\r\n|--)/m)
refute_empty parts
assert_empty parts[0]
parts.shift # remove the first empty part

p1 = parts.shift
p2 = parts.shift
assert_equal "\r\n", parts.shift
assert_empty parts

assert_equal [
"Content-Disposition: form-data; name=\"gem\"; filename=\"#{@path}\"",
"Content-Type: application/octet-stream",
nil,
Gem.read_binary(@path),
].join("\r\n").b, p1
assert_equal [
"Content-Disposition: form-data; name=\"attestations\"",
nil,
"[#{attestation_payload}]",
].join("\r\n").b, p2
end

def singleton_gem_class
class << Gem; self; end
end
Expand Down