Skip to content

Commit 835ae8b

Browse files
Preserve a complete archive before individual release asset uploads
1 parent 52a98e1 commit 835ae8b

10 files changed

Lines changed: 207 additions & 18 deletions

File tree

‎.github/releasing.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ Review the entire release diff. Native GitHub rules require the configured revie
66

77
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.
88

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.
9+
Before uploading to RubyGems, the publisher uploads `release.tar` containing the verified gem, receipt and both attestation bundles to a draft GitHub release, then uploads their individual assets. The draft becomes public after registry verification and tag creation. Registry propagation is retried for up to one minute.
1010

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.
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. A complete `release.tar` lets a rerun restore the original files and finish interrupted individual uploads. If neither the archive nor the Actions artifact completed, restore the missing original files before retrying. Existing backups and assets are checked and conflicting content is never overwritten.
1212

1313
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.
1414

‎context/getting-started.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ bundle exec bake gem:github:release:resume run=RUN_ID
106106

107107
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.
108108

109-
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.
109+
Before uploading to RubyGems, the publisher stores the verified gem, receipt and both attestation bundles together in `release.tar`, uploaded as one draft-release asset before their individual assets. The draft targets 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 `release.tar` in the draft or published release, checking its digest and requiring exactly the four expected regular files before restoring them. It verifies the original bytes and attestations, then resumes any missing individual asset uploads. Older releases without an archive can still restore their four individual assets. Existing assets and backups are compared with the original files and never replaced with conflicting content.
110+
111+
A rerun can recover an interrupted individual asset upload once `release.tar` is available, even if the Actions artifact has disappeared. An available Actions artifact can also resume an interrupted archive upload. If neither backup completed, restore the missing original files manually; the publisher stops before uploading to RubyGems. Keep the draft until finalization succeeds. A published version is never rebuilt to fill a missing backup.
110112

111113
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.
112114

‎fixtures/bake/gem/github/recovery_publisher.rb‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module GitHub
1212
# real-repository integration tests; this fixture exercises interruption/retry.
1313
class RecoveryPublisher < Publisher
1414
attr_accessor :remote_digest, :fail_release, :fail_attestation, :fail_receipt_verification
15-
attr_accessor :releases, :artifacts, :fail_preservation, :stale_release_list
15+
attr_accessor :releases, :artifacts, :fail_preservation, :stale_release_list, :fail_preservation_after
1616
attr_reader :commands, :stored_files
1717

1818
def initialize(root)
@@ -45,13 +45,17 @@ def system(*arguments, **options)
4545
end
4646
case arguments[0, 3]
4747
when ["gh", "release", "upload"]
48-
raise "Preservation failed" if @fail_preservation
48+
raise "Preservation failed" if @fail_preservation || (@fail_preservation_after && @stored_files.size >= @fail_preservation_after)
4949
file = arguments[4]
5050
@stored_files[File.basename(file)] = File.binread(file)
5151
@releases.first.fetch("assets") << {"name" => File.basename(file), "digest" => "sha256:#{Digest::SHA256.file(file).hexdigest}"}
5252
when ["gh", "release", "download"], ["gh", "run", "download"]
53-
path = arguments[arguments.index("--dir") + 1]
54-
@stored_files.each{|name, content| File.binwrite(File.join(path, name), content)}
53+
if index = arguments.index("--output")
54+
File.binwrite(arguments[index + 1], @stored_files.fetch("release.tar"))
55+
else
56+
path = arguments[arguments.index("--dir") + 1]
57+
@stored_files.each{|name, content| File.binwrite(File.join(path, name), content)}
58+
end
5559
when ["gh", "release", "edit"]
5660
raise "GitHub unavailable after upload" if @fail_release
5761
@releases.first["draft"] = false

‎guides/getting-started/readme.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ bundle exec bake gem:github:release:resume run=RUN_ID
106106

107107
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.
108108

109-
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.
109+
Before uploading to RubyGems, the publisher stores the verified gem, receipt and both attestation bundles together in `release.tar`, uploaded as one draft-release asset before their individual assets. The draft targets 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 `release.tar` in the draft or published release, checking its digest and requiring exactly the four expected regular files before restoring them. It verifies the original bytes and attestations, then resumes any missing individual asset uploads. Older releases without an archive can still restore their four individual assets. Existing assets and backups are compared with the original files and never replaced with conflicting content.
110+
111+
A rerun can recover an interrupted individual asset upload once `release.tar` is available, even if the Actions artifact has disappeared. An available Actions artifact can also resume an interrupted archive upload. If neither backup completed, restore the missing original files manually; the publisher stops before uploading to RubyGems. Keep the draft until finalization succeeds. A published version is never rebuilt to fill a missing backup.
110112

