bake-gem-github 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,10 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require_relative "project"
7
+ require_relative "backup"
8
+ require_relative "registry"
7
9
  require "bake/releases"
8
10
  require "digest"
9
- require "net/http"
10
11
  require "openssl"
11
12
 
12
13
  module Bake
@@ -14,102 +15,104 @@ module Bake
14
15
  module GitHub
15
16
  # Builds one artifact, preserves it before upload, and resumes without moving existing tags.
16
17
  class Publisher < Project
17
- # A registered package is not yet available from the download service.
18
- class RegistryPending < RuntimeError
18
+ # Compatibility name for a registered package whose download is pending.
19
+ RegistryPending = Registry::Pending
20
+
21
+ # Load repository policy and the registry used to verify published artifacts.
22
+ # @parameter root [String] The repository root containing `config/release.yaml`.
23
+ # @parameter registry [Registry] The registry providing package digests and attestation verification.
24
+ def initialize(root, registry: Registry.new)
25
+ super(root)
26
+ @registry = registry
19
27
  end
20
28
 
21
29
  # Build or restore this workflow run's artifact, after validating the actual merged commit.
30
+ # @parameter number [String | Integer] The merged release PR number.
31
+ # @returns [Hash] The release receipt. Extends {Project#inspect_release} metadata with `file`, `sha256`, `run_id`, and `signing`; writes the receipt to `pkg/release.json` and Actions outputs when configured.
32
+ # @raises [RuntimeError] If the workflow identity, source, or retained artifact is invalid, or published bytes cannot be recovered.
22
33
  def build(number)
23
34
  guard_environment
24
35
  evidence = inspect_release(number) or raise "PR does not change the version."
25
36
  raise "Checkout must match the merged commit." unless @release.resolve("HEAD") == evidence.fetch(:commit)
37
+
26
38
  path = File.join(@root, "pkg")
27
39
  FileUtils.mkdir_p(path)
40
+
28
41
  artifact = "release-#{evidence.fetch(:commit)}"
29
42
  run = ENV.fetch("GITHUB_RUN_ID")
30
43
  artifacts = api("actions/runs/#{run}/artifacts?per_page=100").fetch("artifacts")
31
44
  retained = artifacts.find{|entry| entry.fetch("name") == artifact}
45
+
32
46
  if retained && !retained.fetch("expired")
33
47
  system("gh", "run", "download", run, "--repo", @repository, "--name", artifact, "--dir", path, chdir: @root)
34
48
  elsif release = github_release("v#{evidence.fetch(:version)}")
35
- guard_release(release, evidence.fetch(:commit))
36
- filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
37
- files = release_files(file: filename)
38
- names = release.fetch("assets").map{|asset| asset.fetch("name")}
39
- raise "Retained release is incomplete; restore the original files before retrying." unless files.all?{|file| names.include?(File.basename(file))}
40
- system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root)
49
+ restore_release(release, evidence, path)
41
50
  elsif retained
42
51
  raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying."
43
52
  else
44
- filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
45
- if registry_digest(evidence.fetch(:name), evidence.fetch(:version))
46
- raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild."
47
- end
48
- package = build_package(path)
49
- raise "Unexpected package filename." unless File.basename(package) == filename
50
- receipt = evidence.merge(file: filename, sha256: Digest::SHA256.file(package).hexdigest, run_id: run, signing: @config.fetch("signing"))
51
- File.write(File.join(path, "release.json"), JSON.pretty_generate(receipt) + "\n")
53
+ receipt = build_receipt(evidence, path, run)
52
54
  return output(receipt, restored: false)
53
55
  end
54
- receipt = load_receipt
55
- [:name, :version, :commit, :repository, :pull_request].each do |key|
56
- raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key]
57
- end
58
- output(receipt, restored: true)
56
+
57
+ receipt = restored_receipt(evidence)
58
+
59
+ return output(receipt, restored: true)
59
60
  end
60
61
 
61
62
  # Verify both attestations, upload exactly those bytes, then create only the intended tag and release.
