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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0643ea73beb64b3061e5ab768d1426e9220ec384ae07cb71c7e31239b671adf9
4
- data.tar.gz: 05b9f8624c05fc27ec1197bd1097df44ec3ab2e0b802c9fbc94204e4bc73b8cf
3
+ metadata.gz: eb1ff7b71712625bd0f0732c6a945b72d555d7ccf0f318314500f61b626269aa
4
+ data.tar.gz: 627281b137b7161bd0f57d208f73cf8fa9e559cee46a63be7545c64e6a3ab24b
5
5
  SHA512:
6
- metadata.gz: 1467cb16603f373a45d019f62010d0e49165a072a9cdf00cd64831404008a980343e388fd7b20d04cbe92d323ebdefdd3795ac63662e84d2b2569921285e76d8
7
- data.tar.gz: 3114b8df86c511ee0a471f5482adf98bdc2769689ef6c94dcc6cf4611542935537217880bdea9f6adc73a7b65ef63f7c2682cac30ec2f9a7bd4d5a92029a200e
6
+ metadata.gz: 7e9e4d0798361689b919529a75415764c3d1d575737b5368a0bee445d406e399e8eba72a6eeee735bd01581912da8cb53496a48deff91d311b275cfe995590fa
7
+ data.tar.gz: ad0ade7c470f26b865b4dce5f7cee96af8f6042daf0ff35a5363a7393098ea414ee03233cfc92a70d1b622a7d83c57188e01aee3f6953d34af8b1fe903f5111d
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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. If failure occurs before artifact retention, rebuilding is safe because uploading has not started.
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
- Artifacts are retained for 90 days and copied to GitHub release assets on success. If the workflow artifact expires before finalization, manual recovery from the preserved bytes is required; do not dispatch a replacement build. 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.
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
- if retained = artifacts.find{|entry| entry.fetch("name") == artifact}
27
- raise "Retained artifact expired; restore it from the GitHub release before retrying." if retained.fetch("expired")
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
- receipt = load_receipt
30
- [:name, :version, :commit, :repository, :pull_request].each do |key|
31
- raise "Retained artifact has different #{key}." unless receipt[key] == evidence[key]
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
- return output(receipt, restored: true)
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
- filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
36
- if registry_digest(evidence.fetch(:name), evidence.fetch(:version))
37
- raise "Version is already published but this run has no retained artifact. Restore the original artifact; do not rebuild."
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
- package = build_package(path)
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
- else
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
- # A failed read after upload is recoverable by rerunning the same workflow.
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
- releases = JSON.parse(readlines("gh", "release", "list", "--repo", @repository, "--limit", "1000", "--json", "tagName", chdir: @root).join)
83
- unless releases.any?{|entry| entry.fetch("tagName") == tag}
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
 
@@ -9,7 +9,7 @@ module Bake
9
9
  module Gem
10
10
  # GitHub pull request and release orchestration for bake-gem.
11
11
  module GitHub
12
- VERSION = "0.0.3"
12
+ VERSION = "0.0.5"
13
13
  end
14
14
  end
15
15
  end
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.
@@ -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
- 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 downloaded before another upload is attempted. Do not 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.
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������-j��7FN�+��Mz~�ty#@�I~�V��|��ȏF�4��|w-p�H8_���a����-D`ٿ\T۠�9 :����`ɂo�ՠ�0C���Kj����+����;2�e���j}����)}��E��70���%�*
2
- Q� "�B��:z��{���# �J�>>�l��T�p_I/P�q��7z�����N�D<�-ﳢ\�����8o$�un�^|�(���t��_����g/��71i!��h�X���w2W�q�?}r�v ��Ѥ��̰c�L�2H6�H�Nfgx��b��9�՟�>�T�ڷb�=�T��m ��ݔe`N!���O�;�F���9j���ހ�k2�@87~9nHp�-M|�ӏ
1
+ �ѓ@�I6����Es~��� r?ŵ�����`�~�.NH�����PaZ��1����u*��n��U�zh5��U@3;F~83��e7߾~hC�ƴW���OAm盓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�uU뮜�:��X/�Nq�ȕ�~�b
3
+ ƹBҵ Ӟ�$���
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bake-gem-github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
metadata.gz.sig CHANGED
@@ -1,2 +1,2 @@
1
- kޘdYOEЂ�tF�6`�Mv���uEz,���tL�� {�`�}��o�-b U�$�E0*�4�r%vd��S�x�r���vl������ �V��pai��>_ӝ�
2
- �m�*�
1
+ ���%ܗ��!8����D������D]L?
2
+ V�t4?�ܮF��ԇƂQ��Q#��5�KOqRk3z��tޕ���?I