111113
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.
112114

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "rubygems/package"
7+
8+
module Bake
9+
module Gem
10+
module GitHub
11+
# Stores the original release files together so a single completed upload can recover them.
12+
module Backup
13+
# Write the release files to a tar archive.
14+
def self.write(path, files)
15+
File.open(path, "wb") do |output|
16+
::Gem::Package::TarWriter.new(output) do |archive|
17+
files.each do |file|
18+
archive.add_file(File.basename(file), 0644){|entry| entry.write(File.binread(file))}
19+
end
20+
end
21+
end
22+
end
23+
24+
# Read only the expected regular files; reject missing, duplicate, or unexpected entries before extraction.
25+
def self.read(path, names)
26+
files = {}
27+
File.open(path, "rb") do |input|
28+
::Gem::Package::TarReader.new(input) do |archive|
29+
archive.each do |entry|
30+
name = entry.full_name
31+
raise "Unexpected release backup entry: #{name}" unless entry.file? && names.include?(name) && !files.key?(name)
32+
files[name] = entry.read
33+
end
34+
end
35+
end
36+
raise "Release backup is incomplete." unless files.keys.sort == names.sort
37+
files
38+
end
39+
end
40+
end
41+
end
42+
end

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Copyright, 2026, by Samuel Williams.
55

66
require_relative "project"
7+
require_relative "backup"
78
require "digest"
89
require "net/http"
910
require "openssl"
@@ -34,9 +35,20 @@ def build(number)
3435
guard_release(release, evidence.fetch(:commit))
3536
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
3637
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)
38+
if backup = release.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}
39+
contents = read_backup(release, backup, files)
40+
contents.each do |name, content|
41+
file = File.join(path, name)
42+
raise "Existing artifact differs: #{name}" if File.exist?(file) && File.binread(file) != content
43+
end
44+
contents.each do |name, content|
45+
File.binwrite(File.join(path, name), content)
46+
end
47+
else
48+
names = release.fetch("assets").map{|asset| asset.fetch("name")}
49+
raise "Retained release is incomplete; restore the original files before retrying." unless files.all?{|file| names.include?(File.basename(file))}
50+
system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root)
51+
end
4052
elsif retained
4153
raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying."
4254
else
@@ -158,16 +170,37 @@ def preserve_release(receipt)
158170
end
159171
guard_release(release, receipt.fetch(:commit))
160172
assets = release.fetch("assets")
161-
release_files(receipt).each do |file|
173+
files = release_files(receipt)
174+
files.each do |file|
162175
if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
163176
raise "Existing release asset differs: #{file}" unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}"
164-
else
177+
end
178+
end
179+
if backup = assets.find{|asset| asset.fetch("name") == "release.tar"}
180+
contents = read_backup(release, backup, files)
181+
raise "Existing release backup differs." unless files.all?{|file| contents.fetch(File.basename(file)) == File.binread(file)}
182+
else
183+
# A complete backup needs only one successful upload, before individual assets:
184+
backup = File.join(@root, "pkg/release.tar")
185+
Backup.write(backup, files)
186+
system("gh", "release", "upload", tag, backup, "--repo", @repository, chdir: @root)
187+
end
188+
files.each do |file|
189+
unless assets.any?{|asset| asset.fetch("name") == File.basename(file)}
165190
system("gh", "release", "upload", tag, file, "--repo", @repository, chdir: @root)
166191
end
167192
end
168193
release
169194
end
170195

196+
def read_backup(release, asset, files)
197+
Tempfile.create("release-backup") do |file|
198+
system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--pattern", "release.tar", "--output", file.path, "--clobber", chdir: @root)
199+
raise "Release backup digest mismatch." unless asset.fetch("digest") == "sha256:#{Digest::SHA256.file(file.path).hexdigest}"
200+
Backup.read(file.path, files.map{|path| File.basename(path)})
201+
end
202+
end
203+
171204
def verify_registry(receipt, bundle, attempts: 7, delay: 10)
172205
local_bundle = JSON.parse(File.read(bundle))
173206
attempts.times do |attempt|

‎releases.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Update generated release files in the working tree with `gem:github:setup:update`.
6+
- Preserve all release files in one archive before individual asset uploads, so reruns can recover interrupted drafts.
67

78
## v0.0.5
89

‎templates/releasing.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ Review the entire release diff. Native GitHub rules require the configured revie
66

77
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.
88

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.
9+
Before uploading to RubyGems, the publisher uploads `release.tar` containing the verified gem, receipt and both attestation bundles to a draft GitHub release, then uploads their individual assets. The draft becomes public after registry verification and tag creation. Registry propagation is retried for up to one minute.
1010

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.
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. A complete `release.tar` lets a rerun restore the original files and finish interrupted individual uploads. If neither the archive nor the Actions artifact completed, restore the missing original files before retrying. Existing backups and assets are checked and conflicting content is never overwritten.
1212

1313
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.
1414

0 commit comments

Comments
 (0)