63
+ # @parameter number [String | Integer] The merged release PR number.
64
+ # @returns [Hash] The verified receipt after registry verification and GitHub finalization.
65
+ # @raises [RuntimeError] If source, signatures, registry content, tags, or release assets conflict, or propagation times out.
66
+ # @raises [Bake::Gem::CommandExecutionError] If a verification or publishing command fails; rerunning resumes from retained artifacts.
62
67
  def publish(number)
63
68
  guard_environment
64
69
  receipt = load_receipt
65
- pr = merged(number)
66
- raise "Artifact is not for this merged PR." unless receipt[:commit] == pr.fetch("merge_commit_sha") && receipt[:pull_request] == pr.fetch("number") && receipt[:repository] == @repository
67
- raise "Checkout must match the artifact source." unless @release.resolve("HEAD") == receipt[:commit]
68
- metadata = @release.validate(base: "#{receipt[:commit]}^1", candidate: receipt[:commit])
69
- [:name, :version, :commit].each do |key|
70
- raise "Artifact #{key} differs from the merged source." unless receipt[key] == metadata[key]
71
- end
70
+ verify_source(receipt, number)
71
+
72
72
  package = File.join(@root, "pkg", receipt.fetch(:file))
73
- verify_certificate(package) if @config.fetch("signing")
74
73
  bundle = "#{package}.sigstore.json"
75
- identity = "https://github.com/#{@repository}/.github/workflows/release-publish.yaml@refs/heads/#{@config.fetch('branch')}"
76
- gem_command("exec", "sigstore-cli:0.2.3", "verify", package, "--bundle", bundle, "--certificate-identity", identity, "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com")
77
- verify_provenance(package)
74
+ verify_artifact(package, bundle)
75
+
78
76
  tag = "v#{receipt.fetch(:version)}"
79
77
  guard_tag(tag, receipt.fetch(:commit))
80
- remote_digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version))
78
+ remote_digest = @registry.digest(receipt.fetch(:name), receipt.fetch(:version))
81
79
  if remote_digest
82
80
  raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256)
83
81
  end
84
- # Keep verified bytes independently of workflow attempts before uploading:
82
+
83
+ # Preserve verified bytes independently of workflow attempts before uploading:
85
84
  release = preserve_release(receipt)
86
85
  unless remote_digest
87
86
  gem_command("push", package, "--host", "https://rubygems.org", "--attestation", bundle)
88
87
  end
89
- verify_registry(receipt, bundle)
90
- unless readlines("git", "tag", "--list", tag, chdir: @root).any?
91
- system("git", "tag", tag, receipt.fetch(:commit), chdir: @root)
92
- end
93
- system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", "refs/tags/#{tag}", chdir: @root)
94
- if release.fetch("draft")
95
- system("gh", "release", "edit", tag, "--repo", @repository, "--draft=false", "--verify-tag", chdir: @root)
96
- end
97
- receipt
88
+
89
+ @registry.verify(receipt, bundle)
90
+ finalize_release(release, tag, receipt.fetch(:commit))
91
+
92
+ return receipt
98
93
  end
99
94
 
100
95
  # Load artifact evidence and verify the stored digest and filename.
96
+ # @returns [Hash] The receipt with symbol keys, including the verified `file` and `sha256`.
97
+ # @raises [RuntimeError] If the package filename is invalid or its bytes do not match the receipt.
101
98
  def load_receipt
102
99
  receipt = JSON.parse(File.read(File.join(@root, "pkg", "release.json")), symbolize_names: true)
103
100
  filename = receipt.fetch(:file)
104
101
  raise "Invalid artifact filename." unless filename == File.basename(filename) && filename.end_with?(".gem")
105
102
  raise "Artifact digest mismatch." unless Digest::SHA256.file(File.join(@root, "pkg", filename)).hexdigest == receipt.fetch(:sha256)
106
- receipt
103
+
104
+ return receipt
107
105
  end
108
106
 
109
107
  # Refuse local or remote tag collisions before uploading a package.
