Skip to content

Commit 01ab4d9

Browse files
Check the version API before downloading a release.
1 parent 6fb8731 commit 01ab4d9

3 files changed

Lines changed: 75 additions & 8 deletions

File tree

‎lib/bake/gem/github/publisher.rb‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def build(number)
3333
return output(receipt, restored: true)
3434
end
3535
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
36-
if registry_digest(filename)
36+
if registry_digest(evidence.fetch(:name), evidence.fetch(:version))
3737
raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild."
3838
end
3939
package = build_package(path)
@@ -63,14 +63,14 @@ def publish(number)
6363
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)
6464
tag = "v#{receipt.fetch(:version)}"
6565
guard_tag(tag, receipt.fetch(:commit))
66-
remote_digest = registry_digest(receipt.fetch(:file))
66+
remote_digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version))
6767
if remote_digest
6868
raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256)
6969
else
7070
gem_command("push", package, "--host", "https://rubygems.org", "--attestation", bundle)
7171
end
7272
# A failed read after upload is recoverable by rerunning the same workflow.
73-
raise "Registry artifact does not match; retry after registry propagation." unless registry_digest(receipt.fetch(:file)) == receipt.fetch(:sha256)
73+
raise "Registry artifact does not match; retry after registry propagation." unless registry_digest(receipt.fetch(:name), receipt.fetch(:version)) == receipt.fetch(:sha256)
7474
attestations = registry_get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json")
7575
raise "Registry attestation is missing." unless attestations
7676
registry_bundles = JSON.parse(attestations)
@@ -193,10 +193,12 @@ def provenance(receipt)
193193
}
194194
end
195195

196-
def registry_digest(filename)
197-
if body = registry_get("https://rubygems.org/downloads/#{filename}")
198-
Digest::SHA256.hexdigest(body)
199-
end
196+
def registry_digest(name, version)
197+
# Missing downloads can return 403; use the version API to establish absence.
198+
return nil unless registry_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby")
199+
body = registry_get("https://rubygems.org/downloads/#{name}-#{version}.gem")
200+
raise "Published gem download is missing; retry after registry propagation." unless body
201+
Digest::SHA256.hexdigest(body)
200202
end
201203

202204
def registry_get(url, redirects: 5)

‎test/recovery.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def api(path)
5353
def guard_environment
5454
end
5555

56-
def registry_digest(filename)
56+
def registry_digest(name, version)
5757
@remote_digest
5858
end
5959

‎test/registry.rb‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require_relative "../lib/bake/gem/github/publisher"
7+
8+
describe Bake::Gem::GitHub::Publisher do
9+
with "#registry_digest" do
10+
let(:publisher) {subject.allocate}
11+
let(:http) {Object.new}
12+
let(:responses) {{}}
13+
let(:version_path) {"/api/v2/rubygems/example/versions/1.0.1.json?platform=ruby"}
14+
let(:download_path) {"/downloads/example-1.0.1.gem"}
15+
16+
def response(code, body = "")
17+
result = Net::HTTPResponse::CODE_TO_OBJ.fetch(code).new("1.1", code, "")
18+
mock(result) do |wrapper|
19+
wrapper.replace(:body){body}
20+
end
21+
result
22+
end
23+
24+
before do
25+
mock(http) do |wrapper|
26+
wrapper.replace(:get){|path| responses.fetch(path)}
27+
end
28+
mock(Net::HTTP) do |wrapper|
29+
wrapper.replace(:start) do |*arguments, **options, &block|
30+
block.call(http)
31+
end
32+
end
33+
end
34+
35+
it "recognizes an unpublished version without requesting the missing download" do
36+
responses[version_path] = response("404")
37+
expect(publisher.send(:registry_digest, "example", "1.0.1")).to be_nil
38+
end
39+
40+
it "hashes the actual published package bytes" do
41+
responses[version_path] = response("200", "{}")
42+
responses[download_path] = response("200", "gem bytes\x00\xff".b)
43+
expect(publisher.send(:registry_digest, "example", "1.0.1")).to be == Digest::SHA256.hexdigest("gem bytes\x00\xff".b)
44+
end
45+
46+
["403", "500"].each do |code|
47+
it "rejects version API errors", unique: code do
48+
responses[version_path] = response(code)
49+
expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: #{code}")
50+
end
51+
end
52+
53+
it "rejects a forbidden download for an existing version" do
54+
responses[version_path] = response("200", "{}")
55+
responses[download_path] = response("403")
56+
expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be == "Registry request failed: 403")
57+
end
58+
59+
it "rejects a missing download for an existing version" do
60+
responses[version_path] = response("200", "{}")
61+
responses[download_path] = response("404")
62+
expect{publisher.send(:registry_digest, "example", "1.0.1")}.to raise_exception(RuntimeError, message: be =~ /Published gem download is missing/)
63+
end
64+
end
65+
end

0 commit comments

Comments
 (0)