diff --git a/lib/bake/gem/github/publisher.rb b/lib/bake/gem/github/publisher.rb index 1c3913d..1a23d0b 100644 --- a/lib/bake/gem/github/publisher.rb +++ b/lib/bake/gem/github/publisher.rb @@ -33,7 +33,7 @@ def build(number) return output(receipt, restored: true) end filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem" - if registry_digest(filename) + if registry_digest(evidence.fetch(:name), evidence.fetch(:version)) raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild." end package = build_package(path) @@ -63,14 +63,14 @@ def publish(number) system("gh", "attestation", "verify", package, "--repo", @repository, "--bundle", File.join(@root, "pkg", "provenance.sigstore.json"), "--signer-workflow", "#{@repository}/.github/workflows/release-publish.yaml", "--source-digest", receipt.fetch(:commit), "--deny-self-hosted-runners", chdir: @root) tag = "v#{receipt.fetch(:version)}" guard_tag(tag, receipt.fetch(:commit)) - remote_digest = registry_digest(receipt.fetch(:file)) + remote_digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version)) if remote_digest raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256) else gem_command("push", package, "--host", "https://rubygems.org", "--attestation", bundle) end # A failed read after upload is recoverable by rerunning the same workflow. - raise "Registry artifact does not match; retry after registry propagation." unless registry_digest(receipt.fetch(:file)) == receipt.fetch(:sha256) + raise "Registry artifact does not match; retry after registry propagation." unless registry_digest(receipt.fetch(:name), receipt.fetch(:version)) == receipt.fetch(:sha256) attestations = registry_get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json") raise "Registry attestation is missing." unless attestations registry_bundles = JSON.parse(attestations) @@ -193,10 +193,12 @@ def provenance(receipt) } end - def registry_digest(filename) - if body = registry_get("https://rubygems.org/downloads/#{filename}") - Digest::SHA256.hexdigest(body) - end + def registry_digest(name, version) + # Missing downloads can return 403; use the version API to establish absence. + return nil unless registry_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby") + body = registry_get("https://rubygems.org/downloads/#{name}-#{version}.gem") + raise "Published gem download is missing; retry after registry propagation." unless body + Digest::SHA256.hexdigest(body) end def registry_get(url, redirects: 5) diff --git a/test/recovery.rb b/test/recovery.rb index 320754c..53257f8 100644 --- a/test/recovery.rb +++ b/test/recovery.rb @@ -53,7 +53,7 @@ def api(path) def guard_environment end - def registry_digest(filename) + def registry_digest(name, version) @remote_digest end diff --git a/test/registry.rb b/test/registry.rb new file mode 100644 index 0000000..3ea5fe6 --- /dev/null +++ b/test/registry.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../lib/bake/gem/github/publisher" + +describe Bake::Gem::GitHub::Publisher do + with "#registry_digest" do + let(:publisher) {subject.allocate} + let(:http) {Object.new} + let(:responses) {{}} + let(:version_path) {"/api/v2/rubygems/example/versions/1.0.1.json?platform=ruby"} + let(:download_path) {"/downloads/example-1.0.1.gem"} + + def response(code, body = "") + result = Net::HTTPResponse::CODE_TO_OBJ.fetch(code).new("1.1", code, "") + mock(result) do |wrapper| + wrapper.replace(:body){body} + end + result + end + + before do + mock(http) do |wrapper| + wrapper.replace(:get){|path| responses.fetch(path)} + end + mock(Net::HTTP) do |wrapper| + wrapper.replace(:start) do |*arguments, **options, &block| + block.call(http) + end + end + end + + it "recognizes an unpublished version without requesting the missing download" do + responses[version_path] = response("404") + expect(publisher.send(:registry_digest, "example", "1.0.1")).to be_nil + end + + it "hashes the actual published package bytes" do + responses[version_path] = response("200", "{}") + responses[download_path] = response("200", "gem bytes\x00\xff".b) + expect(publisher.send(:registry_digest, "example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes\x00\xff".b) + end + + ["403", "500"].each do |code| + it "rejects version API errors", unique: code do + responses[version_path] = response(code) + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: #{code}") + end + end + + it "rejects a forbidden download for an existing version" do + responses[version_path] = response("200", "{}") + responses[download_path] = response("403") + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: 403") + end + + it "rejects a missing download for an existing version" do + responses[version_path] = response("200", "{}") + responses[download_path] = response("404") + expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Published gem download is missing/) + end + end +end