108
+ # @parameter tag [String] The version tag to publish.
109
+ # @parameter commit [String] The intended release commit.
110
+ # @returns [Nil] If local and remote tags are absent or already identify the intended commit.
111
+ # @raises [RuntimeError] If an existing tag identifies another commit.
110
112
  def guard_tag(tag, commit)
111
113
  local = readlines("git", "tag", "--list", tag, chdir: @root)
112
114
  raise "Release tag points to another commit." if local.any? && @release.resolve(tag) != commit
115
+
113
116
  remote = readlines("git", "ls-remote", "--tags", "origin", "refs/tags/#{tag}", "refs/tags/#{tag}^{}", chdir: @root).map{|line| line.split}
114
117
  peeled = remote.find{|sha, ref| ref.end_with?("^{}")} || remote.first
115
118
  raise "Remote release tag points to another commit." if peeled && peeled.first != commit
@@ -117,19 +120,177 @@ module Bake
117
120
 
118
121
  private
119
122
 
123
+ # Restore original files from a complete archive or a legacy set of assets.
124
+ def restore_release(release, evidence, path)
125
+ guard_release(release, evidence.fetch(:commit))
126
+ filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
127
+ files = release_files(file: filename)
128
+
129
+ if backup = release.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}
130
+ contents = read_backup(release, backup, files)
131
+
132
+ # Check every local file before writing any restored content:
133
+ contents.each do |name, content|
134
+ file = File.join(path, name)
135
+ raise "Existing artifact differs: #{name}" if File.exist?(file) && File.binread(file) != content
136
+ end
137
+
138
+ return contents.each do |name, content|
139
+ File.binwrite(File.join(path, name), content)
140
+ end
141
+ else
142
+ names = release.fetch("assets").map{|asset| asset.fetch("name")}
143
+ unless files.all?{|file| names.include?(File.basename(file))}
144
+ raise "Retained release is incomplete; restore the original files before retrying."
145
+ end
146
+
147
+ return system(
148
+ "gh", "release", "download", release.fetch("tag_name"), "--repo", @repository,
149
+ "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root,
150
+ )
151
+ end
152
+ end
153
+
154
+ # Build only an unpublished version and retain the source identity and package digest.
155
+ def build_receipt(evidence, path, run)
156
+ filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
157
+ if @registry.digest(evidence.fetch(:name), evidence.fetch(:version))
158
+ raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild."
159
+ end
160
+
161
+ package = build_package(path)
162
+ raise "Unexpected package filename." unless File.basename(package) == filename
163
+
164
+ receipt = evidence.merge(
165
+ file: filename,
166
+ sha256: Digest::SHA256.file(package).hexdigest,
167
+ run_id: run,
168
+ signing: @config.fetch("signing"),
169
+ )
170
+ File.write(File.join(path, "release.json"), JSON.pretty_generate(receipt) + "\n")
171
+
172
+ return receipt
173
+ end
174
+
175
+ # Compare recovered evidence with the independently validated release source.
176
+ def restored_receipt(evidence)
177
+ receipt = load_receipt
178
+ [:name, :version, :commit, :repository, :pull_request].each do |key|
179
+ raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key]
180
+ end
181
+
182
+ return receipt
183
+ end
184
+
185
+ # Bind the receipt to the actual merged PR and regenerated release content.
186
+ def verify_source(receipt, number)
187
+ pull_request = merged(number)
188
+ unless receipt[:commit] == pull_request.fetch("merge_commit_sha") &&
189
+ receipt[:pull_request] == pull_request.fetch("number") &&
190
+ receipt[:repository] == @repository
191
+ raise "Artifact is not for this merged PR."
192
+ end
193
+ raise "Checkout must match the artifact source." unless @release.resolve("HEAD") == receipt[:commit]
194
+
195
+ metadata = @release.validate(base: "#{receipt[:commit]}^1", candidate: receipt[:commit])
196
+ return [:name, :version, :commit].each do |key|
197
+ raise "Artifact #{key} differs from the merged source." unless receipt[key] == metadata[key]
198
+ end
199
+ end
200
+
201
+ # Verify the optional certificate signature and both attestation formats.
202
+ def verify_artifact(package, bundle)
203
+ verify_certificate(package) if @config.fetch("signing")
204
+
205
+ identity = "https://github.com/#{@repository}/.github/workflows/release-publish.yaml@refs/heads/#{@config.fetch('branch')}"
206
+ gem_command(
207
+ "exec", "sigstore-cli:0.2.3", "verify", package, "--bundle", bundle,
208
+ "--certificate-identity", identity,
209
+ "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
210
+ )
211
+
212
+ return verify_provenance(package)
213
+ end
214
+
215
+ # Publish the version tag and draft only after registry verification completes.
216
+ def finalize_release(release, tag, commit)
217
+ unless readlines("git", "tag", "--list", tag, chdir: @root).any?
218
+ system("git", "tag", tag, commit, chdir: @root)
219
+ end
220
+ push("refs/tags/#{tag}")
221
+
222
+ if release.fetch("draft")
223
+ return system("gh", "release", "edit", tag, "--repo", @repository, "--draft=false", "--verify-tag", chdir: @root)
224
+ end
225
+ end
226
+
227
+ # Create a draft using notes from the exact release checkout.
228
+ def create_draft(receipt, tag)
229
+ notes = Bake::Releases.notes(tag, path: File.join(@root, "releases.md"))
230
+ metadata = "#{receipt.fetch(:pull_request_url)}\n\nSource: #{receipt.fetch(:commit)}\nSHA256: #{receipt.fetch(:sha256)}\n"
231
+
232
+ return Tempfile.create("release") do |file|
233
+ file.write(JSON.generate(
234
+ tag_name: tag,
235
+ draft: true,
236
+ target_commitish: receipt.fetch(:commit),
237
+ name: tag,
238
+ body: [notes, metadata].compact.join("\n"),
239
+ ))
240
+ file.flush
241
+
242
+ # Use the creation response because the release list can remain stale:
243
+ JSON.parse(readlines(
244
+ "gh", "api", "repos/#{@repository}/releases", "--method", "POST",
245
+ "--input", file.path, chdir: @root,
246
+ ).join)
247
+ end
248
+ end
249
+
250
+ # Refuse to overwrite individual assets containing different bytes.
251
+ def verify_assets(assets, files)
252
+ files.each do |file|
253
+ if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
254
+ unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}"
255
+ raise "Existing release asset differs: #{file}"
256
+ end
257
+ end
258
+ end
259
+ end
260
+
261
+ # Preserve the complete set before uploading individual assets.
262
+ def preserve_backup(release, files)
263
+ if asset = release.fetch("assets").find{|entry| entry.fetch("name") == "release.tar"}
264
+ contents = read_backup(release, asset, files)
265
+ unless files.all?{|file| contents.fetch(File.basename(file)) == File.binread(file)}
266
+ raise "Existing release backup differs."
267
+ end
268
+ else
269
+ backup_path = File.join(@root, "pkg/release.tar")
270
+ Backup.write(backup_path, files)
271
+
272
+ return system("gh", "release", "upload", release.fetch("tag_name"), backup_path, "--repo", @repository, chdir: @root)
273
+ end
274
+ end
275
+
120
276
  def github_release(tag)
