Skip to content

Commit dfd76ff

Browse files
Preserve a complete archive before individual release asset uploads
1 parent f25b0a4 commit dfd76ff

8 files changed

Lines changed: 204 additions & 15 deletions

File tree

‎context/getting-started.md‎

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

123123
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.
124124

125-
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.
125+
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.
126+
127+
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.
126128

127129
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.
128130

‎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
@@ -122,7 +122,9 @@ bundle exec bake gem:github:release:resume run=RUN_ID
122122

123123
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.
124124

125-
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.
125+
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.
126+
127+
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.
126128

127129
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.
128130

‎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 "bake/releases"
89
require "digest"
910
require "net/http"
@@ -35,9 +36,20 @@ def build(number)
3536
guard_release(release, evidence.fetch(:commit))
3637
filename = "#{evidence.fetch(:name)}-#{evidence.fetch(:version)}.gem"
3738
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)
39+
if backup = release.fetch("assets").find{|asset| asset.fetch("name") == "release.tar"}
40+
contents = read_backup(release, backup, files)
41+
contents.each do |name, content|
42+
file = File.join(path, name)
43+
raise "Existing artifact differs: #{name}" if File.exist?(file) && File.binread(file) != content
44+
end
45+
contents.each do |name, content|
46+
File.binwrite(File.join(path, name), content)
47+
end
48+
else
49+
names = release.fetch("assets").map{|asset| asset.fetch("name")}
50+
raise "Retained release is incomplete; restore the original files before retrying." unless files.all?{|file| names.include?(File.basename(file))}
51+
system("gh", "release", "download", release.fetch("tag_name"), "--repo", @repository, "--dir", path, *files.flat_map{|file| ["--pattern", File.basename(file)]}, chdir: @root)
52+
end
4153
elsif retained
4254
raise "Retained artifact expired and no GitHub release is available. Restore the original files before retrying."
4355
else
@@ -161,16 +173,37 @@ def preserve_release(receipt)
161173
end
162174
guard_release(release, receipt.fetch(:commit))
163175
assets = release.fetch("assets")
164-
release_files(receipt).each do |file|
176+
files = release_files(receipt)
177+
files.each do |file|
165178
if existing = assets.find{|asset| asset.fetch("name") == File.basename(file)}
166179
raise "Existing release asset differs: #{file}" unless existing.fetch("digest") == "sha256:#{Digest::SHA256.file(file).hexdigest}"
167-
else
180+
end
181+
end
182+
if backup = assets.find{|asset| asset.fetch("name") == "release.tar"}
183+
contents = read_backup(release, backup, files)
184+
raise "Existing release backup differs." unless files.all?{|file| contents.fetch(File.basename(file)) == File.binread(file)}
185+
else
186+
# A complete backup needs only one successful upload, before individual assets:
187+
backup = File.join(@root, "pkg/release.tar")
188+
Backup.write(backup, files)
189+
system("gh", "release", "upload", tag, backup, "--repo", @repository, chdir: @root)
190+
end
191+
files.each do |file|
192+
unless assets.any?{|asset| asset.fetch("name") == File.basename(file)}
168193
system("gh", "release", "upload", tag, file, "--repo", @repository, chdir: @root)
169194
end
170195
end
171196
release
172197
end
173198

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

‎releases.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Unreleased
44

55
- Stop generating `.github/releasing.md`; release instructions are maintained in the shared guide and agent context.
6+
- Resume interrupted release preparation and explicitly refresh stale release PRs while preserving their previous commits.
7+
- Preserve all release files in one archive before individual asset uploads, so reruns can recover interrupted drafts.
68

79
## v0.2.0
810

@@ -12,7 +14,6 @@
1214

1315
- Include the version's release notes in GitHub releases using `bake-releases`.
1416
- Update generated release files in the working tree with `gem:github:setup:update`.
15-
- Resume interrupted release preparation and explicitly refresh stale release PRs while preserving their previous commits.
1617

1718
## v0.0.5
1819

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "bake/gem/github/backup"
7+
require "sus/fixtures/temporary_directory_context"
8+
9+
describe Bake::Gem::GitHub::Backup do
10+
include Sus::Fixtures::TemporaryDirectoryContext
11+
12+
let(:path) {File.join(root, "release.tar")}
13+
14+
it "preserves binary package bytes and the receipt together" do
15+
files = {"example.gem" => "\x00\xffpackage".b, "release.json" => "{}"}
16+
files.each{|name, content| File.binwrite(File.join(root, name), content)}
17+
subject.write(path, files.keys.map{|name| File.join(root, name)})
18+
expect(subject.read(path, files.keys)).to be == files
19+
end
20+
21+
it "rejects an incomplete backup" do
22+
File.write(File.join(root, "example.gem"), "package")
23+
subject.write(path, [File.join(root, "example.gem")])
24+
expect{subject.read(path, ["example.gem", "release.json"])}.to raise_exception(RuntimeError, message: be =~ /incomplete/)
25+
end
26+
27+
[["../outside"], ["/absolute"], ["example.gem", "example.gem"]].each do |entries|
28+
it "rejects unexpected or duplicate filenames", unique: entries do
29+
File.open(path, "wb") do |file|
30+
Gem::Package::TarWriter.new(file) do |archive|
31+
entries.each{|name| archive.add_file(name, 0644){|entry| entry.write("bytes")}}
32+
end
33+
end
34+
expect{subject.read(path, ["example.gem"])}.to raise_exception(RuntimeError, message: be =~ /Unexpected release backup entry/)
35+
end
36+
end
37+
38+
it "rejects links in place of release files" do
39+
File.open(path, "wb") do |file|
40+
Gem::Package::TarWriter.new(file){|archive| archive.add_symlink("example.gem", "../outside", 0644)}
41+
end
42+
expect{subject.read(path, ["example.gem"])}.to raise_exception(RuntimeError, message: be =~ /Unexpected release backup entry/)
43+
end
44+
end

0 commit comments

Comments
 (0)