bake-gem-github 0.2.0 → 0.3.0
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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/gem/github/release.rb +32 -11
- data/bake/gem/github/setup.rb +8 -3
- data/bake/gem/github.rb +23 -9
- data/context/getting-started.md +70 -69
- data/context/index.yaml +20 -4
- data/context/preparing-releases.md +40 -0
- data/context/recovering-releases.md +27 -0
- data/context/verifying-releases.md +43 -0
- data/lib/bake/gem/github/backup.rb +51 -0
- data/lib/bake/gem/github/project.rb +205 -22
- data/lib/bake/gem/github/publisher.rb +240 -120
- data/lib/bake/gem/github/registry.rb +104 -0
- data/lib/bake/gem/github/setup.rb +96 -13
- data/lib/bake/gem/github/version.rb +1 -1
- data/readme.md +84 -6
- data/releases.md +7 -0
- data/templates/release-prepare.yaml.erb +6 -1
- data.tar.gz.sig +0 -0
- metadata +11 -4
- metadata.gz.sig +0 -0
- data/agents.md +0 -437
- data/templates/releasing.md +0 -17
|
@@ -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
|
-
#
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
322
|
+
|
|
323
|
+
return release
|
|
172
324
|
end
|
|
173
325
|
|
|
174
|
-
def
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|