121
277
  pages = JSON.parse(readlines("gh", "api", "--paginate", "--slurp", "repos/#{@repository}/releases?per_page=100", chdir: @root).join)
122
278
  matches = pages.flatten(1).select{|release| release.fetch("tag_name") == tag}
123
279
  raise "Multiple GitHub releases have the same tag." if matches.size > 1
280
+
124
281
  if release = matches.first
125
282
  return api("releases/#{release.fetch('id')}")
126
283
  end
284
+
127
285
  # Resolve pending tags directly when the REST list has not caught up:
128
286
  owner, name = @repository.split("/", 2)
129
287
  query = "query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { databaseId } } }"
130
- result = JSON.parse(readlines("gh", "api", "graphql", "-f", "query=#{query}", "-f", "owner=#{owner}", "-f", "name=#{name}", "-f", "tag=#{tag}", chdir: @root).join)
288
+ result = JSON.parse(readlines(
289
+ "gh", "api", "graphql", "-f", "query=#{query}", "-f", "owner=#{owner}", "-f",
290
+ "name=#{name}", "-f", "tag=#{tag}", chdir: @root,
291
+ ).join)
131
292
  if release = result.fetch("data").fetch("repository").fetch("release")
132
- api("releases/#{release.fetch('databaseId')}")
293
+ return api("releases/#{release.fetch('databaseId')}")
133
294
  end
