Skip to content

Commit 023a1e9

Browse files
Preserve release artifacts across workflow retries. (#8)
1 parent f030611 commit 023a1e9

9 files changed

Lines changed: 383 additions & 113 deletions

File tree

‎.github/releasing.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +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-
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.
1012

1113
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.
1214

‎context/getting-started.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ Use **Re-run all jobs** on the original publishing run, or:
9595
bundle exec bake gem:github:release:resume run=RUN_ID
9696
```
9797

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.
9999

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.
101103

102104
## Development and current limits
103105

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "bake/gem/github/publisher"
7+
8+
module Bake
9+
module Gem
10+
module GitHub
11+
# A simulated registry and GitHub finalizer. Core content validation has its own
12+
# real-repository integration tests; this fixture exercises interruption/retry.
13+
class RecoveryPublisher < Publisher
14+
attr_accessor :remote_digest, :fail_release, :fail_attestation, :fail_receipt_verification
15+
attr_accessor :releases, :artifacts, :fail_preservation
16+
attr_reader :commands, :stored_files
17+
18+
def initialize(root)
19+
super
20+
@commands = []
21+
@releases = []
22+
@artifacts = []
23+
@stored_files = {}
24+
@release = Object.new
25+
def @release.resolve(reference)
26+
"a" * 40
27+
end
28+
def @release.validate(**options)
29+
{name: "example", version: "1.0.1", commit: "a" * 40}
30+
end
31+
end
32+
33+
def merged(number)
34+
{"merge_commit_sha" => "a" * 40, "number" => 42, "html_url" => "https://github.com/socketry/example/pull/42"}
35+
end
36+
37+
def system(*arguments, **options)
38+
@commands << arguments
39+
raise "Attestation verification failed" if @fail_attestation && arguments[0, 3] == ["gem", "exec", "sigstore-cli:0.2.3"]
40+
if @fail_receipt_verification && arguments[0, 3] == ["gh", "attestation", "verify"] && arguments[3].end_with?("/release.json")
41+
raise "Receipt attestation verification failed"
42+
end
43+
if arguments[0, 2] == ["gem", "push"]
44+
@remote_digest = load_receipt.fetch(:sha256)
45+
end
46+
case arguments[0, 3]
47+
when ["gh", "release", "create"]
48+
@releases << {"tag_name" => arguments[3], "draft" => true, "target_commitish" => arguments[arguments.index("--target") + 1], "assets" => []}
49+
when ["gh", "release", "upload"]
50+
raise "Preservation failed" if @fail_preservation
51+
file = arguments[4]
52+
@stored_files[File.basename(file)] = File.binread(file)
53+
@releases.first.fetch("assets") << {"name" => File.basename(file), "digest" => "sha256:#{Digest::SHA256.file(file).hexdigest}"}
54+
when ["gh", "release", "download"], ["gh", "run", "download"]
55+
path = arguments[arguments.index("--dir") + 1]
56+
@stored_files.each{|name, content| File.binwrite(File.join(path, name), content)}
57+
when ["gh", "release", "edit"]
58+
raise "GitHub unavailable after upload" if @fail_release
59+
@releases.first["draft"] = false
60+
end
61+
true
62+
end
63+
64+
def readlines(*arguments, **options)
65+
@commands << arguments
66+
return [JSON.generate([@releases])] if arguments[0, 2] == ["gh", "api"]
67+
[]
68+
end
69+
70+
def api(path)
71+
{"artifacts" => @artifacts}
72+
end
73+
74+
private
75+
76+
def gem_command(*arguments)
77+
system("gem", *arguments, chdir: @root)
78+
end
79+
80+
def guard_environment
81+
end
82+
83+
def registry_digest(name, version)
84+
@remote_digest
85+
end
86+
87+
def registry_get(url)
88+
JSON.generate([{bundle: {mediaType: "test"}}])
89+
end
90+
end
91+
end
92+
end
93+
end

‎guides/getting-started/readme.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ Use **Re-run all jobs** on the original publishing run, or:
9595
bundle exec bake gem:github:release:resume run=RUN_ID
9696
```
9797

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.
9999

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.
101103

102104
## Development and current limits
103105

0 commit comments

Comments
 (0)