Skip to content
Merged
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
16 changes: 9 additions & 7 deletions lib/bake/gem/github/publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion test/recovery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def api(path)
def guard_environment
end

def registry_digest(filename)
def registry_digest(name, version)
@remote_digest
end

Expand Down
65 changes: 65 additions & 0 deletions test/registry.rb
Original file line number Diff line number Diff line change
@@ -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
Loading