134
295
  end
135
296
 
@@ -145,52 +306,32 @@ module Bake
145
306
 
146
307
  def preserve_release(receipt)
147
308
  tag = "v#{receipt.fetch(:version)}"
148
- release = github_release(tag)
149
- unless release
150
- notes = Bake::Releases.notes(tag, path: File.join(@root, "releases.md"))
151
- metadata = "#{receipt.fetch(:pull_request_url)}\n\nSource: #{receipt.fetch(:commit)}\nSHA256: #{receipt.fetch(:sha256)}\n"
152
- Tempfile.create("release") do |file|
153
- file.write(JSON.generate(
154
- tag_name: tag, draft: true, target_commitish: receipt.fetch(:commit), name: tag,
155
- body: [notes, metadata].compact.join("\n")
156
- ))
157
- file.flush
158
- # The release list can remain stale after a successful creation:
159
- release = JSON.parse(readlines("gh", "api", "repos/#{@repository}/releases", "--method", "POST", "--input", file.path, chdir: @root).join)
160
- end
161
- end
309
+ release = github_release(tag) || create_draft(receipt, tag)
162
310
  guard_release(release, receipt.fetch(:commit))
311
+
163
312
  assets = release.fetch("assets")
164
- release_files(receipt).each do |file|
165
- if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
166
- raise "Existing release asset differs: #{file}" unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}"
167
- else
313
+ files = release_files(receipt)
314
+ verify_assets(assets, files)
315
+ preserve_backup(release, files)
316
+
317
+ files.each do |file|
318
+ unless assets.any?{|asset| asset.fetch("name") == File.basename(file)}
168
319
  system("gh", "release", "upload", tag, file, "--repo", @repository, chdir: @root)
169
320
  end
170
321
  end
171
- release
322
+
323
+ return release
172
324
  end
173
325
 
174
- def verify_registry(receipt, bundle, attempts: 7, delay: 10)
175
- local_bundle = JSON.parse(File.read(bundle))
176
- attempts.times do |attempt|
177
- begin
178
- if digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version))
179
- raise "Published version has different bytes." unless digest == receipt.fetch(:sha256)
180
- if attestations = registry_get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json")
181
- raise "Registry does not contain this artifact's Sigstore bundle." unless contains_bundle?(JSON.parse(attestations), local_bundle)
182
- return
183
- end
184
- end
185
- rescue RegistryPending
186
- # The version API can become visible before the gem download:
187
- end
188
- if attempt < attempts - 1
189
- Console.info(self, "Waiting for RubyGems to serve the release.")
190
- sleep(delay)
191
- end
326
+ def read_backup(release, asset, files)
327
+ Tempfile.create("release-backup") do |file|
328
+ system(
329
+ "gh", "release", "download", release.fetch("tag_name"), "--repo", @repository,
330
+ "--pattern", "release.tar", "--output", file.path, "--clobber", chdir: @root,
331
+ )
332
+ raise "Release backup digest mismatch." unless asset.fetch("digest") == "sha256:#{Digest::SHA256.file(file.path).hexdigest}"
333
+ Backup.read(file.path, files.map{|path| File.basename(path)})
192
334
  end
193
- raise "Registry propagation did not complete; rerun this workflow to resume from the retained release."
194
335
  end
195
336
 
196
337
  def gem_command(*arguments)
@@ -206,19 +347,11 @@ module Bake
206
347
  raise "Publishing requires the default branch workflow." unless ENV["GITHUB_REF"] == "refs/heads/#{@config.fetch('branch')}"
207
348
  end
208
349
 
