From 3a827c01cbdd3884885c19837a4e887bf4db6369 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 10:22:30 +0900 Subject: [PATCH 01/16] Scrub GITHUB_ACTIONS in test setup On GitHub Actions the push command tests took the auto-attestation path and spawned real `gem exec sigstore-cli` subprocesses. Co-Authored-By: Claude Fable 5 --- test/rubygems/helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index dbb53600ba65..ab9c2f421608 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -388,6 +388,7 @@ def setup ENV["XDG_STATE_HOME"] = nil ENV["MAKEFLAGS"] = nil ENV["SOURCE_DATE_EPOCH"] = nil + ENV["GITHUB_ACTIONS"] = nil ENV["BUNDLER_VERSION"] = nil ENV["BUNDLE_CONFIG"] = nil ENV["BUNDLE_USER_CONFIG"] = nil From 67c58a63d11d4a54c353c5438c83fbc986e503eb Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 10:24:02 +0900 Subject: [PATCH 02/16] Only auto-attest when GITHUB_ACTIONS is "true" GitHub Actions documents the variable as "true", so any other value (including "false" or an empty string) should not trigger the auto-attestation path. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 2 +- .../rubygems/test_gem_commands_push_command.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 78fb844eb963..acb66f1221c6 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -94,7 +94,7 @@ def send_gem(name) 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"]) + if options[:attestations].any? || (RUBY_ENGINE != "jruby" && attestation_supported_host? && ENV["GITHUB_ACTIONS"] == "true") send_push_request_with_attestation(name, args) else send_push_request_without_attestation(name, args) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index f8bb09d60062..5f7282360fdc 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -170,6 +170,24 @@ def test_execute_attestation_fallback end end + def test_execute_attestation_auto_skipped_unless_github_actions_true + ENV["GITHUB_ACTIONS"] = "false" + + @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] + + attest_called = false + @cmd.stub(:attest!, proc { attest_called = true }) do + @cmd.execute + end + + refute attest_called, "attest! should not be called when GITHUB_ACTIONS is not \"true\"" + assert_equal "application/octet-stream", + @fetcher.last_request["Content-Type"] + 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" From 64b341b6d74ac8f893cdda619896ba2885a50ba3 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 10:25:17 +0900 Subject: [PATCH 03/16] Keep attestation bundle tempfile alive until read attest! returned only the tempfile path, so GC could finalize the Tempfile and unlink the bundle before it was read back, silently degrading the push to unattested. Tempfile.create scopes the file to the block and attest! now returns the bundle content instead. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 39 +++++++++---------- .../test_gem_commands_push_command.rb | 32 ++++++++++++--- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index acb66f1221c6..f3396060eac5 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -118,12 +118,7 @@ def send_push_request_with_attestation(name, args) 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 + [attest!(name)] end bundles = "[" + attestations.join(",") + "]" @@ -150,22 +145,24 @@ def attest!(name) require "shellwords" 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 - # Gem.ruby is quoted if it contains whitespace, so split it into argv - # elements to keep the quotes out of the spawned command. - out, st = Open3.capture2e( - env, - *Shellwords.split(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 + + Tempfile.create([File.basename(name, ".*"), ".sigstore.json"]) do |tempfile| + tempfile.close + bundle = tempfile.path + + # Gem.ruby is quoted if it contains whitespace, so split it into argv + # elements to keep the quotes out of the spawned command. + out, st = Open3.capture2e( + env, + *Shellwords.split(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? + + Gem.read_binary(bundle) + end end def get_hosts_for(name) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 5f7282360fdc..d85e9a33cebe 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -106,7 +106,7 @@ 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") - File.write("#{@path}.sigstore.json", "attestation") + File.write("#{@path}.sigstore.json", '{"attestation":true}') @cmd.options[:args] = [@path] @cmd.options[:attestations] = ["#{@path}.sigstore.json"] @@ -126,12 +126,10 @@ def test_execute_attestation_auto @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) + attestation_content = '{"auto":"attestation"}' @cmd.options[:args] = [@path] - @cmd.stub(:attest!, attestation_path) do + @cmd.stub(:attest!, attestation_content) do @cmd.execute end @@ -252,6 +250,7 @@ def fake_status.success? captured = nil capture_stub = lambda do |*args, **_kwargs| captured = args + File.write(args[args.index("--bundle") + 1], "{}") ["", fake_status] end Gem.stub(:ruby, '"/path with space/bin/ruby"') do @@ -267,6 +266,29 @@ def fake_status.success? assert_equal "-S", captured[2] end + def test_attest_returns_bundle_content_and_removes_tempfile + require "open3" + + fake_status = Object.new + def fake_status.success? + true + end + + bundle_path = nil + capture_stub = lambda do |*args, **_kwargs| + bundle_path = args[args.index("--bundle") + 1] + File.write(bundle_path, '{"signed":true}') + ["", fake_status] + end + + content = Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + + assert_equal '{"signed":true}', content + refute File.exist?(bundle_path), "bundle tempfile should be removed" + end + def test_execute_allowed_push_host @spec, @path = util_gem "freebird", "1.0.1" do |spec| spec.metadata["allowed_push_host"] = "https://privategemserver.example" From 90cf77657eb6f60807222ede8c464287f8c0442f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 24 Aug 2026 10:25:32 +0900 Subject: [PATCH 04/16] Prevent silent attestation downgrade on push The whole attested push was wrapped in rescue StandardError, so a failure to read an explicit --attestation file still published the gem unattested with exit 0, and a network error after the server may have accepted the multipart push retried it unattested, letting an on-path attacker strip attestations by cutting the first connection. Only the opportunistic auto-signing step falls back now, explicit attestation errors abort the push, and each bundle is validated as JSON before being joined into the attestations array. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 47 +++++++++++---- .../test_gem_commands_push_command.rb | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index f3396060eac5..07701d2cbf2d 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -114,11 +114,28 @@ def send_push_request_without_attestation(name, args) def send_push_request_with_attestation(name, args) attestations = if options[:attestations].any? + # An error loading an explicit --attestation aborts the push instead + # of silently publishing unattested. options[:attestations].map do |attestation| - Gem.read_binary(attestation) + load_attestation(attestation) end else - [attest!(name)] + # Auto-attestation is opportunistic, so signing failures fall back to + # an unattested push. The HTTP request stays outside this rescue: once + # the server may have seen the attested push, a network error must not + # trigger an unattested retry. + begin + [attest!(name)] + 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 + return send_push_request_without_attestation(name, args) + end end bundles = "[" + attestations.join(",") + "]" @@ -129,15 +146,23 @@ def send_push_request_with_attestation(name, args) ], "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 + + def load_attestation(file) + data = begin + Gem.read_binary(file) + rescue SystemCallError => e + raise Gem::Exception, "Failed to read attestation #{file}: #{e.message}" end - alert_warning message - send_push_request_without_attestation(name, args) + validate_attestation_json(data, file) + end + + def validate_attestation_json(data, source) + require "json" + JSON.parse(data) + data + rescue JSON::ParserError => e + raise Gem::Exception, "Attestation #{source} is not valid JSON: #{e.message}" end def attest!(name) @@ -161,7 +186,7 @@ def attest!(name) ) raise Gem::Exception, "Failed to sign gem:\n\n#{out}" unless st.success? - Gem.read_binary(bundle) + validate_attestation_json(Gem.read_binary(bundle), "generated by sigstore-cli") end end diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index d85e9a33cebe..c1fffb49405f 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -168,6 +168,66 @@ def test_execute_attestation_fallback end end + def test_execute_attestation_explicit_missing_file + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK") + + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + e = assert_raise Gem::Exception do + use_ui @ui do + @cmd.execute + end + end + + assert_match "Failed to read attestation", e.message + refute_match "retrying without attestation", @ui.error + assert_nil @fetcher.last_request + end + + def test_execute_attestation_explicit_invalid_json + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK") + + File.write("#{@path}.sigstore.json", "not json") + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + e = assert_raise Gem::Exception do + use_ui @ui do + @cmd.execute + end + end + + assert_match "is not valid JSON", e.message + refute_match "retrying without attestation", @ui.error + assert_nil @fetcher.last_request + end + + def test_execute_attestation_network_error_not_retried_without_attestation + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" + + requests = 0 + @fetcher.data["#{Gem.host}/api/v1/gems"] = proc do + requests += 1 + raise Gem::RemoteFetcher::FetchError.new("timed out", "#{Gem.host}/api/v1/gems") + end + + @cmd.options[:args] = [@path] + + assert_raise Gem::RemoteFetcher::FetchError do + @cmd.stub(:attest!, '{"auto":"attestation"}') do + use_ui @ui do + @cmd.execute + end + end + end + + assert_equal 1, requests + refute_match "retrying without attestation", @ui.error + end + def test_execute_attestation_auto_skipped_unless_github_actions_true ENV["GITHUB_ACTIONS"] = "false" From 45723d559e7e665a92d0ae21e4931a766c1f7460 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:03:14 +0900 Subject: [PATCH 05/16] Make the attestation skip guards testable again Scrubbing GITHUB_ACTIONS in setup left both skip tests passing no matter what the host and engine guards do, since the env term alone decided the branch. Verified by removing the guards: both now fail. Co-Authored-By: Claude Fable 5 --- test/rubygems/test_gem_commands_push_command.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index c1fffb49405f..68f037db9cb5 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -247,6 +247,10 @@ def test_execute_attestation_auto_skipped_unless_github_actions_true end def test_execute_attestation_skipped_on_non_rubygems_host + omit if RUBY_ENGINE == "jruby" + + ENV["GITHUB_ACTIONS"] = "true" + @spec, @path = util_gem "freebird", "1.0.1" do |spec| spec.metadata["allowed_push_host"] = "https://privategemserver.example" end @@ -269,6 +273,8 @@ def test_execute_attestation_skipped_on_non_rubygems_host end def test_execute_attestation_skipped_on_jruby + ENV["GITHUB_ACTIONS"] = "true" + @response = "Successfully registered gem: freewill (1.0.0)" @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") From c83409283ba7580af22059a8d5e9fd832f91f11d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:03:38 +0900 Subject: [PATCH 06/16] Say what actually failed when attestation signing fails Narrowing the rescue to the signing step made the old wording wrong in every case it can now print: no push has been attempted at that point, so nothing is being retried. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 2 +- test/rubygems/test_gem_commands_push_command.rb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 07701d2cbf2d..5354d93c99d9 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -127,7 +127,7 @@ def send_push_request_with_attestation(name, args) begin [attest!(name)] rescue StandardError => e - message = "Failed to push with attestation, retrying without attestation.\n" + message = "Failed to create an attestation, pushing without one.\n" message += if Gem.configuration.really_verbose e.full_message else diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 68f037db9cb5..0198c5363a51 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -158,7 +158,7 @@ def test_execute_attestation_fallback end end - assert_match "Failed to push with attestation, retrying without attestation.", @ui.error + assert_match "Failed to create an attestation, pushing without one.", @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", @@ -181,7 +181,7 @@ def test_execute_attestation_explicit_missing_file end assert_match "Failed to read attestation", e.message - refute_match "retrying without attestation", @ui.error + refute_match "pushing without one", @ui.error assert_nil @fetcher.last_request end @@ -199,7 +199,7 @@ def test_execute_attestation_explicit_invalid_json end assert_match "is not valid JSON", e.message - refute_match "retrying without attestation", @ui.error + refute_match "pushing without one", @ui.error assert_nil @fetcher.last_request end @@ -225,7 +225,7 @@ def test_execute_attestation_network_error_not_retried_without_attestation end assert_equal 1, requests - refute_match "retrying without attestation", @ui.error + refute_match "pushing without one", @ui.error end def test_execute_attestation_auto_skipped_unless_github_actions_true From fbe79c6e3f3d8996ec6af3fa9d71f658ca3beb50 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:04:19 +0900 Subject: [PATCH 07/16] Require attestations to be JSON objects JSON.parse accepts top-level scalars, so a file holding just null or a number passed validation and was sent on as "[null]". Hoisting the require above the begin also keeps a LoadError from being reported as an undefined JSON constant. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 10 +++++++--- test/rubygems/test_gem_commands_push_command.rb | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 5354d93c99d9..c850260e4849 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -159,10 +159,14 @@ def load_attestation(file) def validate_attestation_json(data, source) require "json" - JSON.parse(data) + + parsed = begin + JSON.parse(data) + rescue JSON::ParserError => e + raise Gem::Exception, "Attestation #{source} is not valid JSON: #{e.message}" + end + raise Gem::Exception, "Attestation #{source} is not a JSON object" unless parsed.is_a?(Hash) data - rescue JSON::ParserError => e - raise Gem::Exception, "Attestation #{source} is not valid JSON: #{e.message}" end def attest!(name) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 0198c5363a51..950486329020 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -203,6 +203,23 @@ def test_execute_attestation_explicit_invalid_json assert_nil @fetcher.last_request end + def test_execute_attestation_explicit_json_scalar + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: "", code: 200, msg: "OK") + + File.write("#{@path}.sigstore.json", "null") + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.sigstore.json"] + + e = assert_raise Gem::Exception do + use_ui @ui do + @cmd.execute + end + end + + assert_match "is not a JSON object", e.message + assert_nil @fetcher.last_request + end + def test_execute_attestation_network_error_not_retried_without_attestation omit if RUBY_ENGINE == "jruby" From 416d823c68516ec4e8700fef0fcd19e02e42017e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:04:43 +0900 Subject: [PATCH 08/16] Drop the duplicated fallback rationale Both comments described the same asymmetry, once per branch, and the explicit branch is already explained by the one above the condition. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index c850260e4849..221e46a6b079 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -114,16 +114,13 @@ def send_push_request_without_attestation(name, args) def send_push_request_with_attestation(name, args) attestations = if options[:attestations].any? - # An error loading an explicit --attestation aborts the push instead - # of silently publishing unattested. options[:attestations].map do |attestation| load_attestation(attestation) end else - # Auto-attestation is opportunistic, so signing failures fall back to - # an unattested push. The HTTP request stays outside this rescue: once - # the server may have seen the attested push, a network error must not - # trigger an unattested retry. + # Only the opportunistic signing step falls back. The request below stays + # outside this rescue because once the server may have seen the attested + # push, a network error must not trigger an unattested retry. begin [attest!(name)] rescue StandardError => e From 10081a096d743a8712e6608c78d1565b5eb1271e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:05:43 +0900 Subject: [PATCH 09/16] Let the test helper own GITHUB_ACTIONS cleanup setup scrubs it and teardown restores the whole environment, so the per-test ensure blocks were redundant once the scrub landed. Co-Authored-By: Claude Fable 5 --- .../test_gem_commands_push_command.rb | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 950486329020..2073c8847f34 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -122,50 +122,44 @@ 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_content = '{"auto":"attestation"}' - @cmd.options[:args] = [@path] + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") - @cmd.stub(:attest!, attestation_content) do - @cmd.execute - end + attestation_content = '{"auto":"attestation"}' + @cmd.options[:args] = [@path] - 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") + @cmd.stub(:attest!, attestation_content) 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 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] + @response = "Successfully registered gem: freewill (1.0.0)" + @fetcher.data["#{Gem.host}/api/v1/gems"] = HTTPResponseFactory.create(body: @response, code: 200, msg: "OK") - @cmd.stub(:attest!, proc { raise Gem::Exception, "boom" }) do - use_ui @ui do - @cmd.execute - end - end + @cmd.options[:args] = [@path] - assert_match "Failed to create an attestation, pushing without one.", @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") + @cmd.stub(:attest!, proc { raise Gem::Exception, "boom" }) do + use_ui @ui do + @cmd.execute + end end + + assert_match "Failed to create an attestation, pushing without one.", @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"] end def test_execute_attestation_explicit_missing_file From c5417134cbee3e24cae2647fafd51dbace634ffc Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:06:20 +0900 Subject: [PATCH 10/16] Say what --attestation accepts The file is now validated as JSON before the push, so the help should state the format it has to be in. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 221e46a6b079..8cc700208e1d 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -46,7 +46,8 @@ def initialize end add_option("--attestation FILE", - "Push with sigstore attestations") do |value, options| + "Push with sigstore attestations", + " (FILE must be a JSON sigstore bundle)") do |value, options| options[:attestations] << value end From f674c328de0facef8fc85cd4732ace7b728afb43 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:06:56 +0900 Subject: [PATCH 11/16] Stop printing the attestation path twice Errno messages carry the path as a rb_sysopen suffix, and the prefix already names the file. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 4 +++- test/rubygems/test_gem_commands_push_command.rb | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 8cc700208e1d..a89425790950 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -150,7 +150,9 @@ def load_attestation(file) data = begin Gem.read_binary(file) rescue SystemCallError => e - raise Gem::Exception, "Failed to read attestation #{file}: #{e.message}" + # Errno messages already end in " @ rb_sysopen - ", and the path is + # in the prefix above. + raise Gem::Exception, "Failed to read attestation #{file}: #{e.message.sub(/ @ .+\z/, "")}" end validate_attestation_json(data, file) end diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 2073c8847f34..1db0746c6632 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -175,6 +175,7 @@ def test_execute_attestation_explicit_missing_file end assert_match "Failed to read attestation", e.message + assert_equal 1, e.message.scan("#{@path}.sigstore.json").size refute_match "pushing without one", @ui.error assert_nil @fetcher.last_request end From 4b7ce26409d02383627ea531ada893630b861eba Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:07:16 +0900 Subject: [PATCH 12/16] Wrap the other ways reading an attestation can fail A path with a null byte raised a bare ArgumentError, escaping the uniform message the rescue was there to provide. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index a89425790950..4997ecdb9c35 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -149,7 +149,7 @@ def send_push_request_with_attestation(name, args) def load_attestation(file) data = begin Gem.read_binary(file) - rescue SystemCallError => e + rescue SystemCallError, IOError, ArgumentError => e # Errno messages already end in " @ rb_sysopen - ", and the path is # in the prefix above. raise Gem::Exception, "Failed to read attestation #{file}: #{e.message.sub(/ @ .+\z/, "")}" From 4549690686751ff544921c31f96d03d3aa4f1dda Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:07:40 +0900 Subject: [PATCH 13/16] Cover pushing more than one attestation The map and join that build the attestations array had never been run with more than a single element. Co-Authored-By: Claude Fable 5 --- test/rubygems/test_gem_commands_push_command.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 1db0746c6632..1b93a9fb7d47 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -118,6 +118,20 @@ def test_execute_attestation assert_attestation_multipart Gem.read_binary("#{@path}.sigstore.json") end + def test_execute_attestation_multiple + @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}.a.sigstore.json", '{"attestation":"a"}') + File.write("#{@path}.b.sigstore.json", '{"attestation":"b"}') + @cmd.options[:args] = [@path] + @cmd.options[:attestations] = ["#{@path}.a.sigstore.json", "#{@path}.b.sigstore.json"] + + @cmd.execute + + assert_attestation_multipart '{"attestation":"a"},{"attestation":"b"}' + end + def test_execute_attestation_auto omit if RUBY_ENGINE == "jruby" From 2e30bcc7b8a8845516878a0028d77a2586b76908 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:08:12 +0900 Subject: [PATCH 14/16] Cover the signing-failure abort Nothing exercised the non-zero exit status branch, so dropping the guard left the suite green while a truncated bundle went out as an unattested push. Verified by removing it. Co-Authored-By: Claude Fable 5 --- .../test_gem_commands_push_command.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 1b93a9fb7d47..395841e13652 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -358,6 +358,32 @@ def fake_status.success? assert_equal "-S", captured[2] end + def test_attest_aborts_when_signing_fails + require "open3" + + fake_status = Object.new + def fake_status.success? + false + end + + bundle_path = nil + capture_stub = lambda do |*args, **_kwargs| + bundle_path = args[args.index("--bundle") + 1] + ["sigstore-cli: no identity token available", fake_status] + end + + e = assert_raise Gem::Exception do + Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + end + + assert_match "Failed to sign gem", e.message + assert_match "no identity token available", e.message + refute_nil bundle_path, "signing command should have been spawned" + refute File.exist?(bundle_path), "bundle tempfile should be removed" + end + def test_attest_returns_bundle_content_and_removes_tempfile require "open3" From 4491b4c6f1cf10066be0b4e998f1f1fa00950e80 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 10:34:35 +0900 Subject: [PATCH 15/16] Revert the duplicated-path trim JRuby and TruffleRuby do not use CRuby's " @ rb_sysopen - " suffix, so the trim was a no-op there and the assertion failed on all three. Every portable form of it costs more than the cosmetic gain. Co-Authored-By: Claude Fable 5 --- lib/rubygems/commands/push_command.rb | 4 +--- test/rubygems/test_gem_commands_push_command.rb | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/rubygems/commands/push_command.rb b/lib/rubygems/commands/push_command.rb index 4997ecdb9c35..b3be7fdf268f 100644 --- a/lib/rubygems/commands/push_command.rb +++ b/lib/rubygems/commands/push_command.rb @@ -150,9 +150,7 @@ def load_attestation(file) data = begin Gem.read_binary(file) rescue SystemCallError, IOError, ArgumentError => e - # Errno messages already end in " @ rb_sysopen - ", and the path is - # in the prefix above. - raise Gem::Exception, "Failed to read attestation #{file}: #{e.message.sub(/ @ .+\z/, "")}" + raise Gem::Exception, "Failed to read attestation #{file}: #{e.message}" end validate_attestation_json(data, file) end diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index 395841e13652..f82aae5b5607 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -189,7 +189,6 @@ def test_execute_attestation_explicit_missing_file end assert_match "Failed to read attestation", e.message - assert_equal 1, e.message.scan("#{@path}.sigstore.json").size refute_match "pushing without one", @ui.error assert_nil @fetcher.last_request end From 5aad204a4ba2474ead900334460cb8b43a73ea22 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 28 Aug 2026 11:21:47 +0900 Subject: [PATCH 16/16] Cover the check on the generated bundle The JSON validation on sigstore-cli's output had no test: removing the call left the whole suite green. Verified by removing it. Co-Authored-By: Claude Fable 5 --- .../test_gem_commands_push_command.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/rubygems/test_gem_commands_push_command.rb b/test/rubygems/test_gem_commands_push_command.rb index f82aae5b5607..3a89c08fdaaf 100644 --- a/test/rubygems/test_gem_commands_push_command.rb +++ b/test/rubygems/test_gem_commands_push_command.rb @@ -383,6 +383,28 @@ def fake_status.success? refute File.exist?(bundle_path), "bundle tempfile should be removed" end + def test_attest_rejects_a_bundle_that_is_not_json + require "open3" + + fake_status = Object.new + def fake_status.success? + true + end + + capture_stub = lambda do |*args, **_kwargs| + File.write(args[args.index("--bundle") + 1], "not json") + ["", fake_status] + end + + e = assert_raise Gem::Exception do + Open3.stub(:capture2e, capture_stub) do + @cmd.send(:attest!, @path) + end + end + + assert_match "is not valid JSON", e.message + end + def test_attest_returns_bundle_content_and_removes_tempfile require "open3"