bake-gem-github 0.0.3 → 0.0.5
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/context/getting-started.md +4 -2
- data/lib/bake/gem/github/publisher.rb +110 -40
- data/lib/bake/gem/github/version.rb +1 -1
- data/releases.md +9 -0
- data/templates/releasing.md +3 -1
- data.tar.gz.sig +3 -2
- metadata +1 -1
- metadata.gz.sig +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb1ff7b71712625bd0f0732c6a945b72d555d7ccf0f318314500f61b626269aa
|
|
4
|
+
data.tar.gz: 627281b137b7161bd0f57d208f73cf8fa9e559cee46a63be7545c64e6a3ab24b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e9e4d0798361689b919529a75415764c3d1d575737b5368a0bee445d406e399e8eba72a6eeee735bd01581912da8cb53496a48deff91d311b275cfe995590fa
|
|
7
|
+
data.tar.gz: ad0ade7c470f26b865b4dce5f7cee96af8f6042daf0ff35a5363a7393098ea414ee03233cfc92a70d1b622a7d83c57188e01aee3f6953d34af8b1fe903f5111d
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/context/getting-started.md
CHANGED
|
@@ -95,9 +95,11 @@ Use **Re-run all jobs** on the original publishing run, or:
|
|
|
95
95
|
bundle exec bake gem:github:release:resume run=RUN_ID
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
Rerunning keeps the original event identity. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version.
|
|
98
|
+
Rerunning keeps the original event identity. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version. Registry propagation is retried every ten seconds for up to one minute; a digest or attestation mismatch fails immediately.
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
Before uploading to RubyGems, the publisher stores the verified gem, receipt and both attestation bundles in a draft GitHub release targeting the merged commit. It publishes the draft after registry verification and tag creation. Actions artifacts are also retained for 90 days, but can disappear on rerun. Recovery falls back to the draft or published release and verifies the original bytes and attestations. Keep the draft until finalization succeeds. If asset preservation was interrupted and neither backup is complete, restore the missing original files before retrying; conflicting assets are never overwritten.
|
|
101
|
+
|
|
102
|
+
GitHub concurrency does not guarantee a durable FIFO queue: rerun any publishing run displaced while pending. Resume reruns all jobs, including integrity checks; it does not repeat or second-guess the native review policy or a permitted administrator bypass. Older publishing runs execute their original code; adding this recovery support to the default branch does not change an already-triggered workflow.
|
|
101
103
|
|
|
102
104
|
## Development and current limits
|
|
103
105
|
|
|
@@ -13,6 +13,10 @@ module Bake
|
|
|
13
13
|
module GitHub
|
|
14
14
|
# Builds one artifact, preserves it before upload, and resumes without moving existing tags.
|
|
15
15
|
class Publisher < Project
|
|
16
|
+
# A registered package is not yet available from the download service.
|
|
17
|
+
class RegistryPending < RuntimeError
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
# Build or restore this workflow run's artifact, after validating the actual merged commit.
|
|
17
21
|
def build(number)
|
|
18
22
|
guard_environment
|
|
@@ -23,24 +27,34 @@ module Bake
|
|
|
23
27
|
artifact = "release-#{evidence.fetch(:commit)}"
|
|
24
28
|
run = ENV.fetch("GITHUB_RUN_ID")
|
|
25
29
|
artifacts = api("actions/runs/#{run}/artifacts?per_page=100").fetch("artifacts")
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
retained = artifacts.find{|entry| entry.fetch("name") == artifact}
|
|
31
|
+
if retained && !retained.fetch("expired")
|
|
28
32
|
system("gh", "run", "download", run, "--repo", @repository, "--name", artifact, "--dir", path, chdir: @root)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
elsif release = github_release("v#{evidence.fetch(:version)}")
|
|
34
|
+
guard_release(release, evidence.fetch(:commit))
|
|
35
|
+
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
|
|
36
|
+
files = release_files(file: filename)
|
|
37
|
+
names = release.fetch("assets").map{|asset| asset.fetch("name")}
|
|
38
|
+
raise "Retained release is incomplete; restore the original files before retrying." unless files.all?{|file| names.include?(File.basename(file))}
|
|
39
|
+
system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root)
|
|
40
|
+
elsif retained
|
|
41
|
+
raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying."
|
|
42
|
+
else
|
|
43
|
+
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
|
|
44
|
+
if registry_digest(evidence.fetch(:name), evidence.fetch(:version))
|
|
45
|
+
raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild."
|
|
32
46
|
end
|
|
33
|
-
|
|
47
|
+
package = build_package(path)
|
|
48
|
+
raise "Unexpected package filename." unless File.basename(package) == filename
|
|
49
|
+
receipt = evidence.merge(file: filename, sha256: Digest::SHA256.file(package).hexdigest, run_id: run, signing: @config.fetch("signing"))
|
|
50
|
+
File.write(File.join(path, "release.json"), JSON.pretty_generate(receipt) + "\n")
|
|
51
|
+
return output(receipt, restored: false)
|
|
34
52
|
end
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
raise "
|
|
53
|
+
receipt = load_receipt
|
|
54
|
+
[:name, :version, :commit, :repository, :pull_request].each do |key|
|
|
55
|
+
raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key]
|
|
38
56
|
end
|
|
39
|
-
|
|
40
|
-
raise "Unexpected package filename." unless File.basename(package) == filename
|
|
41
|
-
receipt = evidence.merge(file: filename, sha256: Digest::SHA256.file(package).hexdigest, run_id: run, signing: @config.fetch("signing"))
|
|
42
|
-
File.write(File.join(path, "release.json"), JSON.pretty_generate(receipt) + "\n")
|
|
43
|
-
output(receipt, restored: false)
|
|
57
|
+
output(receipt, restored: true)
|
|
44
58
|
end
|
|
45
59
|
|
|
46
60
|
# Verify both attestations, upload exactly those bytes, then create only the intended tag and release.
|
|
@@ -65,37 +79,19 @@ module Bake
|
|
|
65
79
|
remote_digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version))
|
|
66
80
|
if remote_digest
|
|
67
81
|
raise "Published version has different bytes." unless remote_digest == receipt.fetch(:sha256)
|
|
68
|
-
|
|
82
|
+
end
|
|
83
|
+
# Keep verified bytes independently of workflow attempts before uploading:
|
|
84
|
+
release = preserve_release(receipt)
|
|
85
|
+
unless remote_digest
|
|
69
86
|
gem_command("push", package, "--host", "https://rubygems.org", "--attestation", bundle)
|
|
70
87
|
end
|
|
71
|
-
|
|
72
|
-
raise "Registry artifact does not match; retry after registry propagation." unless registry_digest(receipt.fetch(:name), receipt.fetch(:version)) == receipt.fetch(:sha256)
|
|
73
|
-
attestations = registry_get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json")
|
|
74
|
-
raise "Registry attestation is missing." unless attestations
|
|
75
|
-
registry_bundles = JSON.parse(attestations)
|
|
76
|
-
local_bundle = JSON.parse(File.read(bundle))
|
|
77
|
-
raise "Registry does not contain this artifact's Sigstore bundle." unless contains_bundle?(registry_bundles, local_bundle)
|
|
88
|
+
verify_registry(receipt, bundle)
|
|
78
89
|
unless readlines("git", "tag", "--list", tag, chdir: @root).any?
|
|
79
90
|
system("git", "tag", tag, receipt.fetch(:commit), chdir: @root)
|
|
80
91
|
end
|
|
81
92
|
system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", "refs/tags/#{tag}", chdir: @root)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Tempfile.create("release-notes") do |file|
|
|
85
|
-
file.write("#{receipt.fetch(:pull_request_url)}\n\nSource: #{receipt.fetch(:commit)}\nSHA256: #{receipt.fetch(:sha256)}\n\nSee releases.md at the release tag for release notes.\n")
|
|
86
|
-
file.flush
|
|
87
|
-
system("gh", "release", "create", tag, "--repo", @repository, "--verify-tag", "--title", "#{receipt.fetch(:name)} #{tag}", "--notes-file", file.path, chdir: @root)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
# Existing immutable assets are checked before upload; never clobber them.
|
|
91
|
-
assets = api("releases/tags/#{tag}").fetch("assets")
|
|
92
|
-
[package, bundle, File.join(@root, "pkg", "release.json"), File.join(@root, "pkg", "provenance.sigstore.json")].each do |file|
|
|
93
|
-
if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
|
|
94
|
-
digest = existing.fetch("digest")
|
|
95
|
-
raise "Existing release asset differs: #{file}" unless digest == "sha256:#{Digest::SHA256.file(file).hexdigest}"
|
|
96
|
-
else
|
|
97
|
-
system("gh", "release", "upload", tag, file, "--repo", @repository, chdir: @root)
|
|
98
|
-
end
|
|
93
|
+
if release.fetch("draft")
|
|
94
|
+
system("gh", "release", "edit", tag, "--repo", @repository, "--draft=false", "--verify-tag", chdir: @root)
|
|
99
95
|
end
|
|
100
96
|
receipt
|
|
101
97
|
end
|
|
@@ -120,6 +116,80 @@ module Bake
|
|
|
120
116
|
|
|
121
117
|
private
|
|
122
118
|
|
|
119
|
+
def github_release(tag)
|
|
120
|
+
pages = JSON.parse(readlines("gh", "api", "--paginate", "--slurp", "repos/#{@repository}/releases?per_page=100", chdir: @root).join)
|
|
121
|
+
matches = pages.flatten(1).select{|release| release.fetch("tag_name") == tag}
|
|
122
|
+
raise "Multiple GitHub releases have the same tag." if matches.size > 1
|
|
123
|
+
if release = matches.first
|
|
124
|
+
return api("releases/#{release.fetch('id')}")
|
|
125
|
+
end
|
|
126
|
+
# Resolve pending tags directly when the REST list has not caught up:
|
|
127
|
+
owner, name = @repository.split("/", 2)
|
|
128
|
+
query = "query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { databaseId } } }"
|
|
129
|
+
result = JSON.parse(readlines("gh", "api", "graphql", "-f", "query=#{query}", "-f", "owner=#{owner}", "-f", "name=#{name}", "-f", "tag=#{tag}", chdir: @root).join)
|
|
130
|
+
if release = result.fetch("data").fetch("repository").fetch("release")
|
|
131
|
+
api("releases/#{release.fetch('databaseId')}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def guard_release(release, commit)
|
|
136
|
+
if release.fetch("draft") && release.fetch("target_commitish") != commit
|
|
137
|
+
raise "Draft release targets another commit."
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def release_files(receipt)
|
|
142
|
+
[receipt.fetch(:file), "#{receipt.fetch(:file)}.sigstore.json", "release.json", "provenance.sigstore.json"].map{|name| File.join(@root, "pkg", name)}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def preserve_release(receipt)
|
|
146
|
+
tag = "v#{receipt.fetch(:version)}"
|
|
147
|
+
release = github_release(tag)
|
|
148
|
+
unless release
|
|
149
|
+
Tempfile.create("release") do |file|
|
|
150
|
+
file.write(JSON.generate(
|
|
151
|
+
tag_name: tag, draft: true, target_commitish: receipt.fetch(:commit), name: "#{receipt.fetch(:name)} #{tag}",
|
|
152
|
+
body: "#{receipt.fetch(:pull_request_url)}\n\nSource: #{receipt.fetch(:commit)}\nSHA256: #{receipt.fetch(:sha256)}\n\nSee releases.md at the release tag for release notes.\n"
|
|
153
|
+
))
|
|
154
|
+
file.flush
|
|
155
|
+
# The release list can remain stale after a successful creation:
|
|
156
|
+
release = JSON.parse(readlines("gh", "api", "repos/#{@repository}/releases", "--method", "POST", "--input", file.path, chdir: @root).join)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
guard_release(release, receipt.fetch(:commit))
|
|
160
|
+
assets = release.fetch("assets")
|
|
161
|
+
release_files(receipt).each do |file|
|
|
162
|
+
if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
|
|
163
|
+
raise "Existing release asset differs: #{file}" unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}"
|
|
164
|
+
else
|
|
165
|
+
system("gh", "release", "upload", tag, file, "--repo", @repository, chdir: @root)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
release
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def verify_registry(receipt, bundle, attempts: 7, delay: 10)
|
|
172
|
+
local_bundle = JSON.parse(File.read(bundle))
|
|
173
|
+
attempts.times do |attempt|
|
|
174
|
+
begin
|
|
175
|
+
if digest = registry_digest(receipt.fetch(:name), receipt.fetch(:version))
|
|
176
|
+
raise "Published version has different bytes." unless digest == receipt.fetch(:sha256)
|
|
177
|
+
if attestations = registry_get("https://rubygems.org/api/v1/attestations/#{receipt.fetch(:name)}-#{receipt.fetch(:version)}.json")
|
|
178
|
+
raise "Registry does not contain this artifact's Sigstore bundle." unless contains_bundle?(JSON.parse(attestations), local_bundle)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
rescue RegistryPending
|
|
183
|
+
# The version API can become visible before the gem download:
|
|
184
|
+
end
|
|
185
|
+
if attempt < attempts - 1
|
|
186
|
+
Console.info(self, "Waiting for RubyGems to serve the release.")
|
|
187
|
+
sleep(delay)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
raise "Registry propagation did not complete; rerun this workflow to resume from the retained release."
|
|
191
|
+
end
|
|
192
|
+
|
|
123
193
|
def gem_command(*arguments)
|
|
124
194
|
if defined?(::Bundler)
|
|
125
195
|
::Bundler.with_unbundled_env{system("gem", *arguments, chdir: @root)}
|
|
@@ -193,7 +263,7 @@ module Bake
|
|
|
193
263
|
# Missing downloads can return 403; use the version API to establish absence.
|
|
194
264
|
return nil unless registry_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json?platform=ruby")
|
|
195
265
|
body = registry_get("https://rubygems.org/downloads/#{name}-#{version}.gem")
|
|
196
|
-
raise "Published gem download is missing; retry after registry propagation." unless body
|
|
266
|
+
raise RegistryPending, "Published gem download is missing; retry after registry propagation." unless body
|
|
197
267
|
Digest::SHA256.hexdigest(body)
|
|
198
268
|
end
|
|
199
269
|
|
data/releases.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Releases
|
|
2
2
|
|
|
3
|
+
## v0.0.5
|
|
4
|
+
|
|
5
|
+
- Preserve and recover release files even when GitHub's release list is stale.
|
|
6
|
+
|
|
7
|
+
## v0.0.4
|
|
8
|
+
|
|
9
|
+
- Preserve verified release files in a draft GitHub release before uploading to RubyGems, so reruns can recover when Actions artifacts disappear.
|
|
10
|
+
- Wait for RubyGems registry propagation before finalizing releases.
|
|
11
|
+
|
|
3
12
|
## v0.0.1
|
|
4
13
|
|
|
5
14
|
- Initial implementation.
|
data/templates/releasing.md
CHANGED
|
@@ -6,7 +6,9 @@ Review the entire release diff. Native GitHub rules require the configured revie
|
|
|
6
6
|
|
|
7
7
|
Merging a version increase publishes the actual merged commit through `release-publish.yaml`. There is no second release approval. The public `release.cert` stays in Git; the optional private key belongs in the Actions secret `GEM_SIGNING_KEY`, supplied by the `rubygems` environment or an organization secret. RubyGems authentication uses Trusted Publishing.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Before uploading to RubyGems, the publisher preserves the verified gem, receipt and both attestation bundles as assets of a draft GitHub release. The draft becomes public after registry verification and tag creation. Registry propagation is retried for up to one minute.
|
|
10
|
+
|
|
11
|
+
If a publishing job fails, use **Re-run all jobs** on that same workflow run, or `bundle exec bake gem:github:release:resume run=RUN_ID`. The original artifact is restored from the workflow artifact or GitHub release before another upload is attempted. Actions artifacts can disappear on rerun, so the draft release is the durable backup. Do not delete it, create a replacement version bump or rebuild an already-published version. Conflicting bytes or tags require investigation; never automatically yank a version or move a tag. An incomplete draft requires restoring the missing original files before retrying.
|
|
10
12
|
|
|
11
13
|
Verify both the downloaded gem and `release.json` with `gh attestation verify`, enforcing the publishing workflow's certificate identity and default branch ref. Then check the signed receipt's source commit and gem digest; follow the companion gem's setup guide for the complete commands. `--source-digest` checks the workflow revision, which can differ from the release commit. Release assets include the gem, RubyGems Sigstore bundle, GitHub provenance bundle, and signed source/digest receipt.
|
|
12
14
|
|
data.tar.gz.sig
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
W
|
|
2
|
-
Q�
|
|
1
|
+
�ѓ@�I6����Es~���r?ŵ�����`�~�.NH�����PaZ��1����u*��n��U�zh5��U@3;F~83��e7߾~hC�ƴW���OA�m盓7K؎���D�e�l�|c�����������(%# �;�.W>Ob�Y�������x$�*��tS�q��M�|�HAtPz�1K�"�4����g�j���Ѵ
|
|
2
|
+
vV�`�]�Q�'��l;�x��g����J�� ���'-0��jH4��sEҏT�u�U뮜�:��X/�Nq�ȕ�~�b
|
|
3
|
+
ƹBҵ Ӟ�$���
|
metadata
CHANGED
metadata.gz.sig
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
���%ܗ��!8����D������D]L?
|
|
2
|
+
V�t4?�ܮF��ԇƂQ��Q#��5�KOqRk3z��tޕ���?I�
|