209
- def contains_bundle?(value, bundle)
210
- return true if value == bundle
211
- case value
212
- when Hash then value.values.any?{|child| contains_bundle?(child, bundle)}
213
- when Array then value.any?{|child| contains_bundle?(child, bundle)}
214
- else false
215
- end
216
- end
217
-
218
350
  def verify_certificate(path)
219
351
  policy = ::Gem::Security::Policy.new("Release", only_trusted: false)
220
352
  package = ::Gem::Package.new(path, policy)
221
353
  package.verify
354
+
222
355
  signer = OpenSSL::X509::Certificate.new(package.spec.cert_chain.last)
223
356
  expected = OpenSSL::X509::Certificate.new(File.read(File.join(@root, "release.cert")))
224
357
  raise "Package signer differs from release.cert." unless signer.to_der == expected.to_der
@@ -228,11 +361,13 @@ module Bake
228
361
  unless @config.fetch("signing")
229
362
  return @release.worktree("HEAD"){|source| @release.bake(source, "gem:build", root: path, signing_key: false)}
230
363
  end
364
+
231
365
  certificate = OpenSSL::X509::Certificate.new(File.read(File.join(@root, "release.cert")))
232
366
  key = OpenSSL::PKey.read(ENV.fetch("GEM_SIGNING_KEY"))
233
367
  raise "Signing key does not match release.cert." unless certificate.check_private_key(key)
234
368
  raise "Signing certificate is not currently valid." unless (certificate.not_before..certificate.not_after).cover?(Time.now)
235
- Tempfile.create("gem-signing-key") do |file|
369
+
370
+ return Tempfile.create("gem-signing-key") do |file|
236
371
  file.chmod(0600)
237
372
  file.write(ENV.fetch("GEM_SIGNING_KEY"))
238
373
  file.flush
@@ -250,37 +385,22 @@ module Bake
250
385
  file.puts "restored=#{restored}"
251
386
  end
252
387
  end
253
- receipt
388
+
389
+ return receipt
254
390
  end
255
391
 
256
392
  def verify_provenance(package)
257
393
  ref = "refs/heads/#{@config.fetch('branch')}"
258
394
  identity = "https://github.com/#{@repository}/.github/workflows/release-publish.yaml@#{ref}"
259
- # The signed receipt binds the package digest to the release commit, independently of the workflow revision.
260
- [package, File.join(@root, "pkg", "release.json")].each do |file|
261
- system("gh", "attestation", "verify", file, "--repo", @repository, "--bundle", File.join(@root, "pkg", "provenance.sigstore.json"), "--cert-identity", identity, "--source-ref", ref, "--deny-self-hosted-runners", chdir: @root)
262
- end
263
- end
264
-
265
- def registry_digest(name, version)
266
- # Missing downloads can return 403; use the version API to establish absence.
267
- return nil unless registry_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby")
268
- body = registry_get("https://rubygems.org/downloads/#{name}-#{version}.gem")
269
- raise RegistryPending, "Published gem download is missing; retry after registry propagation." unless body
270
- Digest::SHA256.hexdigest(body)
271
- end
272
-
273
- def registry_get(url, redirects: 5)
274
- uri = URI(url)
275
- raise "Registry redirect requires HTTPS." unless uri.scheme == "https"
276
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60){|http| http.get(uri.request_uri)}
277
- return nil if response.is_a?(Net::HTTPNotFound)
278
- if response.is_a?(Net::HTTPRedirection)
279
- raise "Too many registry redirects." unless redirects > 0
280
- return registry_get(URI.join(url, response.fetch("location")).to_s, redirects: redirects - 1)
395
+
396
+ # The signed receipt binds the package digest to the release commit, independently of the workflow revision:
397
+ return [package, File.join(@root, "pkg", "release.json")].each do |file|
398
+ system(
399
+ "gh", "attestation", "verify", file, "--repo", @repository, "--bundle",
400
+ File.join(@root, "pkg", "provenance.sigstore.json"), "--cert-identity", identity,
401
+ "--source-ref", ref, "--deny-self-hosted-runners", chdir: @root,
402
+ )
281
403
  end
