Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds automatic Sigstore attestation support to gem push (targeting rubygems.org), generating an attestation bundle via sigstore-cli when no --attestation is provided and falling back to a non-attested push if attestation fails.
Changes:
- Updated
Gem::Commands::PushCommandto push with multipart attestations by default (rubygems.org-only), and retry without attestations on errors. - Added
attest!implementation usinggem exec sigstore-cli:0.2.2 sign ... --bundle .... - Refactored push command tests to validate multipart attestation requests via a shared helper and added tests for auto-attestation and fallback behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| lib/rubygems/commands/push_command.rb | Implements auto-attestation flow, multipart request building, and fallback-to-non-attested push logic. |
| test/rubygems/test_gem_commands_push_command.rb | Adds/updates tests for multipart attestation requests, auto-attestation behavior, and fallback behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -92,23 +92,64 @@ def send_gem(name) | |||
| private | |||
|
|
|||
| def send_push_request(name, args) | |||
There was a problem hiding this comment.
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.
| 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 |
| begin | ||
| send_push_request_with_attestation(name, args) | ||
| rescue StandardError => e | ||
| alert_warning "Failed to push with attestation, retrying without attestation.\n#{e.full_message}" |
There was a problem hiding this comment.
alert_warning includes e.full_message, which typically contains a full backtrace and can be extremely verbose/noisy (and may leak local paths). Consider logging only e.message by default and printing the full backtrace only under Gem.configuration.really_verbose (or similar), while still retrying without attestation.
| alert_warning "Failed to push with attestation, retrying without attestation.\n#{e.full_message}" | |
| message = "Failed to push with attestation, retrying without attestation." | |
| if Gem.configuration.really_verbose | |
| message = "#{message}\n#{e.full_message}" | |
| else | |
| message = "#{message}\n#{e.message}" | |
| end | |
| alert_warning message |
| bundle = "#{name}.sigstore.json" | ||
| 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 |
There was a problem hiding this comment.
attest! writes the bundle to a deterministic path next to the gem file (#{name}.sigstore.json) and never cleans it up. This can overwrite an existing file and leaves artifacts behind after a successful push. Consider using a Tempfile/tmpdir for the bundle (or at least avoid overwriting existing files) and deleting it after the multipart request is constructed/sent.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| def attestation_supported_host? | ||
| (@host || Gem.host) == "https://rubygems.org" | ||
| end |
There was a problem hiding this comment.
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.
| 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") | ||
|
|
||
| p1 = parts.shift | ||
| p2 = parts.shift | ||
| assert_equal "\r\n", parts.shift | ||
| assert_empty parts | ||
| attestation_path = "#{@path}.sigstore.json" | ||
| File.write(attestation_path, "auto-attestation") | ||
| @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 | ||
| @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 Gem.read_binary(attestation_path) | ||
| end | ||
|
|
||
| def test_execute_attestation_fallback | ||
| @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 |
There was a problem hiding this comment.
New behavior adds branching for skipping auto-attestation on JRuby and on non-rubygems.org hosts, but the tests added here only cover the auto-attestation happy path and the fallback-on-error path. Add coverage that verifies: (1) on a non-rubygems.org host, attest! is not called and the request is a raw application/octet-stream push, and (2) on JRuby (can be simulated by stubbing RUBY_ENGINE access), auto-attestation is skipped.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
Co-authored-by: hsbt <12301+hsbt@users.noreply.github.com>
Co-authored-by: hsbt <12301+hsbt@users.noreply.github.com>
Co-authored-by: hsbt <12301+hsbt@users.noreply.github.com>
9f87b0f to
da9e8e4
Compare
|
I'm not sure making Attestations the default path going forward would be a net positive in its current form. I don't have the stats in front of me, but I believe the vast majority of gem pushes to rubygems.org are still from local dev machines, so the vast majority of |
What was the end-user or developer problem that led to this PR?
We should expand to use attestation of rubygems. The current toolchain only enabled attestation by https://github.com/rubygems/release-gem or
--attestationoption with local sigstore json file.What is your fix for the problem, implemented in this PR?
Integrate rubygems-attestation-patch.rb to
gem pushcommand. After that,gem pushcommand perfome:--attestationoption provided,gem pushonly uses that.gem pushperform without atttestationTODO: After merging this, we should avoid to load
rubygems-attestation-patch.rbatrubygems/release-gemwith RubyGems>= 4.1.Make sure the following tasks are checked