282
- raise "Registry request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
283
- response.body
284
404
  end
285
405
  end
286
406
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "console"
7
+ require "digest"
8
+ require "json"
9
+ require "net/http"
10
+
11
+ module Bake
12
+ module Gem
13
+ module GitHub
14
+ # Reads RubyGems package bytes and verifies registry propagation of the release attestations.
15
+ class Registry
16
+ # A registered package is not yet available from the download service.
17
+ class Pending < RuntimeError
18
+ end
19
+
20
+ # Configure the HTTP transport used for registry requests.
21
+ # @parameter http [Interface(:start)] The transport providing Net::HTTP-compatible sessions.
22
+ def initialize(http: Net::HTTP)
23
+ @http = http
24
+ end
25
+
26
+ # Compute a published package's digest, distinguishing absence from propagation delays.
27
+ # @parameter name [String] The gem name.
28
+ # @parameter version [String] The stable gem version.
29
+ # @returns [String | Nil] The SHA256 digest, or nil if the version is not registered.
30
+ # @raises [Pending] If the version is registered but its download is absent.
31
+ # @raises [RuntimeError] If a request fails or a redirect violates the HTTPS policy.
32
+ def digest(name, version)
33
+ # Missing downloads can return 403; use the version API to establish absence:
34
+ return nil unless get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby")
35
+
36
+ body = get("https://rubygems.org/downloads/#{name}-#{version}.gem")
37
+ raise Pending, "Published gem download is missing; retry after registry propagation." unless body
38
+
39
+ return Digest::SHA256.hexdigest(body)
40
+ end
41
+
42
+ # Wait for the published bytes and attestation to match the retained release.
43
+ # @parameter receipt [Hash] Symbol-keyed release evidence containing `name`, `version`, and `sha256`.
44
+ # @parameter bundle [String] The local Sigstore bundle path.
45
+ # @parameter attempts [Integer] The maximum number of verification attempts.
46
+ # @parameter delay [Numeric] Seconds to wait between attempts.
47
+ # @returns [Nil] When both the bytes and attestation match.
48
+ # @raises [RuntimeError] If verification fails or registry propagation times out.
49
+ def verify(receipt, bundle, attempts: 7, delay: 10)
50
+ local_bundle = JSON.parse(File.read(bundle))
51
+ attempts.times do |attempt|
52
+ begin
53
+ if remote_digest = digest(receipt.fetch(:name), receipt.fetch(:version))
54
+ raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256)
55
+ if attestations = get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json")
56
+ raise "Registry does not contain this artifact's Sigstore bundle." unless contains_bundle?(JSON.parse(attestations), local_bundle)
57
+ return
58
+ end
59
+ end
60
+ rescue Pending
61
+ # The version API can become visible before the gem download.
62
+ end
63
+
64
+ if attempt < attempts - 1
65
+ Console.info(self, "Waiting for RubyGems to serve the release.")
66
+ sleep(delay)
67
+ end
68
+ end
69
+ raise "Registry propagation did not complete; rerun this workflow to resume from the retained release."
70
+ end
71
+
72
+ private
73
+
74
+ def get(url, redirects: 5)
75
+ uri = URI(url)
76
+ raise "Registry redirect requires HTTPS." unless uri.scheme == "https"
77
+
78
+ response = @http.start(uri.host, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60){|http| http.get(uri.request_uri)}
79
+ return nil if response.is_a?(Net::HTTPNotFound)
80
+
81
+ if response.is_a?(Net::HTTPRedirection)
82
+ raise "Too many registry redirects." unless redirects > 0
83
+ return get(URI.join(url, response.fetch("location")).to_s, redirects: redirects - 1)
84
+ end
85
+ raise "Registry request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
86
+
87
+ return response.body
88
+ end
89
+
90
+ def contains_bundle?(value, bundle)
91
+ return true if value == bundle
92
+ case value
93
+ when Hash
94
+ return value.values.any?{|child| contains_bundle?(child, bundle)}
95
+ when Array
96
+ return value.any?{|child| contains_bundle?(child, bundle)}
97
+ else
98
+